cmdk.cljs 44 KB

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