Files
logseq/src/main/frontend/components/cmdk/core.cljs
Gabriel Horner afe4280c6a refactor: DRY up spread out definition of db's
namespace/parent feature. Following up to #11517, provide vars
so it's clear how a feature is used and coupled throughout the codebase
2024-09-23 14:53:12 -04:00

1021 lines
43 KiB
Clojure

(ns frontend.components.cmdk.core
(:require
[cljs-bean.core :as bean]
[clojure.string :as string]
[frontend.components.block :as block]
[frontend.components.cmdk.list-item :as list-item]
[frontend.components.title :as title]
[frontend.extensions.pdf.utils :as pdf-utils]
[frontend.context.i18n :refer [t]]
[frontend.db :as db]
[frontend.db.model :as model]
[frontend.handler.command-palette :as cp-handler]
[frontend.handler.editor :as editor-handler]
[frontend.handler.page :as page-handler]
[frontend.handler.route :as route-handler]
[frontend.handler.whiteboard :as whiteboard-handler]
[frontend.handler.notification :as notification]
[frontend.modules.shortcut.core :as shortcut]
[frontend.handler.db-based.page :as db-page-handler]
[frontend.search :as search]
[frontend.state :as state]
[frontend.ui :as ui]
[frontend.util :as util]
[frontend.util.page :as page-util]
[goog.functions :as gfun]
[goog.object :as gobj]
[logseq.shui.ui :as shui]
[promesa.core :as p]
[rum.core :as rum]
[frontend.mixins :as mixins]
[logseq.common.util.block-ref :as block-ref]
[logseq.common.util :as common-util]
[frontend.modules.shortcut.utils :as shortcut-utils]
[frontend.config :as config]
[logseq.common.path :as path]
[electron.ipc :as ipc]
[frontend.util.text :as text-util]
[goog.userAgent]
[frontend.db.async :as db-async]
[logseq.db :as ldb]
[logseq.common.util.namespace :as ns-util]))
(defn translate [t {:keys [id desc]}]
(when id
(let [desc-i18n (t (shortcut-utils/decorate-namespace id))]
(if (string/starts-with? desc-i18n "{Missing key")
desc
desc-i18n))))
(def GROUP-LIMIT 5)
(defn filters
[]
(let [current-page (state/get-current-page)]
(->>
[(when current-page
{:filter {:group :current-page} :text "Search only current page" :info "Add filter to search" :icon-theme :gray :icon "page"})
{:filter {:group :nodes} :text "Search only nodes" :info "Add filter to search" :icon-theme :gray :icon "letter-n"}
{:filter {:group :commands} :text "Search only commands" :info "Add filter to search" :icon-theme :gray :icon "command"}
{:filter {:group :files} :text "Search only files" :info "Add filter to search" :icon-theme :gray :icon "file"}
{:filter {:group :themes} :text "Search only themes" :info "Add filter to search" :icon-theme :gray :icon "palette"}]
(remove nil?))))
;; The results are separated into groups, and loaded/fetched/queried separately
(def default-results
{:commands {:status :success :show :less :items nil}
:favorites {:status :success :show :less :items nil}
:current-page {:status :success :show :less :items nil}
:nodes {:status :success :show :less :items nil}
:files {:status :success :show :less :items nil}
:themes {:status :success :show :less :items nil}
:filters {:status :success :show :less :items nil}})
(defn get-class-from-input
[input]
(string/replace input #"^#+" ""))
(defn create-items [q]
(when-not (string/blank? q)
(let [class? (string/starts-with? q "#")]
(->> [{:text (if class? "Create tag" "Create page") :icon "new-page"
:icon-theme :gray
:info (if class?
(str "Create class called '" (get-class-from-input q) "'")
(str "Create page called '" q "'"))
:source-create :page}]
(remove nil?)))))
;; Take the results, decide how many items to show, and order the results appropriately
(defn state->results-ordered [state search-mode]
(let [sidebar? (:sidebar? (last (:rum/args state)))
results @(::results state)
input @(::input state)
filter' @(::filter state)
filter-group (:group filter')
index (volatile! -1)
visible-items (fn [group]
(let [{:keys [items show]} (get results group)]
(cond
(or sidebar? (= group filter-group))
items
(= :more show)
items
:else
(take 5 items))))
node-exists? (let [blocks-result (keep :source-block (get-in results [:nodes :items]))]
(when-not (string/blank? input)
(or (some-> (last (string/split input ns-util/parent-char))
string/trim
db/get-page)
(some (fn [block]
(and
(:block/tags block)
(= input (util/page-name-sanity-lc (:block/title block))))) blocks-result))))
include-slash? (or (string/includes? input "/")
(string/starts-with? input "/"))
order* (cond
(= search-mode :graph)
[]
include-slash?
[(when-not node-exists?
["Create" :create (create-items input)])
["Current page" :current-page (visible-items :current-page)]
["Nodes" :nodes (visible-items :nodes)]
["Files" :files (visible-items :files)]
["Filters" :filters (visible-items :filters)]]
filter-group
[(when (= filter-group :nodes)
["Current page" :current-page (visible-items :current-page)])
[(if (= filter-group :current-page) "Current page" (name filter-group))
filter-group
(visible-items filter-group)]
(when-not node-exists?
["Create" :create (create-items input)])]
:else
(->>
[(when-not node-exists?
["Create" :create (create-items input)])
["Current page" :current-page (visible-items :current-page)]
["Nodes" :nodes (visible-items :nodes)]
["Commands" :commands (visible-items :commands)]
["Files" :files (visible-items :files)]
["Filters" :filters (visible-items :filters)]]
(remove nil?)))
order (remove nil? order*)]
(for [[group-name group-key group-items] order]
[group-name
group-key
(if (= group-key :create)
(count group-items)
(count (get-in results [group-key :items])))
(mapv #(assoc % :item-index (vswap! index inc)) group-items)])))
(defn state->highlighted-item [state]
(or (some-> state ::highlighted-item deref)
(some->> (state->results-ordered state (:search/mode @state/state))
(mapcat last)
(first))))
(defn state->action [state]
(let [highlighted-item (state->highlighted-item state)]
(cond (:source-page highlighted-item) :open
(:source-block highlighted-item) :open
(:file-path highlighted-item) :open
(:source-search highlighted-item) :search
(:source-command highlighted-item) :trigger
(:source-create highlighted-item) :create
(:filter highlighted-item) :filter
(:source-theme highlighted-item) :theme
:else nil)))
;; Each result group has it's own load-results function
(defmulti load-results (fn [group _state] group))
(defmethod load-results :initial [_ state]
(let [!results (::results state)
command-items (->> (cp-handler/top-commands 100)
(remove (fn [c] (= :window/close (:id c))))
(map #(hash-map :icon "command"
:icon-theme :gray
:text (translate t %)
:shortcut (:shortcut %)
:source-command %)))]
(reset! !results (assoc-in default-results [:commands :items] command-items))))
;; The commands search uses the command-palette handler
(defmethod load-results :commands [group state]
(let [!input (::input state)
!results (::results state)]
(swap! !results assoc-in [group :status] :loading)
(let [commands (->> (cp-handler/top-commands 1000)
(map #(assoc % :t (translate t %))))
search-results (if (string/blank? @!input)
commands
(search/fuzzy-search commands @!input {:extract-fn :t}))]
(->> search-results
(map #(hash-map :icon "command"
:icon-theme :gray
:text (translate t %)
:shortcut (:shortcut %)
:source-command %))
(hash-map :status :success :items)
(swap! !results update group merge)))))
(defn highlight-content-query
"Return hiccup of highlighted content FTS result"
[content q]
(when-not (or (string/blank? content) (string/blank? q))
[:div (loop [content content ;; why recur? because there might be multiple matches
result []]
(let [[b-cut hl-cut e-cut] (text-util/cut-by content "$pfts_2lqh>$" "$<pfts_2lqh$")
hiccups-add [(when-not (string/blank? b-cut)
[:span b-cut])
(when-not (string/blank? hl-cut)
[:mark.p-0.rounded-none hl-cut])]
hiccups-add (remove nil? hiccups-add)
new-result (concat result hiccups-add)]
(if-not (string/blank? e-cut)
(recur e-cut new-result)
new-result)))]))
(defn- page-item
[repo page]
(let [entity (db/entity [:block/uuid (:block/uuid page)])
source-page (model/get-alias-source-page repo (:db/id entity))
icon (cond
(ldb/class? entity)
"hash"
(ldb/property? entity)
"letter-p"
(ldb/whiteboard? entity)
"whiteboard"
:else
"page")
title (title/block-unique-title page)
title' (if source-page (str title " -> alias: " (:block/title source-page)) title)]
(hash-map :icon icon
:icon-theme :gray
:text title'
:source-page (or source-page page))))
(defn- block-item
[repo block current-page !input]
(let [id (:block/uuid block)
text (title/block-unique-title block)
icon "letter-n"]
{:icon icon
:icon-theme :gray
:text (highlight-content-query text @!input)
:header (when-not (db/page? block) (block/breadcrumb {:search? true} repo id {}))
:current-page? (when-let [page-id (:block/page block)]
(= page-id (:block/uuid current-page)))
:source-block block}))
;; The blocks search action uses an existing handler
(defmethod load-results :nodes [group state]
(let [!input (::input state)
!results (::results state)
repo (state/get-current-repo)
current-page (when-let [id (page-util/get-current-page-id)]
(db/entity id))
opts {:limit 100 :dev? config/dev? :built-in? true}]
(swap! !results assoc-in [group :status] :loading)
(swap! !results assoc-in [:current-page :status] :loading)
(p/let [blocks (search/block-search repo @!input opts)
blocks (remove nil? blocks)
blocks (search/fuzzy-search blocks @!input {:limit 100
:extract-fn :block/title})
items (keep (fn [block]
(if (:page? block)
(page-item repo block)
(block-item repo block current-page !input))) blocks)]
(if (= group :current-page)
(let [items-on-current-page (filter :current-page? items)]
(swap! !results update group merge {:status :success :items items-on-current-page}))
(swap! !results update group merge {:status :success :items items})))))
(defmethod load-results :files [group state]
(let [!input (::input state)
!results (::results state)]
(swap! !results assoc-in [group :status] :loading)
(p/let [files* (search/file-search @!input 99)
files (remove
(fn [f]
(and
f
(string/ends-with? f ".edn")
(or (string/starts-with? f "whiteboards/")
(string/starts-with? f "assets/")
(string/starts-with? f "logseq/version-files")
(contains? #{"logseq/metadata.edn" "logseq/pages-metadata.edn" "logseq/graphs-txid.edn"} f))))
files*)
items (map
(fn [file]
(hash-map :icon "file"
:icon-theme :gray
:text file
:file-path file))
files)]
(swap! !results update group merge {:status :success :items items}))))
(defmethod load-results :themes [group _state]
(let [!input (::input _state)
!results (::results _state)
themes (state/sub :plugin/installed-themes)
themes (if (string/blank? @!input)
themes
(search/fuzzy-search themes @!input :limit 100 :extract-fn :name))
themes (cons {:name "Logseq Default theme"
:pid "logseq-classic-theme"
:mode (state/sub :ui/theme)
:url nil} themes)
selected (state/sub :plugin/selected-theme)]
(swap! !results assoc-in [group :status] :loading)
(let [items (for [t themes
:let [selected? (= (:url t) selected)]]
{:icon-theme :gray :text (:name t) :info (str (:mode t) " #" (:pid t))
:icon (if selected? "checkbox" "palette") :source-theme t :selected selected?})]
(swap! !results update group merge {:status :success :items items}))))
(defn- get-filter-q
[input]
(or (when (string/starts-with? input "/")
(subs input 1))
(last (common-util/split-last "/" input))))
(defmethod load-results :filters [group state]
(let [!results (::results state)
!input (::input state)
input @!input
q (or (get-filter-q input) "")
matched-items (if (string/blank? q)
(filters)
(search/fuzzy-search (filters) q {:extract-fn :text}))]
(swap! !results update group merge {:status :success :items matched-items})))
(defmethod load-results :current-page [group state]
(if-let [current-page (when-let [id (page-util/get-current-page-id)]
(db/entity id))]
(let [!results (::results state)
!input (::input state)
repo (state/get-current-repo)
opts {:limit 100 :page (str (:block/uuid current-page))}]
(swap! !results assoc-in [group :status] :loading)
(swap! !results assoc-in [:current-page :status] :loading)
(p/let [blocks (search/block-search repo @!input opts)
blocks (remove nil? blocks)
items (map (fn [block]
(let [id (if (uuid? (:block/uuid block))
(:block/uuid block)
(uuid (:block/uuid block)))]
{:icon "node"
:icon-theme :gray
:text (highlight-content-query (:block/title block) @!input)
:header (block/breadcrumb {:search? true} repo id {})
:current-page? true
:source-block block})) blocks)]
(swap! !results update :current-page merge {:status :success :items items})))
(reset! (::filter state) nil)))
;; The default load-results function triggers all the other load-results function
(defmethod load-results :default [_ state]
(let [filter-group (:group @(::filter state))]
(if (and (not (some-> state ::input deref seq))
(not filter-group))
(do (load-results :initial state)
(load-results :filters state))
(if filter-group
(load-results filter-group state)
(do
(load-results :commands state)
(load-results :nodes state)
(load-results :filters state)
(load-results :files state)
;; (load-results :recents state)
)))))
(defn- copy-block-ref [state]
(when-let [block-uuid (some-> state state->highlighted-item :source-block :block/uuid)]
(editor-handler/copy-block-ref! block-uuid block-ref/->block-ref)
(state/close-modal!)))
(defmulti handle-action (fn [action _state _event] action))
(defn- get-highlighted-page-uuid-or-name
[state]
(let [highlighted-item (some-> state state->highlighted-item)]
(or (:block/uuid (:source-block highlighted-item))
(:block/uuid (:source-page highlighted-item)))))
(defmethod handle-action :open-page [_ state _event]
(when-let [page-name (get-highlighted-page-uuid-or-name state)]
(let [page (db/get-page page-name)]
(route-handler/redirect-to-page! (:block/uuid page)))
(state/close-modal!)))
(defmethod handle-action :open-block [_ state _event]
(when-let [block-id (some-> state state->highlighted-item :source-block :block/uuid)]
(p/let [repo (state/get-current-repo)
_ (db-async/<get-block repo block-id :children? false)
block (db/entity [:block/uuid block-id])
parents (db-async/<get-block-parents (state/get-current-repo) (:db/id block) 1000)
created-from-block (some (fn [block']
(let [block (db/entity (:db/id block'))]
(when (:logseq.property/created-from-property block)
(:block/parent block)))) parents)
[block-id block] (if created-from-block
(let [block (db/entity (:db/id created-from-block))]
[(:block/uuid block) block])
[block-id block])]
(let [get-block-page (partial model/get-block-page repo)]
(when block
(when-let [page (some-> block-id get-block-page)]
(cond
(db/whiteboard-page? page)
(route-handler/redirect-to-page! (:block/uuid page) {:block-id block-id})
(model/parents-collapsed? (state/get-current-repo) block-id)
(route-handler/redirect-to-page! block-id)
:else
(route-handler/redirect-to-page! (:block/uuid page) {:anchor (str "ls-block-" block-id)}))
(state/close-modal!)))))))
(defmethod handle-action :open-page-right [_ state _event]
(when-let [page-name (get-highlighted-page-uuid-or-name state)]
(let [page (db/get-page page-name)]
(when page
(editor-handler/open-block-in-sidebar! (:block/uuid page))))
(state/close-modal!)))
(defmethod handle-action :open-block-right [_ state _event]
(when-let [block-uuid (some-> state state->highlighted-item :source-block :block/uuid)]
(p/let [repo (state/get-current-repo)
_ (db-async/<get-block repo block-uuid :children? false)]
(editor-handler/open-block-in-sidebar! block-uuid)
(state/close-modal!))))
(defn- open-file
[file-path]
(if (or (string/ends-with? file-path ".edn")
(string/ends-with? file-path ".js")
(string/ends-with? file-path ".css"))
(route-handler/redirect! {:to :file
:path-params {:path file-path}})
;; open this file in directory
(when (util/electron?)
(let [file-fpath (path/path-join (config/get-repo-dir (state/get-current-repo)) file-path)]
(ipc/ipc "openFileInFolder" file-fpath)))))
(defn- page-item?
[item]
(let [block-uuid (:block/uuid (:source-block item))]
(or (boolean (:source-page item))
(and block-uuid (:block/name (db/entity [:block/uuid block-uuid]))))))
(defmethod handle-action :open [_ state event]
(when-let [item (some-> state state->highlighted-item)]
(let [page? (page-item? item)
block? (boolean (:source-block item))
shift? @(::shift? state)
shift-or-sidebar? (or shift? (boolean (:open-sidebar? (:opts state))))
search-mode (:search/mode @state/state)
graph-view? (= search-mode :graph)]
(cond
(:file-path item) (do
(open-file (:file-path item))
(state/close-modal!))
(and graph-view? page? (not shift?)) (do
(state/add-graph-search-filter! @(::input state))
(reset! (::input state) ""))
(and shift-or-sidebar? block?) (handle-action :open-block-right state event)
(and shift-or-sidebar? page?) (handle-action :open-page-right state event)
page? (handle-action :open-page state event)
block? (handle-action :open-block state event)))))
(defmethod handle-action :search [_ state _event]
(when-let [item (some-> state state->highlighted-item)]
(let [search-query (:source-search item)]
(reset! (::input state) search-query))))
(defmethod handle-action :trigger [_ state _event]
(let [command (some-> state state->highlighted-item :source-command)]
(when-let [action (:action command)]
(action)
(when-not (contains? #{:graph/open :graph/remove :dev/replace-graph-with-db-file :ui/toggle-settings :go/flashcards} (:id command))
(state/close-modal!)))))
(defmethod handle-action :create [_ state _event]
(let [item (state->highlighted-item state)
!input (::input state)
create-class? (string/starts-with? @!input "#")
create-whiteboard? (= :whiteboard (:source-create item))
create-page? (= :page (:source-create item))
class (when create-class? (get-class-from-input @!input))]
(p/let [result (cond
create-class?
(db-page-handler/<create-class! class
{:redirect? false
:create-first-block? false})
create-whiteboard? (whiteboard-handler/<create-new-whiteboard-and-redirect! @!input)
create-page? (page-handler/<create! @!input {:redirect? true}))]
(state/close-modal!)
(when (and create-class? result)
(state/pub-event! [:class/configure result])))))
(defn- get-filter-user-input
[input]
(cond
(string/includes? input "/")
(first (common-util/split-last "/" input))
(string/starts-with? input "/")
""
:else
input))
(defmethod handle-action :filter [_ state _event]
(let [item (some-> state state->highlighted-item)
!input (::input state)]
(reset! !input (get-filter-user-input @!input))
(let [!filter (::filter state)
group (get-in item [:filter :group])]
(swap! !filter assoc :group group)
(load-results group state))))
(defmethod handle-action :theme [_ state]
(when-let [item (some-> state state->highlighted-item)]
(js/LSPluginCore.selectTheme (bean/->js (:source-theme item)))
(state/close-modal!)))
(defmethod handle-action :default [_ state event]
(when-let [action (state->action state)]
(handle-action action state event)))
(defn- scroll-into-view-when-invisible
[state target]
(let [*container-ref (::scroll-container-ref state)
container-rect (.getBoundingClientRect @*container-ref)
t1 (.-top container-rect)
b1 (.-bottom container-rect)
target-rect (.getBoundingClientRect target)
t2 (.-top target-rect)
b2 (.-bottom target-rect)]
(when-not (<= t1 t2 b2 b1) ; not visible
(.scrollIntoView target
#js {:inline "nearest"
:behavior "smooth"}))))
(rum/defc mouse-active-effect!
[*mouse-active? deps]
(rum/use-effect!
#(reset! *mouse-active? false)
deps)
nil)
(rum/defcs result-group
< rum/reactive
(rum/local false ::mouse-active?)
[state' state title group visible-items first-item sidebar?]
(let [{:keys [show items]} (some-> state ::results deref group)
highlighted-item (or @(::highlighted-item state) first-item)
highlighted-group @(::highlighted-group state)
*mouse-active? (::mouse-active? state')
filter' @(::filter state)
can-show-less? (< GROUP-LIMIT (count visible-items))
can-show-more? (< (count visible-items) (count items))
show-less #(swap! (::results state) assoc-in [group :show] :less)
show-more #(swap! (::results state) assoc-in [group :show] :more)]
[:<>
(mouse-active-effect! *mouse-active? [highlighted-item])
[:div {:class (if (= title "Create")
"border-b border-gray-06 last:border-b-0"
"border-b border-gray-06 pb-1 last:border-b-0")
:on-mouse-move #(reset! *mouse-active? true)}
(when-not (= title "Create")
[:div {:class "text-xs py-1.5 px-3 flex justify-between items-center gap-2 text-gray-11 bg-gray-02 h-8"}
[:div {:class "font-bold text-gray-11 pl-0.5 cursor-pointer select-none"
:on-click (fn [_e]
;; change :less to :more or :more to :less
(swap! (::results state) update-in [group :show] {:more :less
:less :more}))}
title]
(when (not= group :create)
[:div {:class "pl-1.5 text-gray-12 rounded-full"
:style {:font-size "0.7rem"}}
(if (<= 100 (count items))
(str "99+")
(count items))])
[:div {:class "flex-1"}]
(when (and (= group highlighted-group)
(or can-show-more? can-show-less?)
(empty? filter')
(not sidebar?))
[:a.text-link.select-node.opacity-50.hover:opacity-90
{:on-click (if (= show :more) show-less show-more)}
(if (= show :more)
[:div.flex.flex-row.gap-1.items-center
"Show less"
(shui/shortcut "mod up" nil)]
[:div.flex.flex-row.gap-1.items-center
"Show more"
(shui/shortcut "mod down" nil)])])])
[:div.search-results
(for [item visible-items
:let [highlighted? (= item highlighted-item)
page? (= "page" (some-> item :icon))
text (some-> item :text)
source-page (some-> item :source-page)
hls-page? (and page? (pdf-utils/hls-file? (:block/title source-page)))]]
(let [item (list-item/root
(assoc item
:group group
:query (when-not (= group :create) @(::input state))
:text (if hls-page? (pdf-utils/fix-local-asset-pagename text) text)
:hls-page? hls-page?
:compact true
:rounded false
:hoverable @*mouse-active?
:highlighted highlighted?
;; for some reason, the highlight effect does not always trigger on a
;; boolean value change so manually pass in the dep
:on-highlight-dep highlighted-item
:on-click (fn [e]
(reset! (::highlighted-item state) item)
(handle-action :default state item)
(when-let [on-click (:on-click item)]
(on-click e)))
;; :on-mouse-enter (fn [e]
;; (when (not highlighted?)
;; (reset! (::highlighted-item state) (assoc item :mouse-enter-triggered-highlight true))))
:on-highlight (fn [ref]
(reset! (::highlighted-group state) group)
(when (and ref (.-current ref)
(not (:mouse-enter-triggered-highlight @(::highlighted-item state))))
(scroll-into-view-when-invisible state (.-current ref)))))
nil)]
(if (= group :nodes)
(ui/lazy-visible (fn [] item) {:trigger-once? true})
item)))]]]))
(defn move-highlight [state n]
(let [items (mapcat last (state->results-ordered state (:search/mode @state/state)))
highlighted-item (some-> state ::highlighted-item deref (dissoc :mouse-enter-triggered-highlight))
current-item-index (some->> highlighted-item (.indexOf items))
next-item-index (some-> (or current-item-index 0) (+ n) (mod (count items)))]
(if-let [next-highlighted-item (nth items next-item-index nil)]
(reset! (::highlighted-item state) next-highlighted-item)
(reset! (::highlighted-item state) nil))))
(defn handle-input-change
([state e] (handle-input-change state e (.. e -target -value)))
([state e input]
(let [composing? (util/native-event-is-composing? e)
e-type (gobj/getValueByKeys e "type")
composing-end? (= e-type "compositionend")
!input (::input state)
input-ref @(::input-ref state)]
;; update the input value in the UI
(reset! !input input)
(set! (.-value input-ref) input)
(reset! (::input-changed? state) true)
;; retrieve the load-results function and update all the results
(when (or (not composing?) composing-end?)
(load-results :default state)))))
(defn- open-current-item-link
"Opens a link for the current item if a page or block. For pages, opens the
first :url property if a db graph or for file graphs opens first property
value with a url. For blocks, opens the first url found in the block content"
[state]
(let [item (some-> state state->highlighted-item)
repo (state/get-current-repo)]
(cond
(page-item? item)
(p/let [page (some-> (get-highlighted-page-uuid-or-name state) db/get-page)
_ (db-async/<get-block repo (:block/uuid page) :children? false)
page' (db/entity repo [:block/uuid (:block/uuid page)])
link (if (config/db-based-graph? repo)
(some (fn [[k v]]
(when (= :url (get-in (db/entity repo k) [:block/schema :type]))
(:block/title v)))
(:block/properties page'))
(some #(re-find editor-handler/url-regex (val %)) (:block/properties page')))]
(if link
(js/window.open link)
(notification/show! "No link found in this page's properties." :warning)))
(:source-block item)
(p/let [block-id (:block/uuid (:source-block item))
_ (db-async/<get-block repo block-id :children? false)
block (db/entity [:block/uuid block-id])
link (re-find editor-handler/url-regex (:block/title block))]
(if link
(js/window.open link)
(notification/show! "No link found in this block's content." :warning)))
:else
(notification/show! "No link for this search item." :warning))))
(defn- keydown-handler
[state e]
(let [shift? (.-shiftKey e)
meta? (util/meta-key? e)
ctrl? (.-ctrlKey e)
keyname (.-key e)
enter? (= keyname "Enter")
esc? (= keyname "Escape")
composing? (util/goog-event-is-composing? e)
highlighted-group @(::highlighted-group state)
show-less (fn [] (swap! (::results state) assoc-in [highlighted-group :show] :less))
show-more (fn [] (swap! (::results state) assoc-in [highlighted-group :show] :more))
input @(::input state)
as-keydown? (or (= keyname "ArrowDown") (and ctrl? (= keyname "n")))
as-keyup? (or (= keyname "ArrowUp") (and ctrl? (= keyname "p")))]
(reset! (::shift? state) shift?)
(reset! (::meta? state) meta?)
(when (or as-keydown? as-keyup?)
(util/stop e))
(cond
(and meta? enter?)
(let [repo (state/get-current-repo)]
(state/close-modal!)
(state/sidebar-add-block! repo input :search))
as-keydown? (if meta?
(show-more)
(move-highlight state 1))
as-keyup? (if meta?
(show-less)
(move-highlight state -1))
(and enter? (not composing?)) (do
(handle-action :default state e)
(util/stop-propagation e))
esc? (let [filter' @(::filter state)]
(when-not (string/blank? input)
(util/stop e)
(handle-input-change state nil ""))
(when (and filter' (string/blank? input))
(util/stop e)
(reset! (::filter state) nil)
(load-results :default state)))
(and meta? (= keyname "c")) (do
(copy-block-ref state)
(util/stop-propagation e))
(and meta? (= keyname "o"))
(open-current-item-link state)
:else nil)))
(defn- keyup-handler
[state e]
(let [shift? (.-shiftKey e)
meta? (util/meta-key? e)]
(reset! (::shift? state) shift?)
(reset! (::meta? state) meta?)))
(defn- input-placeholder
[sidebar?]
(let [search-mode (:search/mode @state/state)]
(cond
(and (= search-mode :graph) (not sidebar?))
"Add graph filter"
:else
"What are you looking for?")))
(rum/defc input-row
[state all-items opts]
(let [highlighted-item @(::highlighted-item state)
input @(::input state)
input-ref (::input-ref state)
debounced-on-change (rum/use-callback
(gfun/debounce
(fn [e]
(let [new-value (.-value (.-target e))]
(handle-input-change state e)
(when-let [on-change (:on-input-change opts)]
(on-change new-value))))
100)
[])]
;; use-effect [results-ordered input] to check whether the highlighted item is still in the results,
;; if not then clear that puppy out!
;; This was moved to a functional component
(rum/use-effect! (fn []
(when (and highlighted-item (= -1 (.indexOf all-items (dissoc highlighted-item :mouse-enter-triggered-highlight))))
(reset! (::highlighted-item state) nil)))
[all-items])
(rum/use-effect! (fn [] (load-results :default state)) [])
[:div {:class "bg-gray-02 border-b border-1 border-gray-07"}
[:input.cp__cmdk-search-input
{:class "text-xl bg-transparent border-none w-full outline-none px-3 py-3"
:auto-focus true
:autoComplete "off"
:placeholder (input-placeholder false)
:ref #(when-not @input-ref (reset! input-ref %))
:on-change debounced-on-change
:on-blur (fn [_e]
(when-let [on-blur (:on-input-blur opts)]
(on-blur input)))
:on-composition-end (gfun/debounce (fn [e] (handle-input-change state e)) 100)
:on-key-down (gfun/debounce
(fn [e]
(p/let [value (.-value @input-ref)
last-char (last value)
backspace? (= (util/ekey e) "Backspace")
filter-group (:group @(::filter state))
slash? (= (util/ekey e) "/")
namespace-pages (when (and slash? (contains? #{:whiteboards} filter-group))
(search/block-search (state/get-current-repo) (str value "/") {}))
namespace-page-matched? (some #(string/includes? % "/") namespace-pages)]
(when (and filter-group
(or (and slash? (not namespace-page-matched?))
(and backspace? (= last-char "/"))
(and backspace? (= input ""))))
(reset! (::filter state) nil)
(load-results :default state))))
100)
:default-value input}]]))
(defn rand-tip
[]
(rand-nth
[[:div.flex.flex-row.gap-1.items-center.opacity-50.hover:opacity-100
[:div "Type"]
(shui/shortcut "/")
[:div "to filter search results"]]
[:div.flex.flex-row.gap-1.items-center.opacity-50.hover:opacity-100
(shui/shortcut ["mod" "enter"])
[:div "to open search in the sidebar"]]]))
(rum/defcs tip <
{:init (fn [state]
(assoc state ::rand-tip (rand-tip)))}
[inner-state state]
(let [filter' @(::filter state)]
(cond
filter'
[:div.flex.flex-row.gap-1.items-center.opacity-50.hover:opacity-100
[:div "Type"]
(shui/shortcut "esc" {:tiled false})
[:div "to clear search filter"]]
:else
(::rand-tip inner-state))))
(rum/defc hint-button
[text shortcut opts]
(shui/button
(merge {:class "hint-button [&>span:first-child]:hover:opacity-100 opacity-40 hover:opacity-80"
:variant :ghost
:size :sm}
opts)
[[:span.opacity-60 text]
;; shortcut
(when (not-empty shortcut)
(for [key shortcut]
[:div.ui__button-shortcut-key
(case key
"cmd" [:div (if goog.userAgent/MAC "⌘" "Ctrl")]
"shift" [:div "⇧"]
"return" [:div "⏎"]
"esc" [:div.tracking-tightest {:style {:transform "scaleX(0.8) scaleY(1.2) "
:font-size "0.5rem"
:font-weight "500"}} "ESC"]
(cond-> key (string? key) .toUpperCase))]))]))
(rum/defc hints
[state]
(let [action (state->action state)
button-fn (fn [text shortcut & {:as opts}]
(hint-button text shortcut
{:on-click #(handle-action action (assoc state :opts opts) %)
:muted true}))]
(when action
[:div.hints
[:div.text-sm.leading-6
[:div.flex.flex-row.gap-1.items-center
[:div.font-medium.text-gray-12 "Tip:"]
(tip state)]]
[:div.gap-2.hidden.md:flex {:style {:margin-right -6}}
(case action
:open
[:<>
(button-fn "Open" ["return"])
(button-fn "Open in sidebar" ["shift" "return"] {:open-sidebar? true})
(when (:source-block @(::highlighted-item state)) (button-fn "Copy ref" ["⌘" "c"]))]
:search
[:<>
(button-fn "Search" ["return"])]
:trigger
[:<>
(button-fn "Trigger" ["return"])]
:create
[:<>
(button-fn "Create" ["return"])]
:filter
[:<>
(button-fn "Filter" ["return"])]
nil)]])))
(rum/defc search-only
[state group-name]
[:div.flex.flex-row.gap-1.items-center
[:div "Search only:"]
[:div group-name]
(shui/button
{:variant :ghost
:size :icon
:class "p-1 scale-75"
:on-click (fn []
(reset! (::filter state) nil))}
(shui/tabler-icon "x"))])
(rum/defcs cmdk
< rum/static
rum/reactive
{:will-mount
(fn [state]
(when-not (:sidebar? (last (:rum/args state)))
(shortcut/unlisten-all!))
state)
:will-unmount
(fn [state]
(when-not (:sidebar? (last (:rum/args state)))
(shortcut/listen-all!))
state)}
{:init (fn [state]
(let [search-mode (:search/mode @state/state)
opts (last (:rum/args state))]
(assoc state
::ref (atom nil)
::filter (if (and search-mode
(not (contains? #{:global :graph} search-mode))
(not (:sidebar? opts)))
(atom {:group search-mode})
(atom nil))
::input (atom (or (:initial-input opts) "")))))
:will-unmount (fn [state]
(state/set-state! :search/mode nil)
state)}
(mixins/event-mixin
(fn [state]
(let [ref @(::ref state)]
(mixins/on-key-down state {}
{:target ref
:all-handler (fn [e _key] (keydown-handler state e))})
(mixins/on-key-up state {} (fn [e _key]
(keyup-handler state e))))))
(rum/local false ::shift?)
(rum/local false ::meta?)
(rum/local nil ::highlighted-group)
(rum/local nil ::highlighted-item)
(rum/local default-results ::results)
(rum/local nil ::scroll-container-ref)
(rum/local nil ::input-ref)
(rum/local false ::input-changed?)
[state {:keys [sidebar?] :as opts}]
(let [*input (::input state)
search-mode (:search/mode @state/state)
group-filter (:group (rum/react (::filter state)))
results-ordered (state->results-ordered state search-mode)
all-items (mapcat last results-ordered)
first-item (first all-items)]
[:div.cp__cmdk {:ref #(when-not @(::ref state) (reset! (::ref state) %))
:class (cond-> "w-full h-full relative flex flex-col justify-start"
(not sidebar?) (str " rounded-lg"))}
(input-row state all-items opts)
[:div {:class (cond-> "w-full flex-1 overflow-y-auto min-h-[65dvh] max-h-[65dvh]"
(not sidebar?) (str " pb-14"))
:ref #(let [*ref (::scroll-container-ref state)]
(when-not @*ref (reset! *ref %)))
:style {:background "var(--lx-gray-02)"
:scroll-padding-block 32}}
(when group-filter
[:div.flex.flex-col.px-3.py-1.opacity-70.text-sm
(search-only state (string/capitalize (name group-filter)))])
(let [items (filter
(fn [[_group-name group-key group-count _group-items]]
(and (not= 0 group-count)
(if-not group-filter true
(or (= group-filter group-key)
(and (= group-filter :nodes)
(= group-key :current-page))
(and (contains? #{:create} group-filter)
(= group-key :create))))))
results-ordered)]
(when-not (= ["Filters"] (map first items))
(if (seq items)
(for [[group-name group-key _group-count group-items] items]
(let [title (string/capitalize group-name)]
(result-group state title group-key group-items first-item sidebar?)))
[:div.flex.flex-col.p-4.opacity-50
(when-not (string/blank? @*input)
"No matched results")])))]
(when-not sidebar? (hints state))]))
(rum/defc cmdk-modal [props]
[:div {:class "cp__cmdk__modal rounded-lg w-[90dvw] max-w-4xl relative"}
(cmdk props)])
(rum/defc cmdk-block [props]
[:div {:class "cp__cmdk__block rounded-md"}
(cmdk props)])