core.cljs 45 KB

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