core.cljs 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066
  1. (ns frontend.components.cmdk.core
  2. (:require [cljs-bean.core :as bean]
  3. [clojure.string :as string]
  4. [electron.ipc :as ipc]
  5. [frontend.components.block :as block]
  6. [frontend.components.cmdk.list-item :as list-item]
  7. [frontend.config :as config]
  8. [frontend.context.i18n :refer [t]]
  9. [frontend.db :as db]
  10. [frontend.db.async :as db-async]
  11. [frontend.db.model :as model]
  12. [frontend.extensions.pdf.utils :as pdf-utils]
  13. [frontend.handler.block :as block-handler]
  14. [frontend.handler.command-palette :as cp-handler]
  15. [frontend.handler.db-based.page :as db-page-handler]
  16. [frontend.handler.editor :as editor-handler]
  17. [frontend.handler.notification :as notification]
  18. [frontend.handler.page :as page-handler]
  19. [frontend.handler.route :as route-handler]
  20. [frontend.handler.whiteboard :as whiteboard-handler]
  21. [frontend.mixins :as mixins]
  22. [frontend.modules.shortcut.core :as shortcut]
  23. [frontend.modules.shortcut.utils :as shortcut-utils]
  24. [frontend.search :as search]
  25. [frontend.state :as state]
  26. [frontend.ui :as ui]
  27. [frontend.util :as util]
  28. [frontend.util.page :as page-util]
  29. [frontend.util.text :as text-util]
  30. [goog.functions :as gfun]
  31. [goog.object :as gobj]
  32. [goog.userAgent]
  33. [logseq.common.path :as path]
  34. [logseq.common.util :as common-util]
  35. [logseq.common.util.block-ref :as block-ref]
  36. [logseq.db :as ldb]
  37. [logseq.graph-parser.text :as text]
  38. [logseq.shui.hooks :as hooks]
  39. [logseq.shui.ui :as shui]
  40. [promesa.core :as p]
  41. [rum.core :as rum]))
  42. (defn translate [t {:keys [id desc]}]
  43. (when id
  44. (let [desc-i18n (t (shortcut-utils/decorate-namespace id))]
  45. (if (string/starts-with? desc-i18n "{Missing key")
  46. desc
  47. desc-i18n))))
  48. (defn- get-group-limit
  49. [group]
  50. (if (= group :nodes)
  51. 10
  52. 5))
  53. (defn filters
  54. []
  55. (let [current-page (state/get-current-page)]
  56. (->>
  57. [(when current-page
  58. {:filter {:group :current-page} :text "Search only current page" :info "Add filter to search" :icon-theme :gray :icon "file"})
  59. {:filter {:group :nodes} :text "Search only nodes" :info "Add filter to search" :icon-theme :gray :icon "letter-n"}
  60. {:filter {:group :commands} :text "Search only commands" :info "Add filter to search" :icon-theme :gray :icon "command"}
  61. {:filter {:group :files} :text "Search only files" :info "Add filter to search" :icon-theme :gray :icon "file"}
  62. {:filter {:group :themes} :text "Search only themes" :info "Add filter to search" :icon-theme :gray :icon "palette"}]
  63. (remove nil?))))
  64. ;; The results are separated into groups, and loaded/fetched/queried separately
  65. (def default-results
  66. {:recently-updated-pages {:status :success :show :less :items nil}
  67. :commands {:status :success :show :less :items nil}
  68. :favorites {:status :success :show :less :items nil}
  69. :current-page {:status :success :show :less :items nil}
  70. :nodes {:status :success :show :less :items nil}
  71. :files {:status :success :show :less :items nil}
  72. :themes {:status :success :show :less :items nil}
  73. :filters {:status :success :show :less :items nil}})
  74. (defn get-class-from-input
  75. [input]
  76. (string/replace input #"^#+" ""))
  77. (defn create-items [q]
  78. (when (and (not (string/blank? q))
  79. (not (#{"config.edn" "custom.js" "custom.css"} q))
  80. (not config/publishing?))
  81. (let [class? (string/starts-with? q "#")]
  82. (->> [{:text (if class? "Create tag" "Create page") :icon "new-page"
  83. :icon-theme :gray
  84. :info (if class?
  85. (str "Create class called '" (get-class-from-input q) "'")
  86. (str "Create page called '" q "'"))
  87. :source-create :page}]
  88. (remove nil?)))))
  89. ;; Take the results, decide how many items to show, and order the results appropriately
  90. (defn state->results-ordered [state search-mode]
  91. (let [sidebar? (:sidebar? (last (:rum/args state)))
  92. results @(::results state)
  93. input @(::input state)
  94. filter' @(::filter state)
  95. filter-group (:group filter')
  96. index (volatile! -1)
  97. visible-items (fn [group]
  98. (let [{:keys [items show]} (get results group)]
  99. (cond
  100. (or sidebar? (= group filter-group))
  101. items
  102. (= :more show)
  103. items
  104. :else
  105. (take (get-group-limit group) items))))
  106. node-exists? (let [blocks-result (keep :source-block (get-in results [:nodes :items]))]
  107. (when-not (string/blank? input)
  108. (or (let [page (some-> (text/get-namespace-last-part input)
  109. string/trim
  110. db/get-page)
  111. parent-title (:block/title (:logseq.property/parent page))
  112. namespace? (string/includes? input "/")]
  113. (and page
  114. (or (not namespace?)
  115. (and
  116. parent-title
  117. (= (util/page-name-sanity-lc parent-title)
  118. (util/page-name-sanity-lc (nth (reverse (string/split input "/")) 1)))))))
  119. (some (fn [block]
  120. (and
  121. (:block/tags block)
  122. (= input (util/page-name-sanity-lc (:block/title block))))) blocks-result))))
  123. include-slash? (or (string/includes? input "/")
  124. (string/starts-with? input "/"))
  125. order* (cond
  126. (= search-mode :graph)
  127. []
  128. include-slash?
  129. [(when-not node-exists?
  130. ["Create" :create (create-items input)])
  131. ["Current page" :current-page (visible-items :current-page)]
  132. ["Nodes" :nodes (visible-items :nodes)]
  133. ["Files" :files (visible-items :files)]
  134. ["Filters" :filters (visible-items :filters)]]
  135. filter-group
  136. [(when (= filter-group :nodes)
  137. ["Current page" :current-page (visible-items :current-page)])
  138. [(if (= filter-group :current-page) "Current page" (name filter-group))
  139. filter-group
  140. (visible-items filter-group)]
  141. (when-not node-exists?
  142. ["Create" :create (create-items input)])]
  143. :else
  144. (->>
  145. [(when-not node-exists?
  146. ["Create" :create (create-items input)])
  147. ["Current page" :current-page (visible-items :current-page)]
  148. ["Nodes" :nodes (visible-items :nodes)]
  149. ["Recently updated" :recently-updated-pages (visible-items :recently-updated-pages)]
  150. ["Commands" :commands (visible-items :commands)]
  151. ["Files" :files (visible-items :files)]
  152. ["Filters" :filters (visible-items :filters)]]
  153. (remove nil?)))
  154. order (remove nil? order*)]
  155. (for [[group-name group-key group-items] order]
  156. [group-name
  157. group-key
  158. (if (= group-key :create)
  159. (count group-items)
  160. (count (get-in results [group-key :items])))
  161. (mapv #(assoc % :item-index (vswap! index inc)) group-items)])))
  162. (defn state->highlighted-item [state]
  163. (or (some-> state ::highlighted-item deref)
  164. (some->> (state->results-ordered state (:search/mode @state/state))
  165. (mapcat last)
  166. (first))))
  167. (defn state->action [state]
  168. (let [highlighted-item (state->highlighted-item state)]
  169. (cond (:source-page highlighted-item) :open
  170. (:source-block highlighted-item) :open
  171. (:file-path highlighted-item) :open
  172. (:source-search highlighted-item) :search
  173. (:source-command highlighted-item) :trigger
  174. (:source-create highlighted-item) :create
  175. (:filter highlighted-item) :filter
  176. (:source-theme highlighted-item) :theme
  177. :else nil)))
  178. ;; Each result group has it's own load-results function
  179. (defmulti load-results (fn [group _state] group))
  180. (defn- get-page-icon
  181. [entity]
  182. (cond
  183. (ldb/class? entity)
  184. "hash"
  185. (ldb/property? entity)
  186. "letter-p"
  187. (ldb/whiteboard? entity)
  188. "writing"
  189. :else
  190. "file"))
  191. (defmethod load-results :initial [_ state]
  192. (when-let [db (db/get-db)]
  193. (let [!results (::results state)
  194. recent-pages (map (fn [block]
  195. (let [text (block-handler/block-unique-title block)
  196. icon (get-page-icon block)]
  197. {:icon icon
  198. :icon-theme :gray
  199. :text text
  200. :source-block block}))
  201. (ldb/get-recent-updated-pages db))]
  202. (reset! !results (assoc-in default-results [:recently-updated-pages :items] recent-pages)))))
  203. ;; The commands search uses the command-palette handler
  204. (defmethod load-results :commands [group state]
  205. (let [!input (::input state)
  206. !results (::results state)]
  207. (swap! !results assoc-in [group :status] :loading)
  208. (let [commands (->> (cp-handler/top-commands 1000)
  209. (map #(assoc % :t (translate t %))))
  210. search-results (if (string/blank? @!input)
  211. commands
  212. (search/fuzzy-search commands @!input {:extract-fn :t}))]
  213. (->> search-results
  214. (map #(hash-map :icon "command"
  215. :icon-theme :gray
  216. :text (translate t %)
  217. :shortcut (:shortcut %)
  218. :source-command %))
  219. (hash-map :status :success :items)
  220. (swap! !results update group merge)))))
  221. (defmethod load-results :recently-updated-pages [group state]
  222. (let [!input (::input state)
  223. !results (::results state)]
  224. (swap! !results assoc-in [group :status] :loading)
  225. (let [recent-pages (ldb/get-recent-updated-pages (db/get-db))
  226. search-results (if (string/blank? @!input)
  227. recent-pages
  228. (search/fuzzy-search recent-pages @!input {:extract-fn :block/title}))]
  229. (->> search-results
  230. (map (fn [block]
  231. (let [text (block-handler/block-unique-title block)
  232. icon (get-page-icon block)]
  233. {:icon icon
  234. :icon-theme :gray
  235. :text text
  236. :source-block block})))
  237. (hash-map :status :success :items)
  238. (swap! !results update group merge)))))
  239. (defn highlight-content-query
  240. "Return hiccup of highlighted content FTS result"
  241. [content q]
  242. (when-not (or (string/blank? content) (string/blank? q))
  243. [:div (loop [content content ;; why recur? because there might be multiple matches
  244. result []]
  245. (let [[b-cut hl-cut e-cut] (text-util/cut-by content "$pfts_2lqh>$" "$<pfts_2lqh$")
  246. hiccups-add [[:span b-cut]
  247. [:mark.p-0.rounded-none hl-cut]]
  248. hiccups-add (remove nil? hiccups-add)
  249. new-result (concat result hiccups-add)]
  250. (if-not (string/blank? e-cut)
  251. (recur e-cut new-result)
  252. new-result)))]))
  253. (defn- page-item
  254. [repo page]
  255. (let [entity (db/entity [:block/uuid (:block/uuid page)])
  256. source-page (model/get-alias-source-page repo (:db/id entity))
  257. icon (get-page-icon entity)
  258. title (block-handler/block-unique-title page)
  259. title' (if source-page (str title " -> alias: " (:block/title source-page)) title)]
  260. (hash-map :icon icon
  261. :icon-theme :gray
  262. :text title'
  263. :source-page (or source-page page))))
  264. (defn- block-item
  265. [repo block current-page !input]
  266. (let [id (:block/uuid block)
  267. text (block-handler/block-unique-title block)
  268. icon "letter-n"]
  269. {:icon icon
  270. :icon-theme :gray
  271. :text (highlight-content-query text @!input)
  272. :header (when-not (db/page? block) (block/breadcrumb {:search? true} repo id {}))
  273. :current-page? (when-let [page-id (:block/page block)]
  274. (= page-id (:block/uuid current-page)))
  275. :source-block block}))
  276. ;; The blocks search action uses an existing handler
  277. (defmethod load-results :nodes [group state]
  278. (let [!input (::input state)
  279. !results (::results state)
  280. repo (state/get-current-repo)
  281. current-page (when-let [id (page-util/get-current-page-id)]
  282. (db/entity id))
  283. opts {:limit 100 :dev? config/dev? :built-in? true}]
  284. (swap! !results assoc-in [group :status] :loading)
  285. (swap! !results assoc-in [:current-page :status] :loading)
  286. (p/let [blocks (search/block-search repo @!input opts)
  287. blocks (remove nil? blocks)
  288. blocks (search/fuzzy-search blocks @!input {:limit 100
  289. :extract-fn :block/title})
  290. items (keep (fn [block]
  291. (if (:page? block)
  292. (page-item repo block)
  293. (block-item repo block current-page !input))) blocks)]
  294. (if (= group :current-page)
  295. (let [items-on-current-page (filter :current-page? items)]
  296. (swap! !results update group merge {:status :success :items items-on-current-page}))
  297. (swap! !results update group merge {:status :success :items items})))))
  298. (defmethod load-results :files [group state]
  299. (let [!input (::input state)
  300. !results (::results state)]
  301. (swap! !results assoc-in [group :status] :loading)
  302. (p/let [files* (search/file-search @!input 99)
  303. files (remove
  304. (fn [f]
  305. (and
  306. f
  307. (string/ends-with? f ".edn")
  308. (or (string/starts-with? f "whiteboards/")
  309. (string/starts-with? f "assets/")
  310. (string/starts-with? f "logseq/version-files")
  311. (contains? #{"logseq/metadata.edn" "logseq/pages-metadata.edn" "logseq/graphs-txid.edn"} f))))
  312. files*)
  313. items (map
  314. (fn [file]
  315. (hash-map :icon "file"
  316. :icon-theme :gray
  317. :text file
  318. :file-path file))
  319. files)]
  320. (swap! !results update group merge {:status :success :items items}))))
  321. (defmethod load-results :themes [group _state]
  322. (let [!input (::input _state)
  323. !results (::results _state)
  324. themes (state/sub :plugin/installed-themes)
  325. themes (if (string/blank? @!input)
  326. themes
  327. (search/fuzzy-search themes @!input :limit 100 :extract-fn :name))
  328. themes (cons {:name "Logseq Default theme"
  329. :pid "logseq-classic-theme"
  330. :mode (state/sub :ui/theme)
  331. :url nil} themes)
  332. selected (state/sub :plugin/selected-theme)]
  333. (swap! !results assoc-in [group :status] :loading)
  334. (let [items (for [t themes
  335. :let [selected? (= (:url t) selected)]]
  336. {:icon-theme :gray :text (:name t) :info (str (:mode t) " #" (:pid t))
  337. :icon (if selected? "checkbox" "palette") :source-theme t :selected selected?})]
  338. (swap! !results update group merge {:status :success :items items}))))
  339. (defn- get-filter-q
  340. [input]
  341. (or (when (string/starts-with? input "/")
  342. (subs input 1))
  343. (last (common-util/split-last "/" input))))
  344. (defmethod load-results :filters [group state]
  345. (let [!results (::results state)
  346. !input (::input state)
  347. input @!input
  348. q (or (get-filter-q input) "")
  349. matched-items (if (string/blank? q)
  350. (filters)
  351. (search/fuzzy-search (filters) q {:extract-fn :text}))]
  352. (swap! !results update group merge {:status :success :items matched-items})))
  353. (defmethod load-results :current-page [group state]
  354. (if-let [current-page (when-let [id (page-util/get-current-page-id)]
  355. (db/entity id))]
  356. (let [!results (::results state)
  357. !input (::input state)
  358. repo (state/get-current-repo)
  359. opts {:limit 100 :page (str (:block/uuid current-page))}]
  360. (swap! !results assoc-in [group :status] :loading)
  361. (swap! !results assoc-in [:current-page :status] :loading)
  362. (p/let [blocks (search/block-search repo @!input opts)
  363. blocks (remove nil? blocks)
  364. items (map (fn [block]
  365. (let [id (if (uuid? (:block/uuid block))
  366. (:block/uuid block)
  367. (uuid (:block/uuid block)))]
  368. {:icon "node"
  369. :icon-theme :gray
  370. :text (highlight-content-query (:block/title block) @!input)
  371. :header (block/breadcrumb {:search? true} repo id {})
  372. :current-page? true
  373. :source-block block})) blocks)]
  374. (swap! !results update :current-page merge {:status :success :items items})))
  375. (reset! (::filter state) nil)))
  376. ;; The default load-results function triggers all the other load-results function
  377. (defmethod load-results :default [_ state]
  378. (let [filter-group (:group @(::filter state))]
  379. (if (and (not (some-> state ::input deref seq))
  380. (not filter-group))
  381. (do (load-results :initial state)
  382. (load-results :filters state))
  383. (if filter-group
  384. (load-results filter-group state)
  385. (do
  386. (load-results :commands state)
  387. (load-results :nodes state)
  388. (load-results :filters state)
  389. (load-results :files state)
  390. (load-results :recently-updated-pages state)
  391. ;; (load-results :recents state)
  392. )))))
  393. (defn- copy-block-ref [state]
  394. (when-let [block-uuid (some-> state state->highlighted-item :source-block :block/uuid)]
  395. (editor-handler/copy-block-ref! block-uuid block-ref/->block-ref)
  396. (shui/dialog-close! :ls-dialog-cmdk)))
  397. (defmulti handle-action (fn [action _state _event] action))
  398. (defn- get-highlighted-page-uuid-or-name
  399. [state]
  400. (let [highlighted-item (some-> state state->highlighted-item)]
  401. (or (:block/uuid (:source-block highlighted-item))
  402. (:block/uuid (:source-page highlighted-item)))))
  403. (defmethod handle-action :open-page [_ state _event]
  404. (when-let [page-name (get-highlighted-page-uuid-or-name state)]
  405. (let [page-uuid (get (db/get-page page-name) :block/uuid
  406. (when (uuid? page-name) page-name))]
  407. (route-handler/redirect-to-page! page-uuid))
  408. (shui/dialog-close! :ls-dialog-cmdk)))
  409. (defmethod handle-action :open-block [_ state _event]
  410. (when-let [block-id (some-> state state->highlighted-item :source-block :block/uuid)]
  411. (p/let [repo (state/get-current-repo)
  412. _ (db-async/<get-block repo block-id :children? false)
  413. block (db/entity [:block/uuid block-id])
  414. parents (db-async/<get-block-parents (state/get-current-repo) (:db/id block) 1000)
  415. created-from-block (some (fn [block']
  416. (let [block (db/entity (:db/id block'))]
  417. (when (:logseq.property/created-from-property block)
  418. (:block/parent block)))) parents)
  419. [block-id block] (if created-from-block
  420. (let [block (db/entity (:db/id created-from-block))]
  421. [(:block/uuid block) block])
  422. [block-id block])]
  423. (let [get-block-page (partial model/get-block-page repo)]
  424. (when block
  425. (when-let [page (some-> block-id get-block-page)]
  426. (cond
  427. (db/whiteboard-page? page)
  428. (route-handler/redirect-to-page! (:block/uuid page) {:block-id block-id})
  429. (model/parents-collapsed? (state/get-current-repo) block-id)
  430. (route-handler/redirect-to-page! block-id)
  431. :else
  432. (route-handler/redirect-to-page! (:block/uuid page) {:anchor (str "ls-block-" block-id)}))
  433. (shui/dialog-close! :ls-dialog-cmdk)))))))
  434. (defmethod handle-action :open-page-right [_ state _event]
  435. (when-let [page-name (get-highlighted-page-uuid-or-name state)]
  436. (let [page (db/get-page page-name)]
  437. (when page
  438. (editor-handler/open-block-in-sidebar! (:block/uuid page))))
  439. (shui/dialog-close! :ls-dialog-cmdk)))
  440. (defmethod handle-action :open-block-right [_ state _event]
  441. (when-let [block-uuid (some-> state state->highlighted-item :source-block :block/uuid)]
  442. (p/let [repo (state/get-current-repo)
  443. _ (db-async/<get-block repo block-uuid :children? false)]
  444. (editor-handler/open-block-in-sidebar! block-uuid)
  445. (shui/dialog-close! :ls-dialog-cmdk))))
  446. (defn- open-file
  447. [file-path]
  448. (if (or (string/ends-with? file-path ".edn")
  449. (string/ends-with? file-path ".js")
  450. (string/ends-with? file-path ".css"))
  451. (route-handler/redirect! {:to :file
  452. :path-params {:path file-path}})
  453. ;; open this file in directory
  454. (when (util/electron?)
  455. (let [file-fpath (path/path-join (config/get-repo-dir (state/get-current-repo)) file-path)]
  456. (ipc/ipc "openFileInFolder" file-fpath)))))
  457. (defn- page-item?
  458. [item]
  459. (let [block-uuid (:block/uuid (:source-block item))]
  460. (or (boolean (:source-page item))
  461. (and block-uuid (:block/name (db/entity [:block/uuid block-uuid]))))))
  462. (defmethod handle-action :open [_ state event]
  463. (when-let [item (some-> state state->highlighted-item)]
  464. (let [page? (page-item? item)
  465. block? (boolean (:source-block item))
  466. shift? @(::shift? state)
  467. shift-or-sidebar? (or shift? (boolean (:open-sidebar? (:opts state))))
  468. search-mode (:search/mode @state/state)
  469. graph-view? (= search-mode :graph)]
  470. (cond
  471. (:file-path item) (do
  472. (open-file (:file-path item))
  473. (shui/dialog-close! :ls-dialog-cmdk))
  474. (and graph-view? page? (not shift?)) (do
  475. (state/add-graph-search-filter! @(::input state))
  476. (reset! (::input state) ""))
  477. (and shift-or-sidebar? block?) (handle-action :open-block-right state event)
  478. (and shift-or-sidebar? page?) (handle-action :open-page-right state event)
  479. page? (handle-action :open-page state event)
  480. block? (handle-action :open-block state event)))))
  481. (defmethod handle-action :search [_ state _event]
  482. (when-let [item (some-> state state->highlighted-item)]
  483. (let [search-query (:source-search item)]
  484. (reset! (::input state) search-query))))
  485. (defmethod handle-action :trigger [_ state _event]
  486. (let [command (some-> state state->highlighted-item :source-command)
  487. dont-close-commands #{:graph/open :graph/remove :dev/replace-graph-with-db-file :misc/import-edn-data}]
  488. (when-let [action (:action command)]
  489. (when-not (contains? dont-close-commands (:id command))
  490. (shui/dialog-close! :ls-dialog-cmdk))
  491. (util/schedule #(action) 32))))
  492. (defmethod handle-action :create [_ state _event]
  493. (let [item (state->highlighted-item state)
  494. !input (::input state)
  495. create-class? (string/starts-with? @!input "#")
  496. create-whiteboard? (= :whiteboard (:source-create item))
  497. create-page? (= :page (:source-create item))
  498. class (when create-class? (get-class-from-input @!input))]
  499. (p/let [result (cond
  500. create-class?
  501. (db-page-handler/<create-class! class
  502. {:redirect? false})
  503. create-whiteboard? (whiteboard-handler/<create-new-whiteboard-and-redirect! @!input)
  504. create-page? (page-handler/<create! @!input {:redirect? true}))]
  505. (shui/dialog-close! :ls-dialog-cmdk)
  506. (when (and create-class? result)
  507. (state/sidebar-add-block! (state/get-current-repo) (:db/id result) :block)))))
  508. (defn- get-filter-user-input
  509. [input]
  510. (cond
  511. (string/includes? input "/")
  512. (first (common-util/split-last "/" input))
  513. (string/starts-with? input "/")
  514. ""
  515. :else
  516. input))
  517. (defmethod handle-action :filter [_ state _event]
  518. (let [item (some-> state state->highlighted-item)
  519. !input (::input state)]
  520. (reset! !input (get-filter-user-input @!input))
  521. (let [!filter (::filter state)
  522. group (get-in item [:filter :group])]
  523. (swap! !filter assoc :group group)
  524. (load-results group state))))
  525. (defmethod handle-action :theme [_ state]
  526. (when-let [item (some-> state state->highlighted-item)]
  527. (js/LSPluginCore.selectTheme (bean/->js (:source-theme item)))
  528. (shui/dialog-close!)))
  529. (defmethod handle-action :default [_ state event]
  530. (when-let [action (state->action state)]
  531. (handle-action action state event)))
  532. (defn- scroll-into-view-when-invisible
  533. [state target]
  534. (let [*container-ref (::scroll-container-ref state)
  535. container-rect (.getBoundingClientRect @*container-ref)
  536. t1 (.-top container-rect)
  537. b1 (.-bottom container-rect)
  538. target-rect (.getBoundingClientRect target)
  539. t2 (.-top target-rect)
  540. b2 (.-bottom target-rect)]
  541. (when-not (<= t1 t2 b2 b1) ; not visible
  542. (.scrollIntoView target
  543. #js {:inline "nearest"
  544. :behavior "smooth"}))))
  545. (rum/defc mouse-active-effect!
  546. [*mouse-active? deps]
  547. (hooks/use-effect!
  548. #(reset! *mouse-active? false)
  549. deps)
  550. nil)
  551. (rum/defcs result-group
  552. < rum/reactive
  553. (rum/local false ::mouse-active?)
  554. [state' state title group visible-items first-item sidebar?]
  555. (let [{:keys [show items]} (some-> state ::results deref group)
  556. highlighted-item (or @(::highlighted-item state) first-item)
  557. highlighted-group @(::highlighted-group state)
  558. *mouse-active? (::mouse-active? state')
  559. filter' @(::filter state)
  560. can-show-less? (< (get-group-limit group) (count visible-items))
  561. can-show-more? (< (count visible-items) (count items))
  562. show-less #(swap! (::results state) assoc-in [group :show] :less)
  563. show-more #(swap! (::results state) assoc-in [group :show] :more)]
  564. [:<>
  565. (mouse-active-effect! *mouse-active? [highlighted-item])
  566. [:div {:class (if (= title "Create")
  567. "border-b border-gray-06 last:border-b-0"
  568. "border-b border-gray-06 pb-1 last:border-b-0")
  569. :on-mouse-move #(reset! *mouse-active? true)}
  570. (when-not (= title "Create")
  571. [:div {:class "text-xs py-1.5 px-3 flex justify-between items-center gap-2 text-gray-11 bg-gray-02 h-8"}
  572. [:div {:class "font-bold text-gray-11 pl-0.5 cursor-pointer select-none"
  573. :on-click (fn [_e]
  574. ;; change :less to :more or :more to :less
  575. (swap! (::results state) update-in [group :show] {:more :less
  576. :less :more}))}
  577. title]
  578. (when (not= group :create)
  579. [:div {:class "pl-1.5 text-gray-12 rounded-full"
  580. :style {:font-size "0.7rem"}}
  581. (if (<= 100 (count items))
  582. (str "99+")
  583. (count items))])
  584. [:div {:class "flex-1"}]
  585. (when (and (= group highlighted-group)
  586. (or can-show-more? can-show-less?)
  587. (empty? filter')
  588. (not sidebar?))
  589. [:a.text-link.select-node.opacity-50.hover:opacity-90
  590. {:on-click (if (= show :more) show-less show-more)}
  591. (if (= show :more)
  592. [:div.flex.flex-row.gap-1.items-center
  593. "Show less"
  594. (shui/shortcut "mod up" nil)]
  595. [:div.flex.flex-row.gap-1.items-center
  596. "Show more"
  597. (shui/shortcut "mod down" nil)])])])
  598. [:div.search-results
  599. (for [item visible-items
  600. :let [highlighted? (= item highlighted-item)
  601. page? (= "file" (some-> item :icon))
  602. text (some-> item :text)
  603. source-page (some-> item :source-page)
  604. hls-page? (and page? (pdf-utils/hls-file? (:block/title source-page)))]]
  605. (let [item (list-item/root
  606. (assoc item
  607. :group group
  608. :query (when-not (= group :create) @(::input state))
  609. :text (if hls-page? (pdf-utils/fix-local-asset-pagename text) text)
  610. :hls-page? hls-page?
  611. :compact true
  612. :rounded false
  613. :hoverable @*mouse-active?
  614. :highlighted highlighted?
  615. ;; for some reason, the highlight effect does not always trigger on a
  616. ;; boolean value change so manually pass in the dep
  617. :on-highlight-dep highlighted-item
  618. :on-click (fn [e]
  619. (reset! (::highlighted-item state) item)
  620. (handle-action :default state item)
  621. (when-let [on-click (:on-click item)]
  622. (on-click e)))
  623. ;; :on-mouse-enter (fn [e]
  624. ;; (when (not highlighted?)
  625. ;; (reset! (::highlighted-item state) (assoc item :mouse-enter-triggered-highlight true))))
  626. :on-highlight (fn [ref]
  627. (reset! (::highlighted-group state) group)
  628. (when (and ref (.-current ref)
  629. (not (:mouse-enter-triggered-highlight @(::highlighted-item state))))
  630. (scroll-into-view-when-invisible state (.-current ref)))))
  631. nil)]
  632. (if (= group :nodes)
  633. (ui/lazy-visible (fn [] item) {:trigger-once? true})
  634. item)))]]]))
  635. (defn move-highlight [state n]
  636. (let [items (mapcat last (state->results-ordered state (:search/mode @state/state)))
  637. highlighted-item (some-> state ::highlighted-item deref (dissoc :mouse-enter-triggered-highlight))
  638. current-item-index (some->> highlighted-item (.indexOf items))
  639. next-item-index (some-> (or current-item-index 0) (+ n) (mod (count items)))]
  640. (if-let [next-highlighted-item (nth items next-item-index nil)]
  641. (reset! (::highlighted-item state) next-highlighted-item)
  642. (reset! (::highlighted-item state) nil))))
  643. (defn handle-input-change
  644. ([state e] (handle-input-change state e (.. e -target -value)))
  645. ([state e input]
  646. (let [composing? (util/native-event-is-composing? e)
  647. e-type (gobj/getValueByKeys e "type")
  648. composing-end? (= e-type "compositionend")
  649. !input (::input state)
  650. input-ref @(::input-ref state)]
  651. ;; update the input value in the UI
  652. (reset! !input input)
  653. (set! (.-value input-ref) input)
  654. (reset! (::input-changed? state) true)
  655. ;; retrieve the load-results function and update all the results
  656. (when (or (not composing?) composing-end?)
  657. (load-results :default state)))))
  658. (defn- open-current-item-link
  659. "Opens a link for the current item if a page or block. For pages, opens the
  660. first :url property if a db graph or for file graphs opens first property
  661. value with a url. For blocks, opens the first url found in the block content"
  662. [state]
  663. (let [item (some-> state state->highlighted-item)
  664. repo (state/get-current-repo)]
  665. (cond
  666. (page-item? item)
  667. (p/let [page (some-> (get-highlighted-page-uuid-or-name state) db/get-page)
  668. _ (db-async/<get-block repo (:block/uuid page) :children? false)
  669. page' (db/entity repo [:block/uuid (:block/uuid page)])
  670. link (if (config/db-based-graph? repo)
  671. (some (fn [[k v]]
  672. (when (= :url (:logseq.property/type (db/entity repo k)))
  673. (:block/title v)))
  674. (:block/properties page'))
  675. (some #(re-find editor-handler/url-regex (val %)) (:block/properties page')))]
  676. (if link
  677. (js/window.open link)
  678. (notification/show! "No link found in this page's properties." :warning)))
  679. (:source-block item)
  680. (p/let [block-id (:block/uuid (:source-block item))
  681. _ (db-async/<get-block repo block-id :children? false)
  682. block (db/entity [:block/uuid block-id])
  683. link (re-find editor-handler/url-regex (:block/title block))]
  684. (if link
  685. (js/window.open link)
  686. (notification/show! "No link found in this block's content." :warning)))
  687. :else
  688. (notification/show! "No link for this search item." :warning))))
  689. (defn- keydown-handler
  690. [state e]
  691. (let [shift? (.-shiftKey e)
  692. meta? (util/meta-key? e)
  693. ctrl? (.-ctrlKey e)
  694. keyname (.-key e)
  695. enter? (= keyname "Enter")
  696. esc? (= keyname "Escape")
  697. composing? (util/goog-event-is-composing? e)
  698. highlighted-group @(::highlighted-group state)
  699. show-less (fn [] (swap! (::results state) assoc-in [highlighted-group :show] :less))
  700. show-more (fn [] (swap! (::results state) assoc-in [highlighted-group :show] :more))
  701. input @(::input state)
  702. as-keydown? (or (= keyname "ArrowDown") (and ctrl? (= keyname "n")))
  703. as-keyup? (or (= keyname "ArrowUp") (and ctrl? (= keyname "p")))]
  704. (reset! (::shift? state) shift?)
  705. (reset! (::meta? state) meta?)
  706. (when (or as-keydown? as-keyup?)
  707. (util/stop e))
  708. (cond
  709. (and meta? enter?)
  710. (let [repo (state/get-current-repo)]
  711. (shui/dialog-close! :ls-dialog-cmdk)
  712. (state/sidebar-add-block! repo input :search))
  713. as-keydown? (if meta?
  714. (show-more)
  715. (move-highlight state 1))
  716. as-keyup? (if meta?
  717. (show-less)
  718. (move-highlight state -1))
  719. (and enter? (not composing?)) (do
  720. (handle-action :default state e)
  721. (util/stop-propagation e))
  722. esc? (let [filter' @(::filter state)]
  723. (when-not (string/blank? input)
  724. (util/stop e)
  725. (handle-input-change state nil ""))
  726. (when (and filter' (string/blank? input))
  727. (util/stop e)
  728. (reset! (::filter state) nil)
  729. (load-results :default state)))
  730. (and meta? (= keyname "c")) (do
  731. (copy-block-ref state)
  732. (util/stop-propagation e))
  733. (and meta? (= keyname "o"))
  734. (open-current-item-link state)
  735. :else nil)))
  736. (defn- keyup-handler
  737. [state e]
  738. (let [shift? (.-shiftKey e)
  739. meta? (util/meta-key? e)]
  740. (reset! (::shift? state) shift?)
  741. (reset! (::meta? state) meta?)))
  742. (defn- input-placeholder
  743. [sidebar?]
  744. (let [search-mode (:search/mode @state/state)
  745. search-args (:search/args @state/state)]
  746. (cond
  747. (and (= search-mode :graph) (not sidebar?))
  748. "Add graph filter"
  749. (= search-args :new-page)
  750. "Type a page name to create"
  751. :else
  752. "What are you looking for?")))
  753. (rum/defc input-row
  754. [state all-items opts]
  755. (let [highlighted-item @(::highlighted-item state)
  756. input @(::input state)
  757. input-ref (::input-ref state)
  758. debounced-on-change (hooks/use-callback
  759. (gfun/debounce
  760. (fn [e]
  761. (let [new-value (.-value (.-target e))]
  762. (handle-input-change state e)
  763. (when-let [on-change (:on-input-change opts)]
  764. (on-change new-value))))
  765. 200)
  766. [])]
  767. ;; use-effect [results-ordered input] to check whether the highlighted item is still in the results,
  768. ;; if not then clear that puppy out!
  769. ;; This was moved to a functional component
  770. (hooks/use-effect! (fn []
  771. (when (and highlighted-item (= -1 (.indexOf all-items (dissoc highlighted-item :mouse-enter-triggered-highlight))))
  772. (reset! (::highlighted-item state) nil)))
  773. [all-items])
  774. (hooks/use-effect! (fn [] (load-results :default state)) [])
  775. [:div {:class "bg-gray-02 border-b border-1 border-gray-07"}
  776. [:input.cp__cmdk-search-input
  777. {:class "text-xl bg-transparent border-none w-full outline-none px-3 py-3"
  778. :auto-focus true
  779. :autoComplete "off"
  780. :placeholder (input-placeholder false)
  781. :ref #(when-not @input-ref (reset! input-ref %))
  782. :on-change debounced-on-change
  783. :on-blur (fn [_e]
  784. (when-let [on-blur (:on-input-blur opts)]
  785. (on-blur input)))
  786. :on-composition-end (gfun/debounce (fn [e] (handle-input-change state e)) 100)
  787. :on-key-down (gfun/debounce
  788. (fn [e]
  789. (p/let [value (.-value @input-ref)
  790. last-char (last value)
  791. backspace? (= (util/ekey e) "Backspace")
  792. filter-group (:group @(::filter state))
  793. slash? (= (util/ekey e) "/")
  794. namespace-pages (when (and slash? (contains? #{:whiteboards} filter-group))
  795. (search/block-search (state/get-current-repo) (str value "/") {}))
  796. namespace-page-matched? (some #(string/includes? % "/") namespace-pages)]
  797. (when (and filter-group
  798. (or (and slash? (not namespace-page-matched?))
  799. (and backspace? (= last-char "/"))
  800. (and backspace? (= input ""))))
  801. (reset! (::filter state) nil)
  802. (load-results :default state))))
  803. 100)
  804. :default-value input}]]))
  805. (defn rand-tip
  806. []
  807. (rand-nth
  808. [[:div.flex.flex-row.gap-1.items-center.opacity-50.hover:opacity-100
  809. [:div "Type"]
  810. (shui/shortcut "/")
  811. [:div "to filter search results"]]
  812. [:div.flex.flex-row.gap-1.items-center.opacity-50.hover:opacity-100
  813. (shui/shortcut ["mod" "enter"])
  814. [:div "to open search in the sidebar"]]]))
  815. (rum/defcs tip <
  816. {:init (fn [state]
  817. (assoc state ::rand-tip (rand-tip)))}
  818. [inner-state state]
  819. (let [filter' @(::filter state)]
  820. (cond
  821. filter'
  822. [:div.flex.flex-row.gap-1.items-center.opacity-50.hover:opacity-100
  823. [:div "Type"]
  824. (shui/shortcut "esc" {:tiled false})
  825. [:div "to clear search filter"]]
  826. :else
  827. (::rand-tip inner-state))))
  828. (rum/defc hint-button
  829. [text shortcut opts]
  830. (shui/button
  831. (merge {:class "hint-button [&>span:first-child]:hover:opacity-100 opacity-40 hover:opacity-80"
  832. :variant :ghost
  833. :size :sm}
  834. opts)
  835. [[:span.opacity-60 text]
  836. ;; shortcut
  837. (when (not-empty shortcut)
  838. (for [key shortcut]
  839. [:div.ui__button-shortcut-key
  840. (case key
  841. "cmd" [:div (if goog.userAgent/MAC "⌘" "Ctrl")]
  842. "shift" [:div "⇧"]
  843. "return" [:div "⏎"]
  844. "esc" [:div.tracking-tightest {:style {:transform "scaleX(0.8) scaleY(1.2) "
  845. :font-size "0.5rem"
  846. :font-weight "500"}} "ESC"]
  847. (cond-> key (string? key) .toUpperCase))]))]))
  848. (rum/defc hints
  849. [state]
  850. (let [action (state->action state)
  851. button-fn (fn [text shortcut & {:as opts}]
  852. (hint-button text shortcut
  853. {:on-click #(handle-action action (assoc state :opts opts) %)
  854. :muted true}))]
  855. (when action
  856. [:div.hints
  857. [:div.text-sm.leading-6
  858. [:div.flex.flex-row.gap-1.items-center
  859. [:div.font-medium.text-gray-12 "Tip:"]
  860. (tip state)]]
  861. [:div.gap-2.hidden.md:flex {:style {:margin-right -6}}
  862. (case action
  863. :open
  864. [:<>
  865. (button-fn "Open" ["return"])
  866. (button-fn "Open in sidebar" ["shift" "return"] {:open-sidebar? true})
  867. (when (:source-block @(::highlighted-item state)) (button-fn "Copy ref" ["⌘" "c"]))]
  868. :search
  869. [:<>
  870. (button-fn "Search" ["return"])]
  871. :trigger
  872. [:<>
  873. (button-fn "Trigger" ["return"])]
  874. :create
  875. [:<>
  876. (button-fn "Create" ["return"])]
  877. :filter
  878. [:<>
  879. (button-fn "Filter" ["return"])]
  880. nil)]])))
  881. (rum/defc search-only
  882. [state group-name]
  883. [:div.flex.flex-row.gap-1.items-center
  884. [:div "Search only:"]
  885. [:div group-name]
  886. (shui/button
  887. {:variant :ghost
  888. :size :icon
  889. :class "p-1 scale-75"
  890. :on-click (fn []
  891. (reset! (::filter state) nil))}
  892. (shui/tabler-icon "x"))])
  893. (rum/defcs cmdk
  894. < rum/static
  895. rum/reactive
  896. {:will-mount
  897. (fn [state]
  898. (when-not (:sidebar? (last (:rum/args state)))
  899. (shortcut/unlisten-all!))
  900. state)
  901. :will-unmount
  902. (fn [state]
  903. (when-not (:sidebar? (last (:rum/args state)))
  904. (shortcut/listen-all!))
  905. state)}
  906. {:init (fn [state]
  907. (let [search-mode (:search/mode @state/state)
  908. opts (last (:rum/args state))]
  909. (assoc state
  910. ::ref (atom nil)
  911. ::filter (if (and search-mode
  912. (not (contains? #{:global :graph} search-mode))
  913. (not (:sidebar? opts)))
  914. (atom {:group search-mode})
  915. (atom nil))
  916. ::input (atom (or (:initial-input opts) "")))))
  917. :will-unmount (fn [state]
  918. (state/set-state! :search/mode nil)
  919. (state/set-state! :search/args nil)
  920. state)}
  921. (mixins/event-mixin
  922. (fn [state]
  923. (let [ref @(::ref state)]
  924. (mixins/on-key-down state {}
  925. {:target ref
  926. :all-handler (fn [e _key] (keydown-handler state e))})
  927. (mixins/on-key-up state {} (fn [e _key]
  928. (keyup-handler state e))))))
  929. (rum/local false ::shift?)
  930. (rum/local false ::meta?)
  931. (rum/local nil ::highlighted-group)
  932. (rum/local nil ::highlighted-item)
  933. (rum/local default-results ::results)
  934. (rum/local nil ::scroll-container-ref)
  935. (rum/local nil ::input-ref)
  936. (rum/local false ::input-changed?)
  937. [state {:keys [sidebar?] :as opts}]
  938. (let [*input (::input state)
  939. search-mode (:search/mode @state/state)
  940. group-filter (:group (rum/react (::filter state)))
  941. results-ordered (state->results-ordered state search-mode)
  942. all-items (mapcat last results-ordered)
  943. first-item (first all-items)]
  944. [:div.cp__cmdk {:ref #(when-not @(::ref state) (reset! (::ref state) %))
  945. :class (cond-> "w-full h-full relative flex flex-col justify-start"
  946. (not sidebar?) (str " rounded-lg"))}
  947. (input-row state all-items opts)
  948. [:div {:class (cond-> "w-full flex-1 overflow-y-auto min-h-[65dvh] max-h-[65dvh]"
  949. (not sidebar?) (str " pb-14"))
  950. :ref #(let [*ref (::scroll-container-ref state)]
  951. (when-not @*ref (reset! *ref %)))
  952. :style {:background "var(--lx-gray-02)"
  953. :scroll-padding-block 32}}
  954. (when group-filter
  955. [:div.flex.flex-col.px-3.py-1.opacity-70.text-sm
  956. (search-only state (string/capitalize (name group-filter)))])
  957. (let [items (filter
  958. (fn [[_group-name group-key group-count _group-items]]
  959. (and (not= 0 group-count)
  960. (if-not group-filter true
  961. (or (= group-filter group-key)
  962. (and (= group-filter :nodes)
  963. (= group-key :current-page))
  964. (and (contains? #{:create} group-filter)
  965. (= group-key :create))))))
  966. results-ordered)]
  967. (when-not (= ["Filters"] (map first items))
  968. (if (seq items)
  969. (for [[group-name group-key _group-count group-items] items]
  970. (let [title (string/capitalize group-name)]
  971. (result-group state title group-key group-items first-item sidebar?)))
  972. [:div.flex.flex-col.p-4.opacity-50
  973. (when-not (string/blank? @*input)
  974. "No matched results")])))]
  975. (when-not sidebar? (hints state))]))
  976. (rum/defc cmdk-modal [props]
  977. [:div {:class "cp__cmdk__modal rounded-lg w-[90dvw] max-w-4xl relative"}
  978. (cmdk props)])
  979. (rum/defc cmdk-block [props]
  980. [:div {:class "cp__cmdk__block rounded-md"}
  981. (cmdk props)])