mirror of
https://github.com/logseq/logseq.git
synced 2026-05-24 12:44:22 +00:00
enhance(mobile): improve flashcards tab
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
(ns frontend.components.dnd
|
||||
(:require ["@dnd-kit/core" :refer [DndContext closestCenter MouseSensor useSensor useSensors]]
|
||||
(:require ["@dnd-kit/core" :refer [DndContext closestCenter MouseSensor TouchSensor useSensor useSensors]]
|
||||
["@dnd-kit/sortable" :refer [useSortable arrayMove SortableContext verticalListSortingStrategy horizontalListSortingStrategy] :as sortable]
|
||||
["@dnd-kit/utilities" :refer [CSS]]
|
||||
[cljs-bean.core :as bean]
|
||||
@@ -41,14 +41,17 @@
|
||||
(when (some #(nil? (:id %)) col*)
|
||||
(js/console.error "dnd-kit items without id")
|
||||
(prn :col col*))
|
||||
(let [col (filter :id col*)
|
||||
(let [col (filterv :id col*)
|
||||
ids (mapv :id col)
|
||||
ids-key (pr-str ids)
|
||||
items' (bean/->js ids)
|
||||
id->item (zipmap ids col)
|
||||
[items-state set-items] (rum/use-state items')
|
||||
_ (hooks/use-effect! (fn [] (set-items items')) [col])
|
||||
_ (hooks/use-effect! (fn [] (set-items items')) [ids-key])
|
||||
[_active-id set-active-id] (rum/use-state nil)
|
||||
sensors (useSensors (useSensor MouseSensor (bean/->js {:activationConstraint {:distance 8}})))
|
||||
sensors (useSensors (useSensor MouseSensor (bean/->js {:activationConstraint {:distance 8}}))
|
||||
(useSensor TouchSensor (bean/->js {:activationConstraint {:delay 120
|
||||
:tolerance 8}})))
|
||||
dnd-opts {:sensors sensors
|
||||
:collisionDetection closestCenter
|
||||
:onDragStart (fn [^js event]
|
||||
@@ -56,7 +59,7 @@
|
||||
(set-active-id (.-id ^js (.-active event)))))
|
||||
:onDragEnd (fn [^js event]
|
||||
(let [active-id (.-id ^js (.-active event))
|
||||
over-id (.-id ^js (.-over event))]
|
||||
over-id (some-> ^js (.-over event) .-id)]
|
||||
(when active-id
|
||||
(when-not (= active-id over-id)
|
||||
(let [old-index (.indexOf ids active-id)
|
||||
|
||||
@@ -115,6 +115,22 @@
|
||||
q)]
|
||||
(db-async/<q repo {:transact-db? false} q' card-ids now-inst-ms (:rules result))))
|
||||
|
||||
(defn- global-cards-id?
|
||||
[cards-id]
|
||||
(contains? #{:global "global"} cards-id))
|
||||
|
||||
(defn- selected-cards-title
|
||||
[all-cards cards-id]
|
||||
(or (some (fn [card]
|
||||
(when (= (:db/id card) cards-id)
|
||||
(:block/title card)))
|
||||
all-cards)
|
||||
(some (fn [card]
|
||||
(when (= (:db/id card) :global)
|
||||
(:block/title card)))
|
||||
all-cards)
|
||||
(t :flashcard/all-cards)))
|
||||
|
||||
(defn- <create-cards-block!
|
||||
[]
|
||||
(editor-handler/api-insert-new-block! ""
|
||||
@@ -123,7 +139,8 @@
|
||||
:sibling? false
|
||||
:end? true}))
|
||||
|
||||
(defn- btn-with-shortcut [{:keys [shortcut id btn-text due on-click class]}]
|
||||
(defn- btn-with-shortcut [{:keys [shortcut id btn-text due on-click class show-due? mobile?]
|
||||
:or {show-due? true}}]
|
||||
(let [bg-class (case id
|
||||
"card-again" "primary-red"
|
||||
"card-hard" "primary-purple"
|
||||
@@ -131,21 +148,29 @@
|
||||
"card-easy" "primary-green"
|
||||
nil)]
|
||||
[:div.flex.flex-row.items-center.gap-2
|
||||
{:class (when mobile? "w-full")}
|
||||
(shui/button
|
||||
{:variant :outline
|
||||
:title (t :flashcard/shortcut-tooltip shortcut)
|
||||
:auto-focus false
|
||||
:size :sm
|
||||
:id id
|
||||
:class (str id " " class " !px-2 !py-1 bg-primary/5 hover:bg-primary/10
|
||||
(cond->
|
||||
{:variant :outline
|
||||
:auto-focus false
|
||||
:size :sm
|
||||
:id id
|
||||
:class (str id " " class " "
|
||||
(if mobile?
|
||||
"!w-full !min-h-[48px] !px-4 !py-3 rounded-xl text-base justify-center "
|
||||
"!px-2 !py-1 ")
|
||||
"bg-primary/5 hover:bg-primary/10
|
||||
border-primary opacity-90 hover:opacity-100 " bg-class)
|
||||
:on-pointer-down (fn [e] (util/stop-propagation e))
|
||||
:on-click (fn [_e] (js/setTimeout #(on-click) 10))}
|
||||
:on-pointer-down (fn [e] (util/stop-propagation e))
|
||||
:on-click (fn [_e] (js/setTimeout #(on-click) 10))}
|
||||
(not mobile?)
|
||||
(assoc :title (t :flashcard/shortcut-tooltip shortcut)))
|
||||
[:div.flex.flex-row.items-center.gap-1
|
||||
[:span btn-text]
|
||||
(when-not (util/sm-breakpoint?)
|
||||
(when-not (or mobile? (util/sm-breakpoint?))
|
||||
[:span.scale-90 (shui/shortcut shortcut)])])
|
||||
(when due [:div.text-sm.opacity-50 (util/human-time due {:ago? false})])]))
|
||||
(when (and show-due? due)
|
||||
[:div.text-sm.opacity-50 (util/human-time due {:ago? false})])]))
|
||||
|
||||
(defn- has-cloze?
|
||||
[block]
|
||||
@@ -184,9 +209,10 @@
|
||||
(t (rating-key rating)))
|
||||
|
||||
(defn- rating-btns
|
||||
[repo block *card-index *phase]
|
||||
[repo block *card-index *phase opts]
|
||||
(let [block-id (:db/id block)]
|
||||
[:div.flex.flex-row.items-center.gap-8.flex-wrap
|
||||
{:class (when (:mobile? opts) "ls-mobile-card-rating-buttons")}
|
||||
(mapv
|
||||
(fn [rating]
|
||||
(let [card-map (get-card-map block)
|
||||
@@ -194,26 +220,29 @@
|
||||
(btn-with-shortcut {:btn-text (rating-label rating)
|
||||
:shortcut (rating->shortcut rating)
|
||||
:due due
|
||||
:show-due? (not (:mobile? opts))
|
||||
:mobile? (:mobile? opts)
|
||||
:id (str "card-" (name rating))
|
||||
:on-click #(do (repeat-card! repo block-id rating)
|
||||
(swap! *card-index inc)
|
||||
(reset! *phase :init))})))
|
||||
ratings)
|
||||
(shui/button
|
||||
{:variant :ghost
|
||||
:size :sm
|
||||
:class "!px-0 text-muted-foreground !h-4"
|
||||
:on-click (fn [e]
|
||||
(shui/popup-show! (.-target e)
|
||||
(fn []
|
||||
[:div.p-4.max-w-lg
|
||||
(for [rating ratings]
|
||||
^{:key (name rating)}
|
||||
[:dl
|
||||
[:dt (t (rating-key rating))]
|
||||
[:dd (t (rating-desc-key rating))]])])
|
||||
{:align "start"}))}
|
||||
(ui/icon "info-circle"))]))
|
||||
(when-not (:mobile? opts)
|
||||
(shui/button
|
||||
{:variant :ghost
|
||||
:size :sm
|
||||
:class "!px-0 text-muted-foreground !h-4"
|
||||
:on-click (fn [e]
|
||||
(shui/popup-show! (.-target e)
|
||||
(fn []
|
||||
[:div.p-4.max-w-lg
|
||||
(for [rating ratings]
|
||||
^{:key (name rating)}
|
||||
[:dl
|
||||
[:dt (t (rating-key rating))]
|
||||
[:dd (t (rating-desc-key rating))]])])
|
||||
{:align "start"}))}
|
||||
(ui/icon "info-circle")))]))
|
||||
|
||||
(rum/defcs ^:private card-view < rum/reactive db-mixins/query
|
||||
{:will-mount (fn [state]
|
||||
@@ -222,13 +251,14 @@
|
||||
(p/let [result (db-async/<get-block repo block-id {:children? true})]
|
||||
(reset! *block result))
|
||||
(assoc state ::block *block)))}
|
||||
[state repo _block-id *card-index *phase]
|
||||
[state repo _block-id *card-index *phase opts]
|
||||
(when-let [block (rum/react (::block state))]
|
||||
(when-let [block-entity (db/sub-block (:db/id block))]
|
||||
(let [phase (rum/react *phase)
|
||||
_card-index (rum/react *card-index)
|
||||
next-phase (phase->next-phase block-entity phase)]
|
||||
[:div.ls-card.content.flex.flex-col.overflow-y-auto.overflow-x-hidden
|
||||
{:class (when (:mobile? opts) "ls-mobile-card")}
|
||||
[:div.mb-4.ml-2.opacity-70.text-sm
|
||||
(component-block/breadcrumb {} repo (:block/uuid block-entity) {})]
|
||||
(let [option (case phase
|
||||
@@ -241,6 +271,7 @@
|
||||
:ignore-block-collapsed? true})]
|
||||
(component-block/blocks-container option [block-entity]))
|
||||
[:div.mt-8.pb-2
|
||||
{:class (when (:mobile? opts) "ls-mobile-card-actions")}
|
||||
(if (contains? #{:show-cloze :show-answer} next-phase)
|
||||
(btn-with-shortcut {:btn-text (case next-phase
|
||||
:show-answer
|
||||
@@ -250,11 +281,12 @@
|
||||
:init
|
||||
(t :flashcard.review/hide-answers))
|
||||
:shortcut "s"
|
||||
:mobile? (:mobile? opts)
|
||||
:id "card-answers"
|
||||
:on-click #(swap! *phase
|
||||
(fn [phase]
|
||||
(phase->next-phase block-entity phase)))})
|
||||
[:div.flex.justify-center (rating-btns repo block-entity *card-index *phase)])]]))))
|
||||
[:div.flex.justify-center (rating-btns repo block-entity *card-index *phase opts)])]]))))
|
||||
|
||||
(declare update-due-cards-count)
|
||||
(rum/defcs ^:large-vars/cleanup-todo cards-view < rum/reactive
|
||||
@@ -263,7 +295,8 @@
|
||||
{:init (fn [state]
|
||||
(let [*block-ids (atom nil)
|
||||
*loading? (atom nil)
|
||||
cards-id (last (:rum/args state))
|
||||
[cards-id opts] (:rum/args state)
|
||||
opts (or opts {})
|
||||
*cards-list (atom [{:db/id :global
|
||||
:block/title (t :flashcard/all-cards)}])
|
||||
repo (state/get-current-repo)
|
||||
@@ -290,13 +323,21 @@
|
||||
(assoc state
|
||||
::block-ids *block-ids
|
||||
::cards-id (atom (or cards-id :global))
|
||||
::opts opts
|
||||
::loading? *loading?
|
||||
::cards-list *cards-list)))
|
||||
:will-unmount (fn [state]
|
||||
(let [opts (::opts state)]
|
||||
(when-let [on-header-change (:on-header-change opts)]
|
||||
(on-header-change nil))
|
||||
(when-let [on-selector-change (:on-selector-change opts)]
|
||||
(on-selector-change nil)))
|
||||
(update-due-cards-count)
|
||||
state)}
|
||||
[state _cards-id]
|
||||
[state _cards-id _opts]
|
||||
(let [repo (state/get-current-repo)
|
||||
opts (::opts state)
|
||||
mobile? (:mobile? opts)
|
||||
*cards-id (::cards-id state)
|
||||
cards-id (rum/react *cards-id)
|
||||
*cards-list (::cards-list state)
|
||||
@@ -307,47 +348,59 @@
|
||||
block-ids (rum/react *block-ids)
|
||||
loading? (rum/react (::loading? state))
|
||||
*card-index (::card-index state)
|
||||
*phase (atom :init)]
|
||||
card-index (rum/react *card-index)
|
||||
*phase (atom :init)
|
||||
progress-label (str (min (inc card-index) (count block-ids)) "/" (count block-ids))
|
||||
select-card! (fn [v]
|
||||
(reset! *cards-id v)
|
||||
(let [cards-id' (when-not (global-cards-id? v) v)]
|
||||
(p/let [result (<get-due-card-block-ids repo cards-id')]
|
||||
(reset! *card-index 0)
|
||||
(reset! *block-ids result))))]
|
||||
(when (false? loading?)
|
||||
(when mobile?
|
||||
(when-let [on-header-change (:on-header-change opts)]
|
||||
(on-header-change {:title (selected-cards-title all-cards cards-id)
|
||||
:progress progress-label}))
|
||||
(when-let [on-selector-change (:on-selector-change opts)]
|
||||
(on-selector-change {:cards all-cards
|
||||
:cards-id cards-id
|
||||
:select-card! select-card!})))
|
||||
[:div#cards-modal.flex.flex-col.gap-8.flex-1
|
||||
[:div.flex.flex-row.items-center.gap-2.flex-wrap
|
||||
(shui/select
|
||||
{:on-value-change (fn [v]
|
||||
(reset! *cards-id v)
|
||||
(let [cards-id' (when-not (contains? #{:global "global"} v) v)]
|
||||
(p/let [result (<get-due-card-block-ids repo cards-id')]
|
||||
(reset! *card-index 0)
|
||||
(reset! *block-ids result))))
|
||||
:default-value cards-id}
|
||||
(shui/select-trigger
|
||||
{:class "!px-2 !py-0 !h-8 w-64"}
|
||||
(shui/select-value
|
||||
{:placeholder (t :flashcard/select-cards)}))
|
||||
(shui/select-content
|
||||
(shui/select-group
|
||||
(for [card-entity all-cards]
|
||||
(shui/select-item {:value (:db/id card-entity)}
|
||||
(:block/title card-entity))))))
|
||||
(shui/button
|
||||
{:variant :ghost
|
||||
:id "ls-cards-add"
|
||||
:size :sm
|
||||
:title (t :flashcard/add-query)
|
||||
:class "!px-1 text-muted-foreground"
|
||||
:on-click (fn []
|
||||
(p/let [saved-block (<create-cards-block!)]
|
||||
(shui/dialog-close!)
|
||||
(when saved-block
|
||||
(route-handler/redirect-to-page! (:block/uuid saved-block)
|
||||
{}))))}
|
||||
(ui/icon "plus"))
|
||||
[:span.text-sm.opacity-50 (str (min (inc @*card-index) (count @*block-ids)) "/" (count @*block-ids))]]
|
||||
(let [block-id (nth block-ids @*card-index nil)]
|
||||
(when-not mobile?
|
||||
[:div.flex.flex-row.items-center.gap-2
|
||||
(shui/select
|
||||
{:on-value-change select-card!
|
||||
:default-value cards-id}
|
||||
(shui/select-trigger
|
||||
{:class "!px-2 !py-0 !h-8 w-64"}
|
||||
(shui/select-value
|
||||
{:placeholder (t :flashcard/select-cards)}))
|
||||
(shui/select-content
|
||||
(shui/select-group
|
||||
(for [card-entity all-cards]
|
||||
(shui/select-item {:value (:db/id card-entity)}
|
||||
(:block/title card-entity))))))
|
||||
(shui/button
|
||||
{:variant :ghost
|
||||
:id "ls-cards-add"
|
||||
:size :sm
|
||||
:title (t :flashcard/add-query)
|
||||
:class "!px-1 text-muted-foreground"
|
||||
:on-click (fn []
|
||||
(p/let [saved-block (<create-cards-block!)]
|
||||
(shui/dialog-close!)
|
||||
(when saved-block
|
||||
(route-handler/redirect-to-page! (:block/uuid saved-block)
|
||||
{}))))}
|
||||
(ui/icon "plus"))
|
||||
[:span.text-sm.opacity-50.whitespace-nowrap progress-label]])
|
||||
(let [block-id (nth block-ids card-index nil)]
|
||||
(cond
|
||||
block-id
|
||||
[:div.flex.flex-col
|
||||
(rum/with-key
|
||||
(card-view repo block-id *card-index *phase)
|
||||
(card-view repo block-id *card-index *phase opts)
|
||||
(str "card-" block-id))]
|
||||
|
||||
(empty? block-ids)
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
(defmethod events/handle :modal/show-cards [[_ cards-id]]
|
||||
(shui/dialog-open!
|
||||
(fn [] (fsrs/cards-view cards-id))
|
||||
(fn [] (fsrs/cards-view cards-id nil))
|
||||
{:id :srs
|
||||
:label :flashcards__cp}))
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
(s/def :copy/export-block-text-other-options map?)
|
||||
(s/def ::sync-server-url string?)
|
||||
(s/def ::publish-server-url string?)
|
||||
(s/def ::ls-mobile-tabs (s/coll-of string? :kind vector?))
|
||||
;; Dynamic keys which aren't as easily validated:
|
||||
;; :ls-pdf-last-page-*
|
||||
;; :ls-js-allowed-*
|
||||
@@ -70,4 +71,5 @@
|
||||
:copy/export-block-text-remove-options
|
||||
:copy/export-block-text-other-options
|
||||
::sync-server-url
|
||||
::publish-server-url]))
|
||||
::publish-server-url
|
||||
::ls-mobile-tabs]))
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
[frontend.handler.route :as route-handler]
|
||||
[frontend.mobile.util :as mobile-util]
|
||||
[frontend.state :as state]
|
||||
[frontend.storage :as storage]
|
||||
[frontend.util :as util]
|
||||
[lambdaisland.glogi :as log]
|
||||
[mobile.navigation :as mobile-nav]
|
||||
[mobile.search :as mobile-search]
|
||||
[mobile.state :as mobile-state]
|
||||
[mobile.tabs :as mobile-tabs]
|
||||
[promesa.core :as p]))
|
||||
|
||||
;; Capacitor plugin instance (nil if native side hasn't shipped it yet).
|
||||
@@ -260,28 +262,30 @@
|
||||
(add-graph-action-listener!)
|
||||
(add-keyboard-hack-listener!)))
|
||||
|
||||
(defn- translated-tab
|
||||
[tab]
|
||||
(-> tab
|
||||
(assoc :title (t (:title-key tab)))
|
||||
(dissoc :title-key)))
|
||||
|
||||
(defn selected-tab-ids
|
||||
[]
|
||||
(mobile-tabs/selected-tab-ids
|
||||
(storage/get :ls-mobile-tabs)
|
||||
{:flashcards? (state/enable-flashcards?)}
|
||||
(mobile-tabs/max-main-tabs (mobile-util/native-iphone?))))
|
||||
|
||||
(defn configure
|
||||
[]
|
||||
(configure-tabs
|
||||
(cond->
|
||||
[{:id "home"
|
||||
:title (t :nav/home)
|
||||
:systemImage "house"
|
||||
:role "normal"}
|
||||
{:id "graphs"
|
||||
:title (t :mobile.tab/graphs)
|
||||
:systemImage "app.background.dotted"
|
||||
:role "normal"}
|
||||
{:id "capture"
|
||||
:title (t :mobile.tab/capture)
|
||||
:systemImage "tray"
|
||||
:role "normal"}
|
||||
{:id "go to"
|
||||
:title (t :mobile.tab/go-to)
|
||||
:systemImage "square.stack.3d.down.right"
|
||||
:role "normal"}]
|
||||
(mobile-util/native-android?)
|
||||
(conj {:id "search"
|
||||
:title (t :nav/search)
|
||||
:systemImage "search"
|
||||
:role "search"}))))
|
||||
(let [tabs (->> (mobile-tabs/tab-configs
|
||||
(storage/get :ls-mobile-tabs)
|
||||
{:flashcards? (state/enable-flashcards?)}
|
||||
(mobile-tabs/max-main-tabs (mobile-util/native-iphone?)))
|
||||
(mapv translated-tab))]
|
||||
(configure-tabs
|
||||
(cond-> tabs
|
||||
(mobile-util/native-android?)
|
||||
(conj {:id "search"
|
||||
:title (t :nav/search)
|
||||
:systemImage "search"
|
||||
:role "search"})))))
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
[frontend.handler.editor :as editor-handler]
|
||||
[frontend.handler.repo :as repo-handler]
|
||||
[frontend.handler.user :as user-handler]
|
||||
[frontend.extensions.fsrs :as fsrs]
|
||||
[frontend.mobile.util :as mobile-util]
|
||||
[frontend.rum :as frum]
|
||||
[frontend.state :as state]
|
||||
@@ -247,6 +248,16 @@
|
||||
[]
|
||||
(quick-add/quick-add))
|
||||
|
||||
(rum/defc flashcards <
|
||||
{:did-mount (fn [state]
|
||||
(fsrs/update-due-cards-count)
|
||||
state)}
|
||||
[]
|
||||
[:div.ls-mobile-flashcards
|
||||
(fsrs/cards-view nil {:mobile? true
|
||||
:on-header-change mobile-state/set-flashcards-header!
|
||||
:on-selector-change mobile-state/set-flashcards-selector!})])
|
||||
|
||||
(rum/defc other-page < rum/static
|
||||
[route-view tab route-match]
|
||||
(let [page-view? (= (get-in route-match [:data :name]) :page)]
|
||||
@@ -260,6 +271,7 @@
|
||||
(graphs/page))
|
||||
(= tab "go to") (favorites/favorites)
|
||||
(= tab "search") nil
|
||||
(= tab "flashcards") (component-with-restoring (flashcards))
|
||||
(= tab "capture") (component-with-restoring (capture))))]))
|
||||
|
||||
(rum/defc main-content < rum/static
|
||||
|
||||
@@ -133,6 +133,50 @@ ul {
|
||||
@apply mx-8 p-0;
|
||||
}
|
||||
|
||||
.ls-mobile-flashcards {
|
||||
@apply h-full flex flex-col px-1 py-2;
|
||||
|
||||
#cards-modal {
|
||||
@apply h-full gap-2;
|
||||
}
|
||||
|
||||
#cards-modal .ls-card {
|
||||
@apply flex-1 border-0 bg-transparent px-0 py-2 shadow-none;
|
||||
}
|
||||
|
||||
#cards-modal .ls-card > .blocks-container {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
#cards-modal .ls-card.content.ml-2 {
|
||||
@apply ml-0 flex-none;
|
||||
}
|
||||
|
||||
#cards-modal button {
|
||||
@apply rounded-md;
|
||||
}
|
||||
|
||||
.ls-mobile-card {
|
||||
@apply min-h-0;
|
||||
}
|
||||
|
||||
.ls-mobile-card > .mb-4 {
|
||||
@apply ml-0 mb-3;
|
||||
}
|
||||
|
||||
.ls-mobile-card-actions {
|
||||
@apply mt-4 pb-0;
|
||||
}
|
||||
|
||||
.ls-mobile-card-rating-buttons {
|
||||
@apply grid grid-cols-2 gap-2 w-full;
|
||||
}
|
||||
|
||||
.ls-mobile-card-rating-buttons > div {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
|
||||
.ui__notifications {
|
||||
@apply fixed top-8 pointer-events-none w-full;
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
(defonce native-top-bar-listener? (atom false))
|
||||
(defonce native-top-bar-listener-version (atom nil))
|
||||
(defonce *journal-calendar-open? (atom false))
|
||||
(def ^:private native-top-bar-listener-current-version :sync-upload-v2)
|
||||
(def ^:private native-top-bar-listener-current-version :flashcards-title-selector-v1)
|
||||
|
||||
(defn- open-journal-calendar! []
|
||||
(when (compare-and-set! *journal-calendar-open? false true)
|
||||
@@ -132,9 +132,40 @@
|
||||
{:on-click #(p/do!
|
||||
(shui/popup-hide!)
|
||||
(open-new-db-graph!))}
|
||||
(t :mobile.header/create-graph))])
|
||||
(t :mobile.header/create-graph))])
|
||||
{:default-height false}))
|
||||
|
||||
(defn- global-cards-id?
|
||||
[cards-id]
|
||||
(contains? #{:global "global"} cards-id))
|
||||
|
||||
(defn- same-cards-id?
|
||||
[a b]
|
||||
(or (= a b)
|
||||
(and (global-cards-id? a)
|
||||
(global-cards-id? b))))
|
||||
|
||||
(defn- open-flashcards-selector!
|
||||
[]
|
||||
(when-let [{:keys [cards cards-id select-card!]} @mobile-state/*flashcards-selector]
|
||||
(ui-component/open-popup!
|
||||
(fn []
|
||||
[:div.-mx-2
|
||||
(for [card cards
|
||||
:let [card-id (:db/id card)]]
|
||||
(ui/menu-link
|
||||
{:key (str card-id)
|
||||
:on-click (fn []
|
||||
(p/do!
|
||||
(select-card! card-id)
|
||||
(shui/popup-hide!)))}
|
||||
[:span.text-lg.flex.items-center.justify-between.gap-3.w-full
|
||||
[:span.min-w-0.truncate (:block/title card)]
|
||||
(when (same-cards-id? cards-id card-id)
|
||||
(shui/tabler-icon "check" {:class "text-primary flex-none" :size 20}))]))])
|
||||
{:title (t :flashcard/select-cards)
|
||||
:default-height false})))
|
||||
|
||||
(defn current-local-uploadable-graph
|
||||
[]
|
||||
(let [current-repo (state/get-current-repo)]
|
||||
@@ -152,7 +183,9 @@
|
||||
(fn [^js e]
|
||||
(case (.-id e)
|
||||
"back" (js/history.back)
|
||||
"title" (open-graph-switch!)
|
||||
"title" (if (= @mobile-state/*tab "flashcards")
|
||||
(open-flashcards-selector!)
|
||||
(open-graph-switch!))
|
||||
"calendar" (open-journal-calendar!)
|
||||
"capture" (do
|
||||
(state/clear-edit!)
|
||||
@@ -234,12 +267,42 @@
|
||||
header (cond-> base
|
||||
left-buttons (assoc :leftButtons left-buttons)
|
||||
right-buttons (assoc :rightButtons right-buttons)
|
||||
(and (= tab "home") (not route-view)) (assoc :titleClickable true))]
|
||||
(and (contains? #{"home" "flashcards"} tab) (not route-view))
|
||||
(assoc :titleClickable true))]
|
||||
(.configure ^js mobile-util/native-top-bar
|
||||
(clj->js header)))))
|
||||
|
||||
(defn- flashcards-native-title
|
||||
[{:keys [title progress]}]
|
||||
(let [title (if (string/blank? title)
|
||||
(t :nav/flashcards)
|
||||
title)]
|
||||
(string/trim (str title " ▾"
|
||||
(when-not (string/blank? progress)
|
||||
(str " " progress))))))
|
||||
|
||||
(defn- build-fallback-title
|
||||
[current-repo tab flashcards-header]
|
||||
(cond
|
||||
(= tab "home")
|
||||
(if current-repo
|
||||
(db-conn/get-short-repo-name current-repo)
|
||||
(t :graph.switch/select-prompt))
|
||||
|
||||
(= tab "search")
|
||||
(t :nav/search)
|
||||
|
||||
(= tab "graphs")
|
||||
(t :mobile.tab/graphs)
|
||||
|
||||
(= tab "flashcards")
|
||||
(flashcards-native-title flashcards-header)
|
||||
|
||||
:else
|
||||
(string/capitalize tab)))
|
||||
|
||||
(rum/defc header-inner
|
||||
[current-repo tab route-match]
|
||||
[current-repo tab route-match flashcards-header]
|
||||
(let [route-name (get-in route-match [:data :name])
|
||||
route-view (get-in route-match [:data :view])
|
||||
route-id (get-in route-match [:parameters :path :name])
|
||||
@@ -257,20 +320,7 @@
|
||||
show-local-upload? (some? local-uploadable-graph)
|
||||
unpushed-block-update-count (:pending-local-ops detail-info)
|
||||
pending-asset-ops (:pending-asset-ops detail-info)
|
||||
fallback-title (cond
|
||||
(= tab "home")
|
||||
(if current-repo
|
||||
(db-conn/get-short-repo-name current-repo)
|
||||
(t :graph.switch/select-prompt))
|
||||
|
||||
(= tab "search")
|
||||
(t :nav/search)
|
||||
|
||||
(= tab "graphs")
|
||||
(t :mobile.tab/graphs)
|
||||
|
||||
:else
|
||||
(string/capitalize tab))
|
||||
fallback-title (build-fallback-title current-repo tab flashcards-header)
|
||||
sync-color (if (and online?
|
||||
(= :open rtc-state)
|
||||
(zero? unpushed-block-update-count)
|
||||
@@ -341,6 +391,8 @@
|
||||
|
||||
(rum/defc header < rum/reactive
|
||||
[current-repo tab]
|
||||
(let [route-match (state/sub :route-match)]
|
||||
(let [route-match (state/sub :route-match)
|
||||
flashcards-header (rum/react mobile-state/*flashcards-header)]
|
||||
(header-inner current-repo tab
|
||||
route-match)))
|
||||
route-match
|
||||
flashcards-header)))
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
"Mobile settings"
|
||||
(:require [clojure.string :as string]
|
||||
[frontend.common.missionary :as c.m]
|
||||
[frontend.components.dnd :as dnd]
|
||||
[frontend.components.user.login :as login]
|
||||
[frontend.config :as config]
|
||||
[frontend.context.i18n :refer [t]]
|
||||
[frontend.handler.user :as user-handler]
|
||||
[frontend.mobile.util :as mobile-util]
|
||||
[frontend.state :as state]
|
||||
[frontend.storage :as storage]
|
||||
[frontend.ui :as ui]
|
||||
[frontend.util :as util]
|
||||
[frontend.version :as version]
|
||||
@@ -14,7 +17,9 @@
|
||||
[logseq.shui.hooks :as hooks]
|
||||
[logseq.shui.ui :as shui]
|
||||
[missionary.core :as m]
|
||||
[mobile.bottom-tabs :as bottom-tabs]
|
||||
[mobile.state :as mobile-state]
|
||||
[mobile.tabs :as mobile-tabs]
|
||||
[promesa.core :as p]
|
||||
[rum.core :as rum]))
|
||||
|
||||
@@ -111,6 +116,94 @@
|
||||
(for [record records]
|
||||
[:li (str (:level record) " " (:message record))])])]))
|
||||
|
||||
(defn- persist-mobile-tabs!
|
||||
[tab-ids]
|
||||
(storage/set :ls-mobile-tabs tab-ids)
|
||||
(when-not (contains? (set tab-ids) @mobile-state/*tab)
|
||||
(mobile-state/set-tab! mobile-tabs/required-tab-id))
|
||||
(bottom-tabs/configure))
|
||||
|
||||
(defn- selected-mobile-tabs-label
|
||||
[]
|
||||
(let [available-tabs (mobile-tabs/available-tabs
|
||||
{:flashcards? (state/enable-flashcards?)})
|
||||
available-tabs-by-id (zipmap (map :id available-tabs) available-tabs)]
|
||||
(->> (bottom-tabs/selected-tab-ids)
|
||||
(keep #(some-> (get available-tabs-by-id %) :title-key t))
|
||||
(string/join " · "))))
|
||||
|
||||
(defn- mobile-tab-picker-row
|
||||
[{:keys [id title-key checked? disabled? sortable? toggle-tab! key]}]
|
||||
[:label.flex.items-center.justify-between.gap-3.py-2
|
||||
{:key (or key id)
|
||||
:class (util/classnames
|
||||
[{:opacity-50 disabled?}])}
|
||||
[:span.flex.items-center.gap-2.min-w-0
|
||||
[:span.text-muted-foreground
|
||||
{:class (if sortable? "cursor-grab" "opacity-30")}
|
||||
(shui/tabler-icon "grip-vertical" {:size 14})]
|
||||
[:span.text-base.truncate (t title-key)]]
|
||||
(shui/checkbox
|
||||
{:checked checked?
|
||||
:disabled disabled?
|
||||
:on-checked-change #(toggle-tab! id %)})])
|
||||
|
||||
(rum/defc mobile-tabs-picker
|
||||
[]
|
||||
(let [[custom-tab-ids set-custom-tab-ids!] (hooks/use-state
|
||||
(storage/get :ls-mobile-tabs))
|
||||
features {:flashcards? (state/enable-flashcards?)}
|
||||
max-tabs (mobile-tabs/max-main-tabs (mobile-util/native-iphone?))
|
||||
selected-tab-ids (mobile-tabs/selected-tab-ids custom-tab-ids features max-tabs)
|
||||
selected-tab-id-set (set selected-tab-ids)
|
||||
available-tabs (mobile-tabs/available-tabs features)
|
||||
available-tabs-by-id (zipmap (map :id available-tabs) available-tabs)
|
||||
toggle-tab! (fn [id checked?]
|
||||
(let [next-requested-ids (if checked?
|
||||
(conj selected-tab-ids id)
|
||||
(filterv #(not= id %) selected-tab-ids))
|
||||
next-tab-ids (mobile-tabs/selected-tab-ids next-requested-ids
|
||||
features
|
||||
max-tabs)]
|
||||
(set-custom-tab-ids! next-tab-ids)
|
||||
(persist-mobile-tabs! next-tab-ids)))
|
||||
reorder-tab! (fn [tab-ids]
|
||||
(let [next-tab-ids (mobile-tabs/selected-tab-ids tab-ids features max-tabs)]
|
||||
(set-custom-tab-ids! next-tab-ids)
|
||||
(persist-mobile-tabs! next-tab-ids)))
|
||||
selected-tabs (keep available-tabs-by-id selected-tab-ids)
|
||||
unselected-tabs (remove #(contains? selected-tab-id-set (:id %)) available-tabs)]
|
||||
[:div.p-4.space-y-3.min-w-64
|
||||
[:div.text-lg.font-medium (t :mobile.settings/tabs)]
|
||||
[:div.space-y-2
|
||||
(dnd/items
|
||||
(mapv
|
||||
(fn [{:keys [id title-key]}]
|
||||
(let [required? (= id mobile-tabs/required-tab-id)]
|
||||
{:id id
|
||||
:value id
|
||||
:disabled? required?
|
||||
:content (mobile-tab-picker-row
|
||||
{:id id
|
||||
:title-key title-key
|
||||
:checked? true
|
||||
:disabled? required?
|
||||
:sortable? (not required?)
|
||||
:toggle-tab! toggle-tab!})}))
|
||||
selected-tabs)
|
||||
{:on-drag-end (fn [tab-ids _drag]
|
||||
(reorder-tab! tab-ids))})
|
||||
(for [{:keys [id title-key]} unselected-tabs
|
||||
:let [disabled? (>= (count selected-tab-ids) max-tabs)]]
|
||||
(mobile-tab-picker-row
|
||||
{:id id
|
||||
:key id
|
||||
:title-key title-key
|
||||
:checked? false
|
||||
:disabled? disabled?
|
||||
:sortable? false
|
||||
:toggle-tab! toggle-tab!}))]]))
|
||||
|
||||
(rum/defc page < rum/reactive
|
||||
[]
|
||||
(let [login? (and (state/sub :auth/id-token)
|
||||
@@ -133,6 +226,12 @@
|
||||
[:span.text-base (t :mobile.settings/version)]
|
||||
[:span.text-sm version/version]]
|
||||
|
||||
[:div.mobile-setting-item
|
||||
{:on-click (fn []
|
||||
(shui/popup-show! nil (fn [] (mobile-tabs-picker)) {}))}
|
||||
[:span.text-base (t :mobile.settings/tabs)]
|
||||
[:span.text-sm.opacity-70 (selected-mobile-tabs-label)]]
|
||||
|
||||
(let [revision (string/replace (build-version/revision) "-dirty" "")]
|
||||
[:div.mobile-setting-item
|
||||
{:on-click (fn []
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(defonce *search-input (atom ""))
|
||||
|
||||
(defonce *tab (atom "home"))
|
||||
|
||||
(defn set-tab! [tab]
|
||||
(let [prev @*tab]
|
||||
;; When leaving the search tab, clear its stack so reopening starts fresh.
|
||||
@@ -26,6 +27,16 @@
|
||||
(when data
|
||||
(state/pub-event! [:mobile/clear-edit])))
|
||||
|
||||
(defonce *flashcards-header (atom nil))
|
||||
(defn set-flashcards-header!
|
||||
[data]
|
||||
(reset! *flashcards-header data))
|
||||
|
||||
(defonce *flashcards-selector (atom nil))
|
||||
(defn set-flashcards-selector!
|
||||
[data]
|
||||
(reset! *flashcards-selector data))
|
||||
|
||||
(defonce *log (atom []))
|
||||
(defn log-append!
|
||||
[record]
|
||||
|
||||
82
src/main/mobile/tabs.cljs
Normal file
82
src/main/mobile/tabs.cljs
Normal file
@@ -0,0 +1,82 @@
|
||||
(ns mobile.tabs
|
||||
"Mobile bottom tab definitions and selection rules.")
|
||||
|
||||
(def ^:private tab-definitions
|
||||
[{:id "home"
|
||||
:title-key :nav/home
|
||||
:systemImage "house"
|
||||
:role "normal"}
|
||||
{:id "graphs"
|
||||
:title-key :mobile.tab/graphs
|
||||
:systemImage "app.background.dotted"
|
||||
:role "normal"}
|
||||
{:id "capture"
|
||||
:title-key :mobile.tab/capture
|
||||
:systemImage "tray"
|
||||
:role "normal"}
|
||||
{:id "flashcards"
|
||||
:title-key :nav/flashcards
|
||||
:systemImage "infinity"
|
||||
:role "normal"}
|
||||
{:id "go to"
|
||||
:title-key :mobile.tab/go-to
|
||||
:systemImage "square.stack.3d.down.right"
|
||||
:role "normal"}])
|
||||
|
||||
(def default-tab-ids
|
||||
(mapv :id tab-definitions))
|
||||
|
||||
(def required-tab-id "home")
|
||||
|
||||
(defn max-main-tabs
|
||||
[native-iphone?]
|
||||
(if native-iphone? 4 5))
|
||||
|
||||
(defn available-tabs
|
||||
[{:keys [flashcards?]}]
|
||||
(cond->> tab-definitions
|
||||
(not flashcards?) (remove #(= "flashcards" (:id %)))
|
||||
true vec))
|
||||
|
||||
(defn selected-tab-ids
|
||||
[custom-tab-ids features max-tabs]
|
||||
(let [available-ids (set (map :id (available-tabs features)))
|
||||
requested-ids (if (seq custom-tab-ids) custom-tab-ids default-tab-ids)
|
||||
valid-ids (->> requested-ids
|
||||
distinct
|
||||
(filter available-ids))
|
||||
required-ids (cons required-tab-id
|
||||
(remove #(= required-tab-id %) valid-ids))]
|
||||
(->> required-ids
|
||||
(take max-tabs)
|
||||
vec)))
|
||||
|
||||
(defn reorder-tab-ids
|
||||
[tab-ids dragged-id target-id features max-tabs]
|
||||
(let [selected-ids (selected-tab-ids tab-ids features max-tabs)
|
||||
selected-id-set (set selected-ids)
|
||||
dragged-index (.indexOf selected-ids dragged-id)
|
||||
target-index (.indexOf selected-ids target-id)]
|
||||
(if (or (= dragged-id required-tab-id)
|
||||
(= target-id required-tab-id)
|
||||
(= dragged-id target-id)
|
||||
(not (contains? selected-id-set dragged-id))
|
||||
(not (contains? selected-id-set target-id)))
|
||||
selected-ids
|
||||
(let [without-dragged (filterv #(not= dragged-id %) selected-ids)]
|
||||
(->> without-dragged
|
||||
(mapcat
|
||||
(fn [id]
|
||||
(if (= id target-id)
|
||||
(if (> target-index dragged-index)
|
||||
[id dragged-id]
|
||||
[dragged-id id])
|
||||
[id])))
|
||||
vec)))))
|
||||
|
||||
(defn tab-configs
|
||||
[custom-tab-ids features max-tabs]
|
||||
(let [tabs-by-id (zipmap (map :id tab-definitions) tab-definitions)]
|
||||
(->> (selected-tab-ids custom-tab-ids features max-tabs)
|
||||
(keep tabs-by-id)
|
||||
vec)))
|
||||
@@ -992,6 +992,7 @@
|
||||
:mobile.settings/github "GitHub"
|
||||
:mobile.settings/report-bug "Report bug"
|
||||
:mobile.settings/revision "Revision"
|
||||
:mobile.settings/tabs "Tabs"
|
||||
:mobile.settings/theme "Theme"
|
||||
:mobile.settings/version "Version"
|
||||
|
||||
|
||||
@@ -988,6 +988,7 @@
|
||||
:mobile.settings/github "GitHub 仓库"
|
||||
:mobile.settings/report-bug "报告问题"
|
||||
:mobile.settings/revision "修订"
|
||||
:mobile.settings/tabs "标签页"
|
||||
:mobile.settings/theme "主题"
|
||||
:mobile.settings/version "版本"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
(:require [cljs.test :refer [deftest is testing use-fixtures]]
|
||||
[frontend.handler.route :as route-handler]
|
||||
[frontend.mobile.util :as mobile-util]
|
||||
[frontend.state :as state]
|
||||
[mobile.navigation :as mobile-nav]
|
||||
[mobile.state :as mobile-state]))
|
||||
|
||||
@@ -118,3 +119,14 @@
|
||||
(mobile-state/set-tab! "home")
|
||||
(is (= "Hello" @mobile-state/*search-input))
|
||||
(is (= [[:home {} {}]] @replace-calls))))))
|
||||
|
||||
(deftest selecting-flashcards-does-not-open-cards-dialog
|
||||
(testing "flashcards is a normal tab surface, not a modal shortcut"
|
||||
(let [events (atom [])
|
||||
stacks (atom [])]
|
||||
(with-redefs [state/pub-event! (fn [event] (swap! events conj event))
|
||||
mobile-nav/switch-stack! (fn [stack] (swap! stacks conj stack))]
|
||||
(mobile-state/set-tab! "flashcards")
|
||||
(is (= "flashcards" @mobile-state/*tab))
|
||||
(is (= ["flashcards"] @stacks))
|
||||
(is (empty? @events))))))
|
||||
|
||||
75
src/test/mobile/tabs_test.cljs
Normal file
75
src/test/mobile/tabs_test.cljs
Normal file
@@ -0,0 +1,75 @@
|
||||
(ns mobile.tabs-test
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[mobile.tabs :as tabs]))
|
||||
|
||||
(deftest iphone-limits-main-tabs-to-four
|
||||
(testing "iPhone keeps four configurable content tabs; native search is separate"
|
||||
(is (= ["home" "graphs" "capture" "flashcards"]
|
||||
(tabs/selected-tab-ids nil {:flashcards? true} (tabs/max-main-tabs true)))))
|
||||
|
||||
(testing "custom tab selections are respected before applying the iPhone cap"
|
||||
(is (= ["home" "graphs" "go to" "capture"]
|
||||
(tabs/selected-tab-ids ["home" "graphs" "go to" "capture" "flashcards"]
|
||||
{:flashcards? true}
|
||||
(tabs/max-main-tabs true))))))
|
||||
|
||||
(deftest selected-tabs-ignore-unavailable-tabs
|
||||
(testing "disabled flashcards and unknown tab ids are removed"
|
||||
(is (= ["home" "graphs" "go to"]
|
||||
(tabs/selected-tab-ids ["unknown" "home" "flashcards" "graphs" "go to"]
|
||||
{:flashcards? false}
|
||||
(tabs/max-main-tabs false))))))
|
||||
|
||||
(deftest selected-tabs-always-include-home
|
||||
(testing "home is kept as the stable first tab even when custom data omits it"
|
||||
(is (= ["home" "graphs" "capture"]
|
||||
(tabs/selected-tab-ids ["graphs" "capture"]
|
||||
{:flashcards? true}
|
||||
(tabs/max-main-tabs false)))))
|
||||
|
||||
(testing "home is moved back to the first position when custom data reorders it"
|
||||
(is (= ["home" "graphs" "capture"]
|
||||
(tabs/selected-tab-ids ["graphs" "home" "capture"]
|
||||
{:flashcards? true}
|
||||
(tabs/max-main-tabs false))))))
|
||||
|
||||
(deftest reorder-selected-tabs
|
||||
(testing "dragging a selected tab before another selected tab changes tab order"
|
||||
(is (= ["home" "flashcards" "graphs" "capture"]
|
||||
(tabs/reorder-tab-ids ["home" "graphs" "capture" "flashcards"]
|
||||
"flashcards"
|
||||
"graphs"
|
||||
{:flashcards? true}
|
||||
(tabs/max-main-tabs true)))))
|
||||
|
||||
(testing "dragging a selected tab down inserts it after the target tab"
|
||||
(is (= ["home" "capture" "flashcards" "graphs"]
|
||||
(tabs/reorder-tab-ids ["home" "graphs" "capture" "flashcards"]
|
||||
"graphs"
|
||||
"flashcards"
|
||||
{:flashcards? true}
|
||||
(tabs/max-main-tabs true)))))
|
||||
|
||||
(testing "dragging home is ignored because it is the stable first tab"
|
||||
(is (= ["home" "graphs" "capture" "flashcards"]
|
||||
(tabs/reorder-tab-ids ["home" "graphs" "capture" "flashcards"]
|
||||
"home"
|
||||
"capture"
|
||||
{:flashcards? true}
|
||||
(tabs/max-main-tabs true)))))
|
||||
|
||||
(testing "dragging before home is ignored because home is pinned"
|
||||
(is (= ["home" "graphs" "capture" "flashcards"]
|
||||
(tabs/reorder-tab-ids ["home" "graphs" "capture" "flashcards"]
|
||||
"capture"
|
||||
"home"
|
||||
{:flashcards? true}
|
||||
(tabs/max-main-tabs true)))))
|
||||
|
||||
(testing "reordering ignores unavailable targets"
|
||||
(is (= ["home" "graphs" "capture"]
|
||||
(tabs/reorder-tab-ids ["home" "graphs" "capture"]
|
||||
"capture"
|
||||
"flashcards"
|
||||
{:flashcards? false}
|
||||
(tabs/max-main-tabs false))))))
|
||||
Reference in New Issue
Block a user