| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522 |
- (ns frontend.components.left-sidebar
- "App left sidebar"
- (:require [clojure.string :as string]
- [electron.ipc :as ipc]
- [frontend.components.block :as block]
- [frontend.components.dnd :as dnd-component]
- [frontend.components.icon :as icon]
- [frontend.components.repo :as repo]
- [frontend.config :as config]
- [frontend.context.i18n :refer [t tt]]
- [frontend.db :as db]
- [frontend.db-mixins :as db-mixins]
- [frontend.db.model :as db-model]
- [frontend.extensions.fsrs :as fsrs]
- [frontend.handler.block :as block-handler]
- [frontend.handler.page :as page-handler]
- [frontend.handler.recent :as recent-handler]
- [frontend.handler.route :as route-handler]
- [frontend.modules.shortcut.data-helper :as shortcut-dh]
- [frontend.modules.shortcut.utils :as shortcut-utils]
- [frontend.state :as state]
- [frontend.storage :as storage]
- [frontend.ui :as ui]
- [frontend.util :as util]
- [frontend.util.page :as page-util]
- [goog.object :as gobj]
- [logseq.db :as ldb]
- [logseq.shui.hooks :as hooks]
- [logseq.shui.ui :as shui]
- [react-draggable]
- [reitit.frontend.easy :as rfe]
- [rum.core :as rum]))
- (defn get-default-home-if-valid
- []
- (when-let [default-home (state/get-default-home)]
- (let [page (:page default-home)
- page (when (and (string? page)
- (not (string/blank? page)))
- (db/get-page page))]
- (if page
- default-home
- (dissoc default-home :page)))))
- (rum/defc ^:large-vars/cleanup-todo page-name < rum/reactive db-mixins/query
- [page recent?]
- (when-let [id (:db/id page)]
- (let [page (db/sub-block id)
- icon (icon/get-node-icon-cp page {:size 16})
- title (:block/title page)
- untitled? (db-model/untitled-page? title)
- name (:block/name page)
- file-rpath (when (util/electron?) (page-util/get-page-file-rpath name))
- ctx-icon #(shui/tabler-icon %1 {:class "scale-90 pr-1 opacity-80"})
- open-in-sidebar #(state/sidebar-add-block!
- (state/get-current-repo)
- (:db/id page)
- :page)
- x-menu-content (fn []
- (let [x-menu-item shui/dropdown-menu-item
- x-menu-shortcut shui/dropdown-menu-shortcut]
- [:<>
- (when-not recent?
- (x-menu-item
- {:key "unfavorite"
- :on-click #(page-handler/<unfavorite-page! (str (:block/uuid page)))}
- (ctx-icon "star-off")
- (t :page/unfavorite)
- (x-menu-shortcut (when-let [binding (shortcut-dh/shortcut-binding :command/toggle-favorite)]
- (some-> binding
- (first)
- (shortcut-utils/decorate-binding))))))
- (when-let [page-fpath (and (util/electron?) file-rpath
- (config/get-repo-fpath (state/get-current-repo) file-rpath))]
- [:<>
- (x-menu-item
- {:key "open-in-folder"
- :on-click #(ipc/ipc :openFileInFolder page-fpath)}
- (ctx-icon "folder")
- (t :page/open-in-finder))
- (x-menu-item
- {:key "open with default app"
- :on-click #(js/window.apis.openPath page-fpath)}
- (ctx-icon "file")
- (t :page/open-with-default-app))])
- (x-menu-item
- {:key "open in sidebar"
- :on-click open-in-sidebar}
- (ctx-icon "layout-sidebar-right")
- (t :content/open-in-sidebar)
- (x-menu-shortcut (shortcut-utils/decorate-binding "shift+click")))]))]
- ;; TODO: move to standalone component
- [:a.link-item.group
- (if (util/mobile?)
- {:on-pointer-down util/stop-propagation
- :on-pointer-up (fn [_e]
- (route-handler/redirect-to-page! (:block/uuid page) {:click-from-recent? recent?}))}
- (cond->
- {:on-click
- (fn [e]
- (if (gobj/get e "shiftKey")
- (open-in-sidebar)
- (route-handler/redirect-to-page! (:block/uuid page) {:click-from-recent? recent?})))
- :on-context-menu (fn [^js e]
- (shui/popup-show! e (x-menu-content)
- {:as-dropdown? true
- :content-props {:on-click (fn [] (shui/popup-hide!))
- :class "w-60"}})
- (util/stop e))}
- (ldb/object? page)
- (assoc :title (block-handler/block-unique-title page))))
- [:span.page-icon {:key "page-icon"} icon]
- [:span.page-title {:key "title"
- :class (when untitled? "opacity-50")}
- (cond
- (not (db/page? page))
- (block/inline-text :markdown (string/replace (apply str (take 64 (:block/title page))) "\n" " "))
- untitled? (t :untitled)
- :else (block-handler/block-unique-title page))]
- ;; dots trigger
- (shui/button
- {:key "more actions"
- :size :sm
- :variant :ghost
- :class "absolute !bg-transparent right-0 top-0 px-1.5 scale-75 opacity-40 hidden group-hover:block hover:opacity-80 active:opacity-100"
- :on-click #(do
- (shui/popup-show! (.-target %) (x-menu-content)
- {:as-dropdown? true
- :content-props {:on-click (fn [] (shui/popup-hide!))
- :class "w-60"}})
- (util/stop %))}
- [:i.relative {:style {:top "4px"}} (shui/tabler-icon "dots")])])))
- (defn sidebar-item
- [{:keys [on-click-handler class title icon icon-extension? active href shortcut more]}]
- [:div
- {:key class
- :class (util/classnames [class {:active active}])}
- [:a.item.group.flex.items-center.text-sm.rounded-md.font-medium
- {:on-click on-click-handler
- :class (when active "active")
- :href href}
- (ui/icon (str icon) {:extension? icon-extension? :size 16})
- [:span.flex-1 title]
- (when shortcut
- [:span.ml-1
- (ui/render-keyboard-shortcut
- (ui/keyboard-shortcut-from-config shortcut {:pick-first? true}))])
- more]])
- (rum/defc sidebar-graphs
- []
- [:div.sidebar-graphs
- (repo/graphs-selector)])
- (rum/defc sidebar-navigations-edit-content
- [{:keys [_id navs checked-navs set-checked-navs!]}]
- (let [[local-navs set-local-navs!] (rum/use-state checked-navs)]
- (hooks/use-effect!
- (fn []
- (set-checked-navs! local-navs))
- [local-navs])
- (for [nav navs
- :let [name' (name nav)]]
- (shui/dropdown-menu-checkbox-item
- {:checked (contains? (set local-navs) nav)
- :onCheckedChange (fn [v] (set-local-navs!
- (fn []
- (if v
- (conj local-navs nav)
- (filterv #(not= nav %) local-navs)))))}
- (tt (keyword "left-side-bar" name')
- (keyword "right-side-bar" name'))))))
- (rum/defc sidebar-content-group < rum/reactive
- [name {:keys [class count more header-props enter-show-more? collapsable?]} child]
- (let [collapsed? (state/sub [:ui/navigation-item-collapsed? class])]
- [:div.sidebar-content-group
- {:class (util/classnames [class {:is-expand (not collapsed?)
- :has-children (and (number? count) (> count 0))}])}
- [:div.sidebar-content-group-inner
- [:div.hd.items-center
- (cond-> (merge header-props
- {:class (util/classnames [(:class header-props)
- {:non-collapsable (false? collapsable?)
- :enter-show-more (true? enter-show-more?)}])})
- (not (false? collapsable?))
- (assoc :on-click (fn [^js/MouseEvent _e]
- (state/toggle-navigation-item-collapsed! class))))
- [:span.a name]
- [:span.b (or more (ui/icon "chevron-right" {:class "more" :size 15}))]]
- (when child [:div.bd child])]]))
- (rum/defc ^:large-vars/cleanup-todo sidebar-navigations
- [{:keys [default-home route-match route-name srs-open?]}]
- (let [navs [:flashcards :all-pages :graph-view :tag/tasks :tag/assets]
- [checked-navs set-checked-navs!] (rum/use-state (or (storage/get :ls-sidebar-navigations)
- [:flashcards :all-pages :graph-view]))]
- (hooks/use-effect!
- (fn []
- (when (vector? checked-navs)
- (storage/set :ls-sidebar-navigations checked-navs)))
- [checked-navs])
- (sidebar-content-group
- [:a.wrap-th [:strong.flex-1 "Navigations"]]
- {:collapsable? false
- :enter-show-more? true
- :header-props {:on-click (fn [^js e] (when-let [^js _el (some-> (.-target e) (.closest ".as-edit"))]
- (shui/popup-show! _el
- #(sidebar-navigations-edit-content
- {:id (:id %) :navs navs
- :checked-navs checked-navs
- :set-checked-navs! set-checked-navs!})
- {:as-dropdown? false})))}
- :more [:a.as-edit {:class "!opacity-60 hover:!opacity-80 relative -top-0.5 -right-0.5"}
- (shui/tabler-icon "filter-edit" {:size 14})]}
- [:div.sidebar-navigations.flex.flex-col.mt-1
- ;; required custom home page
- (let [page (:page default-home)
- enable-journals? (state/enable-journals? (state/get-current-repo))]
- (if (and page (not enable-journals?))
- (sidebar-item
- {:class "home-nav"
- :title page
- :on-click-handler route-handler/redirect-to-home!
- :active (and (not srs-open?)
- (= route-name :page)
- (= page (get-in route-match [:path-params :name])))
- :icon "home"
- :shortcut :go/home})
- (when enable-journals?
- (sidebar-item
- {:class "journals-nav"
- :active (and (not srs-open?)
- (or (= route-name :all-journals) (= route-name :home)))
- :title (t :left-side-bar/journals)
- :on-click-handler (fn [e]
- (if (gobj/get e "shiftKey")
- (route-handler/sidebar-journals!)
- (route-handler/go-to-journals!)))
- :icon "calendar"
- :shortcut :go/journals}))))
- (for [nav checked-navs]
- (cond
- (= nav :flashcards)
- (when (state/enable-flashcards? (state/get-current-repo))
- (let [num (state/sub :srs/cards-due-count)]
- (sidebar-item
- {:class "flashcards-nav"
- :title (t :right-side-bar/flashcards)
- :icon "infinity"
- :shortcut :go/flashcards
- :active srs-open?
- :on-click-handler #(do (fsrs/update-due-cards-count)
- (state/pub-event! [:modal/show-cards]))
- :more (when (and num (not (zero? num)))
- [:span.ml-1.inline-block.py-0.5.px-3.text-xs.font-medium.rounded-full.fade-in num])})))
- (= nav :graph-view)
- (sidebar-item
- {:class "graph-view-nav"
- :title (t :right-side-bar/graph-view)
- :href (rfe/href :graph)
- :active (and (not srs-open?) (= route-name :graph))
- :icon "hierarchy"
- :shortcut :go/graph-view})
- (= nav :all-pages)
- (sidebar-item
- {:class "all-pages-nav"
- :title (t :right-side-bar/all-pages)
- :href (rfe/href :all-pages)
- :active (and (not srs-open?) (= route-name :all-pages))
- :icon "files"})
- (= (namespace nav) "tag")
- (let [name'' (name nav)
- class-ident (get {"assets" :logseq.class/Asset "tasks" :logseq.class/Task} name'')]
- (when-let [tag-uuid (and class-ident (:block/uuid (db/entity class-ident)))]
- (sidebar-item
- {:class (str "tag-view-nav " name'')
- :title (tt (keyword "left-side-bar" name'')
- (keyword "right-side-bar" name''))
- :href (rfe/href :page {:name tag-uuid})
- :active (= (str tag-uuid) (get-in route-match [:path-params :name]))
- :icon "hash"})))))])))
- (rum/defc sidebar-favorites < rum/reactive
- []
- (let [_favorites-updated? (state/sub :favorites/updated?)
- favorite-entities (page-handler/get-favorites)]
- (sidebar-content-group
- [:a.wrap-th
- [:strong.flex-1 (t :left-side-bar/nav-favorites)]]
- {:class "favorites"
- :count (count favorite-entities)
- :edit-fn
- (fn [e]
- (rfe/push-state :page {:name "Favorites"})
- (util/stop e))}
- (when (seq favorite-entities)
- (let [favorite-items (map
- (fn [e]
- {:id (str (:db/id e))
- :value (:block/uuid e)
- :content [:li.favorite-item.font-medium (page-name e false)]})
- favorite-entities)]
- (dnd-component/items favorite-items
- {:on-drag-end (fn [favorites']
- (page-handler/<reorder-favorites! favorites'))
- :parent-node :ul.favorites.text-sm}))))))
- (rum/defc sidebar-recent-pages < rum/reactive db-mixins/query
- []
- (let [pages (recent-handler/get-recent-pages)]
- (sidebar-content-group
- [:a.wrap-th [:strong.flex-1 (t :left-side-bar/nav-recent-pages)]]
- {:class "recent"
- :count (count pages)}
- [:ul.text-sm
- (for [page pages]
- [:li.recent-item.select-none.font-medium
- {:key (str "recent-" (:db/id page))
- :title (block-handler/block-unique-title page)}
- (page-name page true)])])))
- (rum/defc ^:large-vars/cleanup-todo sidebar-container
- [route-match close-modal-fn left-sidebar-open? srs-open?
- *closing? close-signal touching-x-offset]
- (let [[local-closing? set-local-closing?] (rum/use-state false)
- [el-rect set-el-rect!] (rum/use-state nil)
- ref-el (rum/use-ref nil)
- ref-open? (rum/use-ref left-sidebar-open?)
- default-home (get-default-home-if-valid)
- route-name (get-in route-match [:data :name])
- on-contents-scroll #(when-let [^js el (.-target %)]
- (let [top (.-scrollTop el)
- cls (.-classList el)
- cls' "is-scrolled"]
- (if (> top 2)
- (.add cls cls')
- (.remove cls cls'))))
- close-fn #(set-local-closing? true)
- touching-x-offset (when (number? touching-x-offset)
- (if-not left-sidebar-open?
- (when (> touching-x-offset 0)
- (min touching-x-offset (:width el-rect)))
- (when (< touching-x-offset 0)
- (max touching-x-offset (- 0 (:width el-rect))))))
- offset-ratio (and (number? touching-x-offset)
- (some->> (:width el-rect)
- (/ touching-x-offset)))]
- (hooks/use-effect!
- #(js/setTimeout
- (fn [] (some-> (rum/deref ref-el)
- (.getBoundingClientRect)
- (.toJSON)
- (js->clj :keywordize-keys true)
- (set-el-rect!)))
- 16)
- [])
- (hooks/use-layout-effect!
- (fn []
- (when (and (rum/deref ref-open?) local-closing?)
- (reset! *closing? true))
- (rum/set-ref! ref-open? left-sidebar-open?)
- #())
- [local-closing? left-sidebar-open?])
- (hooks/use-effect!
- (fn []
- (when-not (neg? close-signal)
- (close-fn)))
- [close-signal])
- [:<>
- [:div.left-sidebar-inner.as-container.flex-1.flex.flex-col.min-h-0
- {:key "left-sidebar"
- :ref ref-el
- :style (cond-> {}
- (and (number? offset-ratio)
- (> touching-x-offset 0))
- (assoc :transform (str "translate3d(calc(" touching-x-offset "px - 100%), 0, 0)"))
- (and (number? offset-ratio)
- (< touching-x-offset 0))
- (assoc :transform (str "translate3d(" (* offset-ratio 100) "%, 0, 0)")))
- :on-transition-end (fn []
- (when local-closing?
- (reset! *closing? false)
- (set-local-closing? false)
- (close-modal-fn)))
- :on-click #(when-let [^js target (and (util/sm-breakpoint?) (.-target %))]
- (when (some (fn [sel] (boolean (.closest target sel)))
- [".favorites .bd" ".recent .bd" ".dropdown-wrapper" ".nav-header"])
- (close-fn)))}
- [:div.wrap
- [:div.sidebar-header-container
- ;; sidebar graphs
- (when (not config/publishing?)
- (sidebar-graphs))
- ;; sidebar sticky navigations
- (sidebar-navigations
- {:default-home default-home
- :route-match route-match
- :route-name route-name
- :srs-open? srs-open?})]
- [:div.sidebar-contents-container
- {:on-scroll on-contents-scroll}
- (sidebar-favorites)
- (when (not config/publishing?)
- (sidebar-recent-pages))]]]
- [:span.shade-mask
- (cond-> {:on-click close-fn
- :key "shade-mask"}
- (number? offset-ratio)
- (assoc :style {:opacity (cond-> offset-ratio
- (neg? offset-ratio)
- (+ 1))}))]]))
- (rum/defc sidebar-resizer
- []
- (let [*el-ref (rum/use-ref nil)
- ^js el-doc js/document.documentElement
- adjust-size! (fn [width]
- (.setProperty (.-style el-doc) "--ls-left-sidebar-width" width)
- (storage/set :ls-left-sidebar-width width))]
- ;; restore size
- (hooks/use-layout-effect!
- (fn []
- (when-let [width (storage/get :ls-left-sidebar-width)]
- (.setProperty (.-style el-doc) "--ls-left-sidebar-width" width))))
- ;; draggable handler
- (hooks/use-effect!
- (fn []
- (when-let [el (and (fn? js/window.interact) (rum/deref *el-ref))]
- (let [^js sidebar-el (.querySelector el-doc "#left-sidebar")]
- (-> (js/interact el)
- (.draggable
- #js {:listeners
- #js {:move (fn [^js/MouseEvent e]
- (when-let [offset (.-left (.-rect e))]
- (let [width (.toFixed (max (min offset 460) 240) 2)]
- (adjust-size! (str width "px")))))}})
- (.styleCursor false)
- (.on "dragstart" (fn []
- (.. sidebar-el -classList (add "is-resizing"))
- (.. el-doc -classList (add "is-resizing-buf"))))
- (.on "dragend" (fn []
- (.. sidebar-el -classList (remove "is-resizing"))
- (.. el-doc -classList (remove "is-resizing-buf"))))))
- #()))
- [])
- [:span.left-sidebar-resizer {:ref *el-ref}]))
- (rum/defcs left-sidebar < rum/reactive
- (rum/local false ::closing?)
- (rum/local -1 ::close-signal)
- (rum/local nil ::touch-state)
- [s {:keys [left-sidebar-open? route-match]}]
- (let [close-fn #(state/set-left-sidebar-open! false)
- *closing? (::closing? s)
- *touch-state (::touch-state s)
- *close-signal (::close-signal s)
- touch-point-fn (fn [^js e] (some-> (gobj/get e "touches") (aget 0) (#(hash-map :x (.-clientX %) :y (.-clientY %)))))
- srs-open? (= :srs (state/sub :modal/id))
- touching-x-offset (and (some-> @*touch-state :after)
- (some->> @*touch-state
- ((juxt :after :before))
- (map :x) (apply -)))
- touch-pending? (> (abs touching-x-offset) 20)]
- [:div#left-sidebar.cp__sidebar-left-layout
- {:class (util/classnames [{:is-open left-sidebar-open?
- :is-closing @*closing?
- :is-touching touch-pending?}])
- :on-touch-start
- (fn [^js e]
- (reset! *touch-state {:before (touch-point-fn e)}))
- :on-touch-move
- (fn [^js e]
- (when @*touch-state
- (some-> *touch-state (swap! assoc :after (touch-point-fn e)))))
- :on-touch-end
- (fn []
- (when touch-pending?
- (cond
- (and (not left-sidebar-open?) (> touching-x-offset 40))
- (state/set-left-sidebar-open! true)
- (and left-sidebar-open? (< touching-x-offset -30))
- (reset! *close-signal (inc @*close-signal))))
- (reset! *touch-state nil))}
- ;; sidebar contents
- (sidebar-container route-match close-fn left-sidebar-open? srs-open? *closing?
- @*close-signal (and touch-pending? touching-x-offset))
- ;; resizer
- (sidebar-resizer)]))
|