(ns frontend.commands "Provides functionality for commands and advanced commands" (:require [clojure.string :as string] [frontend.config :as config] [frontend.date :as date] [frontend.db :as db] [frontend.db.utils :as db-util] [frontend.handler.draw :as draw] [frontend.handler.notification :as notification] [frontend.handler.plugin :as plugin-handler] [frontend.extensions.video.youtube :as youtube] [frontend.search :as search] [frontend.state :as state] [frontend.util :as util] [frontend.util.cursor :as cursor] [frontend.util.marker :as marker] [frontend.util.priority :as priority] [frontend.util.property :as property] [logseq.graph-parser.util :as gp-util] [logseq.graph-parser.config :as gp-config] [logseq.graph-parser.property :as gp-property] [logseq.graph-parser.util.page-ref :as page-ref] [logseq.graph-parser.util.block-ref :as block-ref] [goog.dom :as gdom] [goog.object :as gobj] [promesa.core :as p])) ;; TODO: move to frontend.handler.editor.commands (defonce angle-bracket "<") (defonce colon ":") (defonce *current-command (atom nil)) (def query-doc [:div {:on-mouse-down (fn [e] (.stopPropagation e))} [:div.font-medium.text-lg.mb-2 "Query examples:"] [:ul.mb-1 [:li.mb-1 [:code "{{query #tag}}"]] [:li.mb-1 [:code "{{query [[page]]}}"]] [:li.mb-1 [:code "{{query \"full-text search\"}}"]] [:li.mb-1 [:code "{{query (and [[project]] (task NOW LATER))}}"]] [:li.mb-1 [:code "{{query (or [[page 1]] [[page 2]])}}"]] [:li.mb-1 [:code "{{query (and (between -7d +7d) (task DONE))}}"]] [:li.mb-1 [:code "{{query (property key value)}}"]] [:li.mb-1 [:code "{{query (page-tags #tag)}}"]]] [:p "Check more examples at " [:a {:href "https://docs.logseq.com/#/page/queries" :target "_blank"} "Queries documentation"] "."]]) (defn link-steps [] [[:editor/input (str (state/get-editor-command-trigger) "link")] [:editor/show-input [{:command :link :id :link :placeholder "Link" :autoFocus true} {:command :link :id :label :placeholder "Label"}]]]) (defn image-link-steps [] [[:editor/input (str (state/get-editor-command-trigger) "link")] [:editor/show-input [{:command :image-link :id :link :placeholder "Link" :autoFocus true} {:command :image-link :id :label :placeholder "Label"}]]]) (defn zotero-steps [] [[:editor/input (str (state/get-editor-command-trigger) "zotero")] [:editor/show-zotero]]) (def *extend-slash-commands (atom [])) (defn register-slash-command [cmd] (swap! *extend-slash-commands conj cmd)) (defn ->marker [marker] [[:editor/clear-current-slash] [:editor/set-marker marker] [:editor/move-cursor-to-end]]) (defn ->priority [priority] [[:editor/clear-current-slash] [:editor/set-priority priority] [:editor/move-cursor-to-end]]) (defn ->inline [type] (let [template (util/format "@@%s: @@" type)] [[:editor/input template {:last-pattern (state/get-editor-command-trigger) :backward-pos 2}]])) (defn embed-page [] (conj [[:editor/input "{{embed [[]]}}" {:last-pattern (state/get-editor-command-trigger) :backward-pos 4}]] [:editor/search-page :embed])) (defn embed-block [] [[:editor/input "{{embed (())}}" {:last-pattern (state/get-editor-command-trigger) :backward-pos 4}] [:editor/search-block :embed]]) (defn get-preferred-workflow [] (let [workflow (state/get-preferred-workflow)] (if (= :now workflow) [["LATER" (->marker "LATER")] ["NOW" (->marker "NOW")] ["TODO" (->marker "TODO")] ["DOING" (->marker "DOING")]] [["TODO" (->marker "TODO")] ["DOING" (->marker "DOING")] ["LATER" (->marker "LATER")] ["NOW" (->marker "NOW")]]))) ;; Credits to roamresearch.com (defn- ->heading [heading] [[:editor/clear-current-slash] [:editor/set-heading heading] [:editor/move-cursor-to-end]]) (defn- headings [] (mapv (fn [level] (let [heading (str "h" level)] [heading (->heading level)])) (range 1 7))) (defonce *matched-commands (atom nil)) (defonce *initial-commands (atom nil)) (defonce *first-command-group {"Page reference" "BASIC" "Tomorrow" "TIME & DATE" "LATER" "TASK" "A" "PRIORITY" "Query" "ADVANCED" "Quote" "ORG-MODE"}) (defn ->block ([type] (->block type nil)) ([type optional] (let [format (get (state/get-edit-block) :block/format) markdown-src? (and (= format :markdown) (= (string/lower-case type) "src")) [left right] (cond markdown-src? ["```" "\n```"] :else (->> ["#+BEGIN_%s" "\n#+END_%s"] (map #(util/format % (string/upper-case type))))) template (str left (if optional (str " " optional) "") "\n" right) backward-pos (if (= type "src") (+ 1 (count right)) (count right))] [[:editor/input template {:type "block" :last-pattern angle-bracket :backward-pos backward-pos}]]))) (defn ->properties [] [[:editor/clear-current-bracket] [:editor/insert-properties] [:editor/move-cursor-to-properties]]) ;; https://orgmode.org/manual/Structure-Templates.html (defn block-commands-map [] (->> (concat [["Quote" (->block "quote")] ["Src" (->block "src" "")] ["Query" (->block "query")] ["Latex export" (->block "export" "latex")] ;; FIXME: current page's format (when (= :org (state/get-preferred-format)) ["Properties" (->properties)]) ["Note" (->block "note")] ["Tip" (->block "tip")] ["Important" (->block "important")] ["Caution" (->block "caution")] ["Pinned" (->block "pinned")] ["Warning" (->block "warning")] ["Example" (->block "example")] ["Export" (->block "export")] ["Verse" (->block "verse")] ["Ascii" (->block "export" "ascii")] ["Center" (->block "center")] ["Comment" (->block "comment")]] ;; Allow user to modify or extend, should specify how to extend. (state/get-commands)) (remove nil?) (util/distinct-by-last-wins first))) (defn commands-map [get-page-ref-text] (->> (concat ;; basic [["Page reference" [[:editor/input page-ref/left-and-right-brackets {:backward-pos 2}] [:editor/search-page]] "Create a backlink to a page"] ["Page embed" (embed-page) "Embed a page here"] ["Block reference" [[:editor/input block-ref/left-and-right-parens {:backward-pos 2}] [:editor/search-block :reference]] "Create a backlink to a block"] ["Block embed" (embed-block) "Embed a block here" "Embed a block here"] ["Link" (link-steps) "Create a HTTP link"] ["Image link" (image-link-steps) "Create a HTTP link to a image"] (when (state/markdown?) ["Underline" [[:editor/input "" {:last-pattern (state/get-editor-command-trigger) :backward-pos 6}]] "Create a underline text decoration"]) ["Template" [[:editor/input (state/get-editor-command-trigger) nil] [:editor/search-template]] "Insert a created template here"] (cond (and (util/electron?) (config/local-db? (state/get-current-repo))) ["Upload an asset" [[:editor/click-hidden-file-input :id]] "Upload file types like image, pdf, docx, etc.)"] ;; ["Upload an image" [[:editor/click-hidden-file-input :id]]] )] (headings) ;; time & date [["Tomorrow" #(get-page-ref-text (date/tomorrow)) "Insert the date of tomorrow"] ["Yesterday" #(get-page-ref-text (date/yesterday)) "Insert the date of yesterday"] ["Today" #(get-page-ref-text (date/today)) "Insert the date of today"] ["Current time" #(date/get-current-time) "Insert current time"] ["Date picker" [[:editor/show-date-picker]] "Pick a date and insert here"]] ;; task management (get-preferred-workflow) [["DONE" (->marker "DONE")] ["WAITING" (->marker "WAITING")] ["CANCELED" (->marker "CANCELED")] ["Deadline" [[:editor/clear-current-slash] [:editor/show-date-picker :deadline]]] ["Scheduled" [[:editor/clear-current-slash] [:editor/show-date-picker :scheduled]]]] ;; priority [["A" (->priority "A")] ["B" (->priority "B")] ["C" (->priority "C")]] ;; advanced [["Query" [[:editor/input "{{query }}" {:backward-pos 2}]] query-doc] ["Zotero" (zotero-steps) "Import Zotero journal article"] ["Query table function" [[:editor/input "{{function }}" {:backward-pos 2}]] "Create a query table function"] ["Calculator" [[:editor/input "```calc\n\n```" {:backward-pos 4}] [:codemirror/focus]] "Insert a calculator"] ["Draw" (fn [] (let [file (draw/file-name) path (str gp-config/default-draw-directory "/" file) text (page-ref/->page-ref path)] (p/let [_ (draw/create-draw-with-default-content path)] (println "draw file created, " path)) text)) "Draw a graph with Excalidraw"] ["Embed HTML " (->inline "html")] ["Embed Video URL" [[:editor/input "{{video }}" {:last-pattern (state/get-editor-command-trigger) :backward-pos 2}]]] ["Embed Youtube timestamp" [[:youtube/insert-timestamp]]] ["Embed Twitter tweet" [[:editor/input "{{tweet }}" {:last-pattern (state/get-editor-command-trigger) :backward-pos 2}]]]] @*extend-slash-commands ;; Allow user to modify or extend, should specify how to extend. (state/get-commands) (state/get-plugins-commands)) (remove nil?) (util/distinct-by-last-wins first))) (defn init-commands! [get-page-ref-text] (let [commands (commands-map get-page-ref-text)] (reset! *initial-commands commands) (reset! *matched-commands commands))) (defonce *matched-block-commands (atom (block-commands-map))) (defn reinit-matched-commands! [] (reset! *matched-commands @*initial-commands)) (defn reinit-matched-block-commands! [] (reset! *matched-block-commands (block-commands-map))) (defn restore-state [] (state/clear-editor-action!) (reinit-matched-commands!) (reinit-matched-block-commands!)) (defn insert! [id value {:keys [last-pattern postfix-fn backward-pos forward-pos end-pattern backward-truncate-number] :as _option}] (when-let [input (gdom/getElement id)] (let [last-pattern (when-not backward-truncate-number (or last-pattern (state/get-editor-command-trigger))) edit-content (gobj/get input "value") current-pos (cursor/pos input) current-pos (or (when (and end-pattern (string? end-pattern)) (when-let [i (string/index-of (gp-util/safe-subs edit-content current-pos) end-pattern)] (+ current-pos i))) current-pos) orig-prefix (subs edit-content 0 current-pos) space? (let [space? (when (and last-pattern orig-prefix) (let [s (when-let [last-index (string/last-index-of orig-prefix last-pattern)] (gp-util/safe-subs orig-prefix 0 last-index))] (not (or (and s (string/ends-with? s "(") (or (string/starts-with? last-pattern block-ref/left-parens) (string/starts-with? last-pattern page-ref/left-brackets))) (and s (string/starts-with? s "{{embed")) (and s (= (last s) \#) (string/starts-with? last-pattern "[[")) (and last-pattern (or (string/ends-with? last-pattern gp-property/colons) (string/starts-with? last-pattern gp-property/colons)))))))] (if (and space? (string/starts-with? last-pattern "#[[")) false space?)) prefix (cond (and backward-truncate-number (integer? backward-truncate-number)) (str (gp-util/safe-subs orig-prefix 0 (- (count orig-prefix) backward-truncate-number)) (when-not (zero? backward-truncate-number) value)) (string/blank? last-pattern) (if space? (util/concat-without-spaces orig-prefix value) (str orig-prefix value)) :else (util/replace-last last-pattern orig-prefix value space?)) postfix (subs edit-content current-pos) postfix (if postfix-fn (postfix-fn postfix) postfix) new-value (cond (string/blank? postfix) prefix space? (util/concat-without-spaces prefix postfix) :else (str prefix postfix)) new-pos (- (count prefix) (or backward-pos 0))] (when-not (string/blank? new-value) (state/set-block-content-and-last-pos! id new-value new-pos) (cursor/move-cursor-to input (if (and (or backward-pos forward-pos) (not= end-pattern page-ref/right-brackets)) new-pos (inc new-pos))))))) (defn simple-insert! [id value {:keys [backward-pos forward-pos check-fn] :as _option}] (let [input (gdom/getElement id) edit-content (gobj/get input "value") current-pos (cursor/pos input) prefix (subs edit-content 0 current-pos) new-value (str prefix value (subs edit-content current-pos)) new-pos (- (+ (count prefix) (count value) (or forward-pos 0)) (or backward-pos 0))] (state/set-block-content-and-last-pos! id new-value new-pos) (cursor/move-cursor-to input new-pos) (when check-fn (check-fn new-value (dec (count prefix)) new-pos)))) (defn simple-replace! [id value selected {:keys [backward-pos forward-pos check-fn] :as _option}] (let [selected? (not (string/blank? selected)) input (gdom/getElement id) edit-content (gobj/get input "value")] (when edit-content (let [current-pos (cursor/pos input) prefix (subs edit-content 0 current-pos) postfix (if selected? (string/replace-first (subs edit-content current-pos) selected "") (subs edit-content current-pos)) new-value (str prefix value postfix) new-pos (- (+ (count prefix) (count value) (or forward-pos 0)) (or backward-pos 0))] (state/set-block-content-and-last-pos! id new-value new-pos) (cursor/move-cursor-to input new-pos) (when selected? (.setSelectionRange input new-pos (+ new-pos (count selected)))) (when check-fn (check-fn new-value (dec (count prefix)))))))) (defn delete-pair! [id] (let [input (gdom/getElement id) edit-content (gobj/get input "value") current-pos (cursor/pos input) prefix (subs edit-content 0 (dec current-pos)) new-value (str prefix (subs edit-content (inc current-pos))) new-pos (count prefix)] (state/set-block-content-and-last-pos! id new-value new-pos) (cursor/move-cursor-to input new-pos))) (defn delete-selection! [id] (let [input (gdom/getElement id) edit-content (gobj/get input "value") start (util/get-selection-start input) end (util/get-selection-end input)] (when-not (= start end) (let [prefix (subs edit-content 0 start) new-value (str prefix (subs edit-content end)) new-pos (count prefix)] (state/set-block-content-and-last-pos! id new-value new-pos) (cursor/move-cursor-to input new-pos))))) (defn get-matched-commands ([text] (get-matched-commands text @*initial-commands)) ([text commands] (search/fuzzy-search commands text :extract-fn first :limit 50))) (defmulti handle-step first) (defmethod handle-step :editor/hook [[_ event {:keys [pid uuid] :as payload}] format] (plugin-handler/hook-plugin-editor event (merge payload {:format format :uuid (or uuid (:block/uuid (state/get-edit-block)))}) pid)) (defmethod handle-step :editor/input [[_ value option]] (when-let [input-id (state/get-edit-input-id)] (let [type (:type option) input (gdom/getElement input-id) beginning-of-line? (or (cursor/beginning-of-line? input) (= 1 (:pos (:pos (state/get-editor-action-data))))) value (if (and (contains? #{"block" "properties"} type) (not beginning-of-line?)) (str "\n" value) value)] (insert! input-id value option) (state/clear-editor-action!)))) (defmethod handle-step :editor/cursor-back [[_ n]] (when-let [input-id (state/get-edit-input-id)] (when-let [current-input (gdom/getElement input-id)] (cursor/move-cursor-backward current-input n)))) (defmethod handle-step :editor/cursor-forward [[_ n]] (when-let [input-id (state/get-edit-input-id)] (when-let [current-input (gdom/getElement input-id)] (cursor/move-cursor-forward current-input n)))) (defmethod handle-step :editor/move-cursor-to-end [[_]] (when-let [input-id (state/get-edit-input-id)] (when-let [current-input (gdom/getElement input-id)] (cursor/move-cursor-to-end current-input)))) (defmethod handle-step :editor/restore-saved-cursor [[_]] (when-let [input-id (state/get-edit-input-id)] (when-let [current-input (gdom/getElement input-id)] (cursor/move-cursor-to current-input (state/get-editor-last-pos))))) (defmethod handle-step :editor/clear-current-slash [[_ space?]] (when-let [input-id (state/get-edit-input-id)] (when-let [current-input (gdom/getElement input-id)] (let [edit-content (gobj/get current-input "value") current-pos (cursor/pos current-input) prefix (subs edit-content 0 current-pos) prefix (util/replace-last (state/get-editor-command-trigger) prefix "" (boolean space?)) new-value (str prefix (subs edit-content current-pos))] (state/set-block-content-and-last-pos! input-id new-value (count prefix)))))) (defmethod handle-step :editor/clear-current-bracket [[_ space?]] (when-let [input-id (state/get-edit-input-id)] (when-let [current-input (gdom/getElement input-id)] (let [edit-content (gobj/get current-input "value") current-pos (cursor/pos current-input) prefix (subs edit-content 0 current-pos) prefix (util/replace-last angle-bracket prefix "" (boolean space?)) new-value (str prefix (subs edit-content current-pos))] (state/set-block-content-and-last-pos! input-id new-value (count prefix)))))) (defn compute-pos-delta-when-change-marker [edit-content marker pos] (let [old-marker (some->> (first (util/safe-re-find marker/bare-marker-pattern edit-content)) (string/trim)) pos-delta (- (count marker) (count old-marker)) pos-delta (cond (string/blank? old-marker) (inc pos-delta) (string/blank? marker) (dec pos-delta) :else pos-delta)] (max (+ pos pos-delta) 0))) (defmethod handle-step :editor/set-marker [[_ marker] format] (when-let [input-id (state/get-edit-input-id)] (when-let [current-input (gdom/getElement input-id)] (let [edit-content (gobj/get current-input "value") slash-pos (:pos (:pos (state/get-editor-action-data))) [re-pattern new-line-re-pattern] (if (= :org format) [#"\*+\s" #"\n\*+\s"] [#"#+\s" #"\n#+\s"]) pos (let [prefix (subs edit-content 0 (dec slash-pos))] (if-let [matches (seq (util/re-pos new-line-re-pattern prefix))] (let [[start-pos content] (last matches)] (+ start-pos (count content))) (count (util/safe-re-find re-pattern prefix)))) new-value (str (subs edit-content 0 pos) (string/replace-first (subs edit-content pos) (marker/marker-pattern format) (str marker " ")))] (state/set-edit-content! input-id new-value) (let [new-pos (compute-pos-delta-when-change-marker edit-content marker (dec slash-pos))] ;; TODO: any performance issue? (js/setTimeout #(cursor/move-cursor-to current-input new-pos) 10)))))) (defmethod handle-step :editor/set-priority [[_ priority] _format] (when-let [input-id (state/get-edit-input-id)] (when-let [current-input (gdom/getElement input-id)] (let [format (or (db/get-page-format (state/get-current-page)) (state/get-preferred-format)) edit-content (gobj/get current-input "value") new-priority (util/format "[#%s]" priority) new-value (string/trim (priority/add-or-update-priority edit-content format new-priority))] (state/set-edit-content! input-id new-value))))) (defmethod handle-step :editor/insert-properties [[_ _] _format] (when-let [input-id (state/get-edit-input-id)] (when-let [current-input (gdom/getElement input-id)] (let [format (or (db/get-page-format (state/get-current-page)) (state/get-preferred-format)) edit-content (gobj/get current-input "value") new-value (property/insert-property format edit-content "" "")] (state/set-edit-content! input-id new-value))))) (defmethod handle-step :editor/move-cursor-to-properties [[_]] (when-let [input-id (state/get-edit-input-id)] (when-let [current-input (gdom/getElement input-id)] (let [format (or (db/get-page-format (state/get-current-page)) (state/get-preferred-format))] (property/goto-properties-end format current-input) (cursor/move-cursor-backward current-input 3))))) (defonce markdown-heading-pattern #"^#+\s+") (defn set-markdown-heading [content heading] (let [heading-str (apply str (repeat heading "#"))] (if (util/safe-re-find markdown-heading-pattern content) (string/replace-first content markdown-heading-pattern (str heading-str " ")) (str heading-str " " (string/triml content))))) (defn clear-markdown-heading [content] [:pre (string? content)] (string/replace-first content markdown-heading-pattern "")) (defmethod handle-step :editor/set-heading [[_ heading]] (when-let [input-id (state/get-edit-input-id)] (when-let [current-input (gdom/getElement input-id)] (let [current-block (state/get-edit-block) format (:block/format current-block)] (if (= format :markdown) (let [edit-content (gobj/get current-input "value") new-content (set-markdown-heading edit-content heading)] (state/set-edit-content! input-id new-content)) (state/pub-event! [:editor/set-org-mode-heading current-block heading])))))) (defmethod handle-step :editor/search-page [[_]] (state/set-editor-action! :page-search)) (defmethod handle-step :editor/search-page-hashtag [[_]] (state/set-editor-action! :page-search-hashtag)) (defmethod handle-step :editor/search-block [[_ _type]] (state/set-editor-action! :block-search)) (defmethod handle-step :editor/search-template [[_]] (state/set-editor-action! :template-search)) (defmethod handle-step :editor/show-input [[_ option]] (state/set-editor-show-input! option)) (defmethod handle-step :editor/show-zotero [[_]] (state/set-editor-action! :zotero)) (defn insert-youtube-timestamp [] (let [input-id (state/get-edit-input-id) macro (youtube/gen-youtube-ts-macro)] (when-let [input (gdom/getElement input-id)] (when macro (util/insert-at-current-position! input (str macro " ")))))) (defmethod handle-step :youtube/insert-timestamp [[_]] (let [input-id (state/get-edit-input-id) macro (youtube/gen-youtube-ts-macro)] (insert! input-id macro {}))) (defmethod handle-step :editor/show-date-picker [[_ type]] (if (and (contains? #{:scheduled :deadline} type) (when-let [value (gobj/get (state/get-input) "value")] (string/blank? value))) (do (notification/show! [:div "Please add some content first."] :warning) (restore-state)) (state/set-editor-action! :datepicker))) (defmethod handle-step :editor/click-hidden-file-input [[_ _input-id]] (when-let [input-file (gdom/getElement "upload-file")] (.click input-file))) (defmethod handle-step :default [[type & _args]] (prn "No handler for step: " type)) (defn handle-steps [vector format] (doseq [step vector] (handle-step step format))) (defn exec-plugin-simple-command! [pid {:keys [block-id] :as cmd} action] (let [format (and block-id (:block/format (db-util/pull [:block/uuid block-id]))) inputs (vector (conj action (assoc cmd :pid pid)))] (handle-steps inputs format)))