icon.cljs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. (ns frontend.components.icon
  2. (:require ["@emoji-mart/data" :as emoji-data]
  3. ["emoji-mart" :refer [SearchIndex]]
  4. [camel-snake-kebab.core :as csk]
  5. [cljs-bean.core :as bean]
  6. [clojure.string :as string]
  7. [frontend.config :as config]
  8. [frontend.handler.property.util :as pu]
  9. [frontend.hooks :as hooks]
  10. [frontend.search :as search]
  11. [frontend.storage :as storage]
  12. [frontend.ui :as ui]
  13. [frontend.util :as util]
  14. [goog.functions :refer [debounce]]
  15. [goog.object :as gobj]
  16. [logseq.db :as ldb]
  17. [logseq.shui.ui :as shui]
  18. [medley.core :as medley]
  19. [promesa.core :as p]
  20. [rum.core :as rum]))
  21. (defonce emojis (vals (bean/->clj (gobj/get emoji-data "emojis"))))
  22. (defn icon
  23. [icon' & [opts]]
  24. (let [icon' (if (or (string? icon') (keyword? icon'))
  25. {:type :tabler-icon :id (name icon')} icon')
  26. color? (:color? opts)
  27. opts (dissoc opts :color?)
  28. item (cond
  29. (and (= :emoji (:type icon')) (:id icon'))
  30. [:em-emoji (merge {:id (:id icon')
  31. :style {:line-height 1}}
  32. opts)]
  33. (and (= :tabler-icon (:type icon')) (:id icon'))
  34. (ui/icon (:id icon') opts))]
  35. (if color?
  36. [:span.inline-flex.items-center.ls-icon-color-wrap
  37. {:style {:color (or (some-> icon' :color) "inherit")}} item]
  38. item)))
  39. (defn get-node-icon
  40. [node-entity]
  41. (let [first-tag-icon (some :logseq.property/icon (sort-by :db/id (:block/tags node-entity)))]
  42. (or (get node-entity (pu/get-pid :logseq.property/icon))
  43. (let [asset-type (:logseq.property.asset/type node-entity)]
  44. (cond
  45. (some? first-tag-icon)
  46. first-tag-icon
  47. (ldb/class? node-entity)
  48. "hash"
  49. (ldb/property? node-entity)
  50. "letter-p"
  51. (ldb/whiteboard? node-entity)
  52. "whiteboard"
  53. (ldb/page? node-entity)
  54. "page"
  55. (= asset-type "pdf")
  56. "book"
  57. :else
  58. "letter-n")))))
  59. (defn get-node-icon-cp
  60. [node-entity opts]
  61. (let [opts' (merge {:size 14} opts)
  62. node-icon (if (:own-icon? opts)
  63. (get node-entity (pu/get-pid :logseq.property/icon))
  64. (get-node-icon node-entity))]
  65. (when-not (or (string/blank? node-icon) (and (contains? #{"letter-n" "page"} node-icon) (:not-text-or-page? opts)))
  66. [:div.icon-cp-container.flex.items-center
  67. (merge {:style {:color (or (:color node-icon) "inherit")}}
  68. (select-keys opts [:class]))
  69. (icon node-icon opts')])))
  70. (defn- search-emojis
  71. [q]
  72. (p/let [result (.search SearchIndex q)]
  73. (bean/->clj result)))
  74. (defonce *tabler-icons (atom nil))
  75. (defn- get-tabler-icons
  76. []
  77. (if @*tabler-icons
  78. @*tabler-icons
  79. (let [result (->> (keys (bean/->clj js/tablerIcons))
  80. (map (fn [k]
  81. (-> (string/replace (csk/->Camel_Snake_Case (name k)) "_" " ")
  82. (string/replace-first "Icon " ""))))
  83. ;; FIXME: somehow those icons don't work
  84. (remove #{"Ab" "Ab 2" "Ab Off"}))]
  85. (reset! *tabler-icons result)
  86. result)))
  87. (defn- search-tabler-icons
  88. [q]
  89. (search/fuzzy-search (get-tabler-icons) q :limit 100))
  90. (defn- search
  91. [q tab]
  92. (p/let [icons (when (not= tab :emoji) (search-tabler-icons q))
  93. emojis' (when (not= tab :icon) (search-emojis q))]
  94. {:icons icons
  95. :emojis emojis'}))
  96. (rum/defc icons-row
  97. [items]
  98. [:div.its.icons-row items])
  99. (rum/defc icon-cp < rum/static
  100. [icon' {:keys [on-chosen hover]}]
  101. [:button.w-9.h-9.transition-opacity
  102. (when-let [icon' (cond-> icon' (string? icon') (string/replace " " ""))]
  103. {:key icon'
  104. :tabIndex "0"
  105. :title icon'
  106. :on-click (fn [e]
  107. (on-chosen e {:type :tabler-icon
  108. :id icon'
  109. :name icon'}))
  110. :on-mouse-over #(some-> hover
  111. (reset! {:type :tabler-icon
  112. :id icon'
  113. :name icon'
  114. :icon icon'}))
  115. :on-mouse-out #()})
  116. (ui/icon icon' {:size 24})])
  117. (rum/defc emoji-cp < rum/static
  118. [{:keys [id name] :as emoji} {:keys [on-chosen hover]}]
  119. [:button.text-2xl.w-9.h-9.transition-opacity
  120. (cond->
  121. {:tabIndex "0"
  122. :title name
  123. :on-click (fn [e]
  124. (on-chosen e (assoc emoji :type :emoji)))}
  125. (not (nil? hover))
  126. (assoc :on-mouse-over #(reset! hover emoji)
  127. :on-mouse-out #()))
  128. [:em-emoji {:id id
  129. :style {:line-height 1}}]])
  130. (defn item-render
  131. [item opts]
  132. (if (or (string? item)
  133. (= :tabler-icon (:type item)))
  134. (icon-cp (if (string? item) item (:id item)) opts)
  135. (emoji-cp item opts)))
  136. (rum/defc pane-section
  137. [label items & {:keys [searching? virtual-list?]
  138. :or {virtual-list? true}
  139. :as opts}]
  140. (let [*el-ref (rum/use-ref nil)]
  141. [:div.pane-section
  142. {:ref *el-ref
  143. :class (util/classnames
  144. [{:has-virtual-list virtual-list?
  145. :searching-result searching?}])}
  146. [:div.hd.px-1.pb-1.leading-none
  147. [:strong.text-xs.font-medium.text-gray-07.dark:opacity-80 label]]
  148. (if virtual-list?
  149. (let [total (count items)
  150. step 9
  151. rows (quot total step)
  152. mods (mod total step)
  153. rows (if (zero? mods) rows (inc rows))
  154. items (vec items)]
  155. (ui/virtualized-list
  156. (cond-> {:total-count rows
  157. :item-content (fn [idx]
  158. (icons-row
  159. (let [last? (= (dec rows) idx)
  160. start (* idx step)
  161. end (* (inc idx) (if (and last? (not (zero? mods))) mods step))
  162. icons (try (subvec items start end)
  163. (catch js/Error e
  164. (js/console.error e)
  165. nil))]
  166. (mapv #(item-render % opts) icons))))}
  167. searching?
  168. (assoc :custom-scroll-parent (some-> (rum/deref *el-ref) (.closest ".bd-scroll"))))))
  169. [:div.its
  170. (map #(item-render % opts) items)])]))
  171. (rum/defc emojis-cp < rum/static
  172. [emojis* opts]
  173. (pane-section
  174. (util/format "Emojis (%s)" (count emojis*))
  175. emojis*
  176. opts))
  177. (rum/defc icons-cp < rum/static
  178. [icons opts]
  179. (pane-section
  180. (util/format "Icons (%s)" (count icons))
  181. icons
  182. opts))
  183. (defn get-used-items
  184. []
  185. (storage/get :ui/ls-icons-used))
  186. (defn add-used-item!
  187. [m]
  188. (let [s (some->> (or (get-used-items) [])
  189. (take 24)
  190. (filter #(not= m %))
  191. (cons m))]
  192. (storage/set :ui/ls-icons-used s)))
  193. (rum/defc all-cp
  194. [opts]
  195. (let [used-items (get-used-items)
  196. emoji-items (take 32 emojis)
  197. icon-items (take 48 (get-tabler-icons))
  198. opts (assoc opts :virtual-list? false)]
  199. [:div.all-pane.pb-10
  200. (when (count used-items)
  201. (pane-section "Frequently used" used-items opts))
  202. (pane-section (util/format "Emojis (%s)" (count emojis))
  203. emoji-items
  204. opts)
  205. (pane-section (util/format "Icons (%s)" (count (get-tabler-icons)))
  206. icon-items
  207. opts)]))
  208. (rum/defc tab-observer
  209. [tab {:keys [reset-q!]}]
  210. (hooks/use-effect!
  211. #(reset-q!)
  212. [tab])
  213. nil)
  214. (rum/defc select-observer
  215. [*input-ref]
  216. (let [*el-ref (rum/use-ref nil)
  217. *items-ref (rum/use-ref [])
  218. *current-ref (rum/use-ref [-1])
  219. set-current! (fn [idx node] (set! (. *current-ref -current) [idx node]))
  220. get-cnt #(some-> (rum/deref *el-ref) (.closest ".cp__emoji-icon-picker"))
  221. focus! (fn [idx dir]
  222. (let [items (rum/deref *items-ref)
  223. ^js popup (some-> (get-cnt) (.-parentNode))
  224. idx (loop [n idx]
  225. (if (false? (nth items n nil))
  226. (recur (+ n (if (= dir :prev) -1 1))) n))]
  227. (if-let [node (nth items idx nil)]
  228. (do (.focus node #js {:preventScroll true :focusVisible true})
  229. (.scrollIntoView node #js {:block "center"})
  230. (when popup (set! (. popup -scrollTop) 0))
  231. (set-current! idx node))
  232. (do (.focus (rum/deref *input-ref)) (set-current! -1 nil)))))
  233. down-handler!
  234. (hooks/use-callback
  235. (fn [^js e]
  236. (let []
  237. (if (= 13 (.-keyCode e))
  238. ;; enter
  239. (some-> (second (rum/deref *current-ref)) (.click))
  240. (let [[idx _node] (rum/deref *current-ref)]
  241. (case (.-keyCode e)
  242. ;;left
  243. 37 (focus! (dec idx) :prev)
  244. ;; tab & right
  245. (9 39) (focus! (inc idx) :next)
  246. ;; up
  247. 38 (do (focus! (- idx 9) :prev) (util/stop e))
  248. ;; down
  249. 40 (do (focus! (+ idx 9) :next) (util/stop e))
  250. :dune))))) [])]
  251. (hooks/use-effect!
  252. (fn []
  253. ;; calculate items
  254. (let [^js sections (.querySelectorAll (get-cnt) ".pane-section")
  255. items (map #(some-> (.querySelectorAll % ".its > button") (js/Array.from) (js->clj)) sections)
  256. step 9
  257. items (map #(let [count (count %)
  258. m (mod count step)]
  259. (if (> m 0) (concat % (repeat (- step m) false)) %)) items)]
  260. (set! (. *items-ref -current) (flatten items))
  261. (focus! 0 :next))
  262. ;; handlers
  263. (let [^js cnt (get-cnt)]
  264. (.addEventListener cnt "keydown" down-handler! false)
  265. #(.removeEventListener cnt "keydown" down-handler!)))
  266. [])
  267. [:span.absolute.hidden {:ref *el-ref}]))
  268. (rum/defc color-picker
  269. [*color on-select!]
  270. (let [[color, set-color!] (rum/use-state @*color)
  271. *el (rum/use-ref nil)
  272. content-fn (fn []
  273. (let [colors ["#6e7b8b" "#5e69d2" "#00b5ed" "#00b55b"
  274. "#f2be00" "#e47a00" "#f38e81" "#fb434c" nil]]
  275. [:div.color-picker-presets
  276. (for [c colors]
  277. (shui/button
  278. {:on-click (fn [] (set-color! c)
  279. (some-> on-select! (apply [c]))
  280. (shui/popup-hide!))
  281. :size :sm :variant :outline
  282. :class "it" :style {:background-color c}}
  283. (if c "" (shui/tabler-icon "minus" {:class "scale-75 opacity-70"}))))]))]
  284. (hooks/use-effect!
  285. (fn []
  286. (when-let [^js picker (some-> (rum/deref *el) (.closest ".cp__emoji-icon-picker"))]
  287. (let [color (if (string/blank? color) "inherit" color)]
  288. (.setProperty (.-style picker) "--ls-color-icon-preset" color)
  289. (storage/set :ls-icon-color-preset color)))
  290. (reset! *color color))
  291. [color])
  292. (shui/button {:size :sm
  293. :ref *el
  294. :class "color-picker"
  295. :on-click (fn [^js e] (shui/popup-show! (.-target e) content-fn {:content-props {:side-offset 6}}))
  296. :variant :outline}
  297. [:strong {:style {:color (or color "inherit")}}
  298. (shui/tabler-icon "palette")])))
  299. (rum/defcs ^:large-vars/cleanup-todo icon-search <
  300. (rum/local "" ::q)
  301. (rum/local nil ::result)
  302. (rum/local false ::select-mode?)
  303. (rum/local :all ::tab)
  304. {:init (fn [s]
  305. (assoc s ::color (atom (storage/get :ls-icon-color-preset))))}
  306. [state {:keys [on-chosen del-btn? icon-value] :as opts}]
  307. (let [*q (::q state)
  308. *result (::result state)
  309. *tab (::tab state)
  310. *color (::color state)
  311. *input-ref (rum/create-ref)
  312. *result-ref (rum/create-ref)
  313. result @*result
  314. opts (assoc opts
  315. :on-chosen (fn [e m]
  316. (let [icon? (= (:type m) :tabler-icon)
  317. m (if (and icon? (not (string/blank? @*color)))
  318. (assoc m :color @*color) m)]
  319. (and on-chosen (on-chosen e m))
  320. (when (:type m) (add-used-item! m)))))
  321. *select-mode? (::select-mode? state)
  322. reset-q! #(when-let [^js input (rum/deref *input-ref)]
  323. (reset! *q "")
  324. (reset! *result {})
  325. (reset! *select-mode? false)
  326. (set! (. input -value) "")
  327. (util/schedule
  328. (fn []
  329. (when (not= js/document.activeElement input)
  330. (.focus input))
  331. (util/scroll-to (rum/deref *result-ref) 0 false))))]
  332. [:div.cp__emoji-icon-picker
  333. {:data-keep-selection true}
  334. ;; header
  335. [:div.hd.bg-popover
  336. (tab-observer @*tab {:reset-q! reset-q!})
  337. (when @*select-mode?
  338. (select-observer *input-ref))
  339. [:div.search-input
  340. (shui/tabler-icon "search" {:size 16})
  341. [(shui/input
  342. {:auto-focus true
  343. :ref *input-ref
  344. :placeholder (util/format "Search %s items" (string/lower-case (name @*tab)))
  345. :default-value ""
  346. :on-focus #(reset! *select-mode? false)
  347. :on-key-down (fn [^js e]
  348. (case (.-keyCode e)
  349. ;; esc
  350. 27 (do (util/stop e)
  351. (if (string/blank? @*q)
  352. ;(some-> (rum/deref *input-ref) (.blur))
  353. (shui/popup-hide!)
  354. (reset-q!)))
  355. 38 (do (util/stop e))
  356. (9 40) (do
  357. (reset! *select-mode? true)
  358. (util/stop e))
  359. :dune))
  360. :on-change (debounce
  361. (fn [e]
  362. (reset! *q (util/evalue e))
  363. (reset! *select-mode? false)
  364. (if (string/blank? @*q)
  365. (reset! *result {})
  366. (p/let [result (search @*q @*tab)]
  367. (reset! *result result))))
  368. 200)})]
  369. (when-not (string/blank? @*q)
  370. [:a.x {:on-click reset-q!} (shui/tabler-icon "x" {:size 14})])]]
  371. ;; body
  372. [:div.bd.bd-scroll
  373. {:ref *result-ref
  374. :class (or (some-> @*tab (name)) "other")}
  375. [:div.content-pane
  376. (if (seq result)
  377. [:div.flex.flex-1.flex-col.gap-1.search-result
  378. (let [matched (concat (:emojis result) (:icons result))]
  379. (when (seq matched)
  380. (pane-section
  381. (util/format "Matched (%s)" (count matched))
  382. matched
  383. opts)))]
  384. [:div.flex.flex-1.flex-col.gap-1
  385. (case @*tab
  386. :emoji (emojis-cp emojis opts)
  387. :icon (icons-cp (get-tabler-icons) opts)
  388. (all-cp opts))])]]
  389. ;; footer
  390. [:div.ft
  391. ;; tabs
  392. [:<>
  393. [:div.flex.flex-1.flex-row.items-center.gap-2
  394. (let [tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"]]]
  395. (for [[id label] tabs
  396. :let [active? (= @*tab id)]]
  397. (shui/button
  398. {:variant :ghost
  399. :size :sm
  400. :class (util/classnames [{:active active?} "tab-item"])
  401. :on-mouse-down (fn [e]
  402. (util/stop e)
  403. (reset! *tab id))}
  404. label)))]
  405. (when (not= :emoji @*tab)
  406. (color-picker *color (fn [c]
  407. (when (= :tabler-icon (some-> icon-value :type))
  408. (on-chosen nil (assoc icon-value :color c) true)))))
  409. ;; action buttons
  410. (when del-btn?
  411. (shui/button {:variant :outline :size :sm :data-action "del"
  412. :on-click #(on-chosen nil)}
  413. (shui/tabler-icon "trash" {:size 17})))]]]))
  414. (rum/defc icon-picker
  415. [icon-value {:keys [empty-label disabled? initial-open? del-btn? on-chosen icon-props popup-opts button-opts]}]
  416. (let [*trigger-ref (rum/use-ref nil)
  417. content-fn
  418. (if config/publishing?
  419. (constantly [])
  420. (fn [{:keys [id]}]
  421. (icon-search
  422. {:on-chosen (fn [e icon-value keep-popup?]
  423. (on-chosen e icon-value)
  424. (when-not (true? keep-popup?) (shui/popup-hide! id)))
  425. :icon-value icon-value
  426. :del-btn? del-btn?})))]
  427. (hooks/use-effect!
  428. (fn []
  429. (when initial-open?
  430. (js/setTimeout #(some-> (rum/deref *trigger-ref) (.click)) 32)))
  431. [initial-open?])
  432. ;; trigger
  433. (let [has-icon? (some? icon-value)]
  434. (shui/button
  435. (merge
  436. {:ref *trigger-ref
  437. :variant :ghost
  438. :size :sm
  439. :class (if has-icon? "px-1 leading-none text-muted-foreground hover:text-foreground"
  440. "font-normal text-sm px-[0.5px] text-muted-foreground hover:text-foreground")
  441. :on-click (fn [^js e]
  442. (when-not disabled?
  443. (shui/popup-show! (.-target e) content-fn
  444. (medley/deep-merge
  445. {:align :start
  446. :id :ls-icon-picker
  447. :content-props {:class "ls-icon-picker"
  448. :onEscapeKeyDown #(.preventDefault %)}}
  449. popup-opts))))}
  450. button-opts)
  451. (if has-icon?
  452. (if (vector? icon-value) ; hiccup
  453. icon-value
  454. (icon icon-value (merge {:color? true} icon-props)))
  455. (or empty-label "Empty"))))))