left_sidebar.cljs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. (ns frontend.components.left-sidebar
  2. "App left sidebar"
  3. (:require [clojure.string :as string]
  4. [electron.ipc :as ipc]
  5. [frontend.components.block :as block]
  6. [frontend.components.dnd :as dnd-component]
  7. [frontend.components.icon :as icon]
  8. [frontend.components.repo :as repo]
  9. [frontend.config :as config]
  10. [frontend.context.i18n :refer [t tt]]
  11. [frontend.db :as db]
  12. [frontend.db-mixins :as db-mixins]
  13. [frontend.db.model :as db-model]
  14. [frontend.extensions.fsrs :as fsrs]
  15. [frontend.handler.block :as block-handler]
  16. [frontend.handler.page :as page-handler]
  17. [frontend.handler.recent :as recent-handler]
  18. [frontend.handler.route :as route-handler]
  19. [frontend.modules.shortcut.data-helper :as shortcut-dh]
  20. [frontend.modules.shortcut.utils :as shortcut-utils]
  21. [frontend.state :as state]
  22. [frontend.storage :as storage]
  23. [frontend.ui :as ui]
  24. [frontend.util :as util]
  25. [frontend.util.page :as page-util]
  26. [goog.object :as gobj]
  27. [logseq.db :as ldb]
  28. [logseq.shui.hooks :as hooks]
  29. [logseq.shui.ui :as shui]
  30. [react-draggable]
  31. [reitit.frontend.easy :as rfe]
  32. [rum.core :as rum]))
  33. (defn get-default-home-if-valid
  34. []
  35. (when-let [default-home (state/get-default-home)]
  36. (let [page (:page default-home)
  37. page (when (and (string? page)
  38. (not (string/blank? page)))
  39. (db/get-page page))]
  40. (if page
  41. default-home
  42. (dissoc default-home :page)))))
  43. (rum/defc ^:large-vars/cleanup-todo page-name < rum/reactive db-mixins/query
  44. [page recent?]
  45. (when-let [id (:db/id page)]
  46. (let [page (db/sub-block id)
  47. icon (icon/get-node-icon-cp page {:size 16})
  48. title (:block/title page)
  49. untitled? (db-model/untitled-page? title)
  50. name (:block/name page)
  51. file-rpath (when (util/electron?) (page-util/get-page-file-rpath name))
  52. ctx-icon #(shui/tabler-icon %1 {:class "scale-90 pr-1 opacity-80"})
  53. open-in-sidebar #(state/sidebar-add-block!
  54. (state/get-current-repo)
  55. (:db/id page)
  56. :page)
  57. x-menu-content (fn []
  58. (let [x-menu-item shui/dropdown-menu-item
  59. x-menu-shortcut shui/dropdown-menu-shortcut]
  60. [:<>
  61. (when-not recent?
  62. (x-menu-item
  63. {:key "unfavorite"
  64. :on-click #(page-handler/<unfavorite-page! (str (:block/uuid page)))}
  65. (ctx-icon "star-off")
  66. (t :page/unfavorite)
  67. (x-menu-shortcut (when-let [binding (shortcut-dh/shortcut-binding :command/toggle-favorite)]
  68. (some-> binding
  69. (first)
  70. (shortcut-utils/decorate-binding))))))
  71. (when-let [page-fpath (and (util/electron?) file-rpath
  72. (config/get-repo-fpath (state/get-current-repo) file-rpath))]
  73. [:<>
  74. (x-menu-item
  75. {:key "open-in-folder"
  76. :on-click #(ipc/ipc :openFileInFolder page-fpath)}
  77. (ctx-icon "folder")
  78. (t :page/open-in-finder))
  79. (x-menu-item
  80. {:key "open with default app"
  81. :on-click #(js/window.apis.openPath page-fpath)}
  82. (ctx-icon "file")
  83. (t :page/open-with-default-app))])
  84. (x-menu-item
  85. {:key "open in sidebar"
  86. :on-click open-in-sidebar}
  87. (ctx-icon "layout-sidebar-right")
  88. (t :content/open-in-sidebar)
  89. (x-menu-shortcut (shortcut-utils/decorate-binding "shift+click")))]))]
  90. ;; TODO: move to standalone component
  91. [:a.link-item.group
  92. (if (util/mobile?)
  93. {:on-pointer-down util/stop-propagation
  94. :on-pointer-up (fn [_e]
  95. (route-handler/redirect-to-page! (:block/uuid page) {:click-from-recent? recent?}))}
  96. (cond->
  97. {:on-click
  98. (fn [e]
  99. (if (gobj/get e "shiftKey")
  100. (open-in-sidebar)
  101. (route-handler/redirect-to-page! (:block/uuid page) {:click-from-recent? recent?})))
  102. :on-context-menu (fn [^js e]
  103. (shui/popup-show! e (x-menu-content)
  104. {:as-dropdown? true
  105. :content-props {:on-click (fn [] (shui/popup-hide!))
  106. :class "w-60"}})
  107. (util/stop e))}
  108. (ldb/object? page)
  109. (assoc :title (block-handler/block-unique-title page))))
  110. [:span.page-icon {:key "page-icon"} icon]
  111. [:span.page-title {:key "title"
  112. :class (when untitled? "opacity-50")}
  113. (cond
  114. (not (db/page? page))
  115. (block/inline-text :markdown (string/replace (apply str (take 64 (:block/title page))) "\n" " "))
  116. untitled? (t :untitled)
  117. :else (block-handler/block-unique-title page))]
  118. ;; dots trigger
  119. (shui/button
  120. {:key "more actions"
  121. :size :sm
  122. :variant :ghost
  123. :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"
  124. :on-click #(do
  125. (shui/popup-show! (.-target %) (x-menu-content)
  126. {:as-dropdown? true
  127. :content-props {:on-click (fn [] (shui/popup-hide!))
  128. :class "w-60"}})
  129. (util/stop %))}
  130. [:i.relative {:style {:top "4px"}} (shui/tabler-icon "dots")])])))
  131. (defn sidebar-item
  132. [{:keys [on-click-handler class title icon icon-extension? active href shortcut more]}]
  133. [:div
  134. {:key class
  135. :class (util/classnames [class {:active active}])}
  136. [:a.item.group.flex.items-center.text-sm.rounded-md.font-medium
  137. {:on-click on-click-handler
  138. :class (when active "active")
  139. :href href}
  140. (ui/icon (str icon) {:extension? icon-extension? :size 16})
  141. [:span.flex-1 title]
  142. (when shortcut
  143. [:span.ml-1
  144. (ui/render-keyboard-shortcut
  145. (ui/keyboard-shortcut-from-config shortcut {:pick-first? true}))])
  146. more]])
  147. (rum/defc sidebar-graphs
  148. []
  149. [:div.sidebar-graphs
  150. (repo/graphs-selector)])
  151. (rum/defc sidebar-navigations-edit-content
  152. [{:keys [_id navs checked-navs set-checked-navs!]}]
  153. (let [[local-navs set-local-navs!] (rum/use-state checked-navs)]
  154. (hooks/use-effect!
  155. (fn []
  156. (set-checked-navs! local-navs))
  157. [local-navs])
  158. (for [nav navs
  159. :let [name' (name nav)]]
  160. (shui/dropdown-menu-checkbox-item
  161. {:checked (contains? (set local-navs) nav)
  162. :onCheckedChange (fn [v] (set-local-navs!
  163. (fn []
  164. (if v
  165. (conj local-navs nav)
  166. (filterv #(not= nav %) local-navs)))))}
  167. (tt (keyword "left-side-bar" name')
  168. (keyword "right-side-bar" name'))))))
  169. (rum/defc sidebar-content-group < rum/reactive
  170. [name {:keys [class count more header-props enter-show-more? collapsable?]} child]
  171. (let [collapsed? (state/sub [:ui/navigation-item-collapsed? class])]
  172. [:div.sidebar-content-group
  173. {:class (util/classnames [class {:is-expand (not collapsed?)
  174. :has-children (and (number? count) (> count 0))}])}
  175. [:div.sidebar-content-group-inner
  176. [:div.hd.items-center
  177. (cond-> (merge header-props
  178. {:class (util/classnames [(:class header-props)
  179. {:non-collapsable (false? collapsable?)
  180. :enter-show-more (true? enter-show-more?)}])})
  181. (not (false? collapsable?))
  182. (assoc :on-click (fn [^js/MouseEvent _e]
  183. (state/toggle-navigation-item-collapsed! class))))
  184. [:span.a name]
  185. [:span.b (or more (ui/icon "chevron-right" {:class "more" :size 15}))]]
  186. (when child [:div.bd child])]]))
  187. (rum/defc ^:large-vars/cleanup-todo sidebar-navigations
  188. [{:keys [default-home route-match route-name srs-open?]}]
  189. (let [navs [:flashcards :all-pages :graph-view :tag/tasks :tag/assets]
  190. [checked-navs set-checked-navs!] (rum/use-state (or (storage/get :ls-sidebar-navigations)
  191. [:flashcards :all-pages :graph-view]))]
  192. (hooks/use-effect!
  193. (fn []
  194. (when (vector? checked-navs)
  195. (storage/set :ls-sidebar-navigations checked-navs)))
  196. [checked-navs])
  197. (sidebar-content-group
  198. [:a.wrap-th [:strong.flex-1 "Navigations"]]
  199. {:collapsable? false
  200. :enter-show-more? true
  201. :header-props {:on-click (fn [^js e] (when-let [^js _el (some-> (.-target e) (.closest ".as-edit"))]
  202. (shui/popup-show! _el
  203. #(sidebar-navigations-edit-content
  204. {:id (:id %) :navs navs
  205. :checked-navs checked-navs
  206. :set-checked-navs! set-checked-navs!})
  207. {:as-dropdown? false})))}
  208. :more [:a.as-edit {:class "!opacity-60 hover:!opacity-80 relative -top-0.5 -right-0.5"}
  209. (shui/tabler-icon "filter-edit" {:size 14})]}
  210. [:div.sidebar-navigations.flex.flex-col.mt-1
  211. ;; required custom home page
  212. (let [page (:page default-home)
  213. enable-journals? (state/enable-journals? (state/get-current-repo))]
  214. (if (and page (not enable-journals?))
  215. (sidebar-item
  216. {:class "home-nav"
  217. :title page
  218. :on-click-handler route-handler/redirect-to-home!
  219. :active (and (not srs-open?)
  220. (= route-name :page)
  221. (= page (get-in route-match [:path-params :name])))
  222. :icon "home"
  223. :shortcut :go/home})
  224. (when enable-journals?
  225. (sidebar-item
  226. {:class "journals-nav"
  227. :active (and (not srs-open?)
  228. (or (= route-name :all-journals) (= route-name :home)))
  229. :title (t :left-side-bar/journals)
  230. :on-click-handler (fn [e]
  231. (if (gobj/get e "shiftKey")
  232. (route-handler/sidebar-journals!)
  233. (route-handler/go-to-journals!)))
  234. :icon "calendar"
  235. :shortcut :go/journals}))))
  236. (for [nav checked-navs]
  237. (cond
  238. (= nav :flashcards)
  239. (when (state/enable-flashcards? (state/get-current-repo))
  240. (let [num (state/sub :srs/cards-due-count)]
  241. (sidebar-item
  242. {:class "flashcards-nav"
  243. :title (t :right-side-bar/flashcards)
  244. :icon "infinity"
  245. :shortcut :go/flashcards
  246. :active srs-open?
  247. :on-click-handler #(do (fsrs/update-due-cards-count)
  248. (state/pub-event! [:modal/show-cards]))
  249. :more (when (and num (not (zero? num)))
  250. [:span.ml-1.inline-block.py-0.5.px-3.text-xs.font-medium.rounded-full.fade-in num])})))
  251. (= nav :graph-view)
  252. (sidebar-item
  253. {:class "graph-view-nav"
  254. :title (t :right-side-bar/graph-view)
  255. :href (rfe/href :graph)
  256. :active (and (not srs-open?) (= route-name :graph))
  257. :icon "hierarchy"
  258. :shortcut :go/graph-view})
  259. (= nav :all-pages)
  260. (sidebar-item
  261. {:class "all-pages-nav"
  262. :title (t :right-side-bar/all-pages)
  263. :href (rfe/href :all-pages)
  264. :active (and (not srs-open?) (= route-name :all-pages))
  265. :icon "files"})
  266. (= (namespace nav) "tag")
  267. (let [name'' (name nav)
  268. class-ident (get {"assets" :logseq.class/Asset "tasks" :logseq.class/Task} name'')]
  269. (when-let [tag-uuid (and class-ident (:block/uuid (db/entity class-ident)))]
  270. (sidebar-item
  271. {:class (str "tag-view-nav " name'')
  272. :title (tt (keyword "left-side-bar" name'')
  273. (keyword "right-side-bar" name''))
  274. :href (rfe/href :page {:name tag-uuid})
  275. :active (= (str tag-uuid) (get-in route-match [:path-params :name]))
  276. :icon "hash"})))))])))
  277. (rum/defc sidebar-favorites < rum/reactive
  278. []
  279. (let [_favorites-updated? (state/sub :favorites/updated?)
  280. favorite-entities (page-handler/get-favorites)]
  281. (sidebar-content-group
  282. [:a.wrap-th
  283. [:strong.flex-1 (t :left-side-bar/nav-favorites)]]
  284. {:class "favorites"
  285. :count (count favorite-entities)
  286. :edit-fn
  287. (fn [e]
  288. (rfe/push-state :page {:name "Favorites"})
  289. (util/stop e))}
  290. (when (seq favorite-entities)
  291. (let [favorite-items (map
  292. (fn [e]
  293. {:id (str (:db/id e))
  294. :value (:block/uuid e)
  295. :content [:li.favorite-item.font-medium (page-name e false)]})
  296. favorite-entities)]
  297. (dnd-component/items favorite-items
  298. {:on-drag-end (fn [favorites']
  299. (page-handler/<reorder-favorites! favorites'))
  300. :parent-node :ul.favorites.text-sm}))))))
  301. (rum/defc sidebar-recent-pages < rum/reactive db-mixins/query
  302. []
  303. (let [pages (recent-handler/get-recent-pages)]
  304. (sidebar-content-group
  305. [:a.wrap-th [:strong.flex-1 (t :left-side-bar/nav-recent-pages)]]
  306. {:class "recent"
  307. :count (count pages)}
  308. [:ul.text-sm
  309. (for [page pages]
  310. [:li.recent-item.select-none.font-medium
  311. {:key (str "recent-" (:db/id page))
  312. :title (block-handler/block-unique-title page)}
  313. (page-name page true)])])))
  314. (rum/defc ^:large-vars/cleanup-todo sidebar-container
  315. [route-match close-modal-fn left-sidebar-open? srs-open?
  316. *closing? close-signal touching-x-offset]
  317. (let [[local-closing? set-local-closing?] (rum/use-state false)
  318. [el-rect set-el-rect!] (rum/use-state nil)
  319. ref-el (rum/use-ref nil)
  320. ref-open? (rum/use-ref left-sidebar-open?)
  321. default-home (get-default-home-if-valid)
  322. route-name (get-in route-match [:data :name])
  323. on-contents-scroll #(when-let [^js el (.-target %)]
  324. (let [top (.-scrollTop el)
  325. cls (.-classList el)
  326. cls' "is-scrolled"]
  327. (if (> top 2)
  328. (.add cls cls')
  329. (.remove cls cls'))))
  330. close-fn #(set-local-closing? true)
  331. touching-x-offset (when (number? touching-x-offset)
  332. (if-not left-sidebar-open?
  333. (when (> touching-x-offset 0)
  334. (min touching-x-offset (:width el-rect)))
  335. (when (< touching-x-offset 0)
  336. (max touching-x-offset (- 0 (:width el-rect))))))
  337. offset-ratio (and (number? touching-x-offset)
  338. (some->> (:width el-rect)
  339. (/ touching-x-offset)))]
  340. (hooks/use-effect!
  341. #(js/setTimeout
  342. (fn [] (some-> (rum/deref ref-el)
  343. (.getBoundingClientRect)
  344. (.toJSON)
  345. (js->clj :keywordize-keys true)
  346. (set-el-rect!)))
  347. 16)
  348. [])
  349. (hooks/use-layout-effect!
  350. (fn []
  351. (when (and (rum/deref ref-open?) local-closing?)
  352. (reset! *closing? true))
  353. (rum/set-ref! ref-open? left-sidebar-open?)
  354. #())
  355. [local-closing? left-sidebar-open?])
  356. (hooks/use-effect!
  357. (fn []
  358. (when-not (neg? close-signal)
  359. (close-fn)))
  360. [close-signal])
  361. [:<>
  362. [:div.left-sidebar-inner.as-container.flex-1.flex.flex-col.min-h-0
  363. {:key "left-sidebar"
  364. :ref ref-el
  365. :style (cond-> {}
  366. (and (number? offset-ratio)
  367. (> touching-x-offset 0))
  368. (assoc :transform (str "translate3d(calc(" touching-x-offset "px - 100%), 0, 0)"))
  369. (and (number? offset-ratio)
  370. (< touching-x-offset 0))
  371. (assoc :transform (str "translate3d(" (* offset-ratio 100) "%, 0, 0)")))
  372. :on-transition-end (fn []
  373. (when local-closing?
  374. (reset! *closing? false)
  375. (set-local-closing? false)
  376. (close-modal-fn)))
  377. :on-click #(when-let [^js target (and (util/sm-breakpoint?) (.-target %))]
  378. (when (some (fn [sel] (boolean (.closest target sel)))
  379. [".favorites .bd" ".recent .bd" ".dropdown-wrapper" ".nav-header"])
  380. (close-fn)))}
  381. [:div.wrap
  382. [:div.sidebar-header-container
  383. ;; sidebar graphs
  384. (when (not config/publishing?)
  385. (sidebar-graphs))
  386. ;; sidebar sticky navigations
  387. (sidebar-navigations
  388. {:default-home default-home
  389. :route-match route-match
  390. :route-name route-name
  391. :srs-open? srs-open?})]
  392. [:div.sidebar-contents-container
  393. {:on-scroll on-contents-scroll}
  394. (sidebar-favorites)
  395. (when (not config/publishing?)
  396. (sidebar-recent-pages))]]]
  397. [:span.shade-mask
  398. (cond-> {:on-click close-fn
  399. :key "shade-mask"}
  400. (number? offset-ratio)
  401. (assoc :style {:opacity (cond-> offset-ratio
  402. (neg? offset-ratio)
  403. (+ 1))}))]]))
  404. (rum/defc sidebar-resizer
  405. []
  406. (let [*el-ref (rum/use-ref nil)
  407. ^js el-doc js/document.documentElement
  408. adjust-size! (fn [width]
  409. (.setProperty (.-style el-doc) "--ls-left-sidebar-width" width)
  410. (storage/set :ls-left-sidebar-width width))]
  411. ;; restore size
  412. (hooks/use-layout-effect!
  413. (fn []
  414. (when-let [width (storage/get :ls-left-sidebar-width)]
  415. (.setProperty (.-style el-doc) "--ls-left-sidebar-width" width))))
  416. ;; draggable handler
  417. (hooks/use-effect!
  418. (fn []
  419. (when-let [el (and (fn? js/window.interact) (rum/deref *el-ref))]
  420. (let [^js sidebar-el (.querySelector el-doc "#left-sidebar")]
  421. (-> (js/interact el)
  422. (.draggable
  423. #js {:listeners
  424. #js {:move (fn [^js/MouseEvent e]
  425. (when-let [offset (.-left (.-rect e))]
  426. (let [width (.toFixed (max (min offset 460) 240) 2)]
  427. (adjust-size! (str width "px")))))}})
  428. (.styleCursor false)
  429. (.on "dragstart" (fn []
  430. (.. sidebar-el -classList (add "is-resizing"))
  431. (.. el-doc -classList (add "is-resizing-buf"))))
  432. (.on "dragend" (fn []
  433. (.. sidebar-el -classList (remove "is-resizing"))
  434. (.. el-doc -classList (remove "is-resizing-buf"))))))
  435. #()))
  436. [])
  437. [:span.left-sidebar-resizer {:ref *el-ref}]))
  438. (rum/defcs left-sidebar < rum/reactive
  439. (rum/local false ::closing?)
  440. (rum/local -1 ::close-signal)
  441. (rum/local nil ::touch-state)
  442. [s {:keys [left-sidebar-open? route-match]}]
  443. (let [close-fn #(state/set-left-sidebar-open! false)
  444. *closing? (::closing? s)
  445. *touch-state (::touch-state s)
  446. *close-signal (::close-signal s)
  447. touch-point-fn (fn [^js e] (some-> (gobj/get e "touches") (aget 0) (#(hash-map :x (.-clientX %) :y (.-clientY %)))))
  448. srs-open? (= :srs (state/sub :modal/id))
  449. touching-x-offset (and (some-> @*touch-state :after)
  450. (some->> @*touch-state
  451. ((juxt :after :before))
  452. (map :x) (apply -)))
  453. touch-pending? (> (abs touching-x-offset) 20)]
  454. [:div#left-sidebar.cp__sidebar-left-layout
  455. {:class (util/classnames [{:is-open left-sidebar-open?
  456. :is-closing @*closing?
  457. :is-touching touch-pending?}])
  458. :on-touch-start
  459. (fn [^js e]
  460. (reset! *touch-state {:before (touch-point-fn e)}))
  461. :on-touch-move
  462. (fn [^js e]
  463. (when @*touch-state
  464. (some-> *touch-state (swap! assoc :after (touch-point-fn e)))))
  465. :on-touch-end
  466. (fn []
  467. (when touch-pending?
  468. (cond
  469. (and (not left-sidebar-open?) (> touching-x-offset 40))
  470. (state/set-left-sidebar-open! true)
  471. (and left-sidebar-open? (< touching-x-offset -30))
  472. (reset! *close-signal (inc @*close-signal))))
  473. (reset! *touch-state nil))}
  474. ;; sidebar contents
  475. (sidebar-container route-match close-fn left-sidebar-open? srs-open? *closing?
  476. @*close-signal (and touch-pending? touching-x-offset))
  477. ;; resizer
  478. (sidebar-resizer)]))