icon.cljs 15 KB

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