core.cljs 43 KB

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