From 71b5fa3b974451e05eaa379f89fbc2945a052af1 Mon Sep 17 00:00:00 2001 From: Andelf Date: Wed, 15 Feb 2023 17:56:33 +0800 Subject: [PATCH] Feat: auto-completion for config editing (#8584) * feat(editor): add auto-completion for config editing * enhance: filter keys already in file when autocompletion --- resources/css/show-hint.css | 36 +++ src/main/frontend/extensions/code.cljs | 230 +++++++++++++++++- .../schema/handler/common_config.cljc | 18 +- tailwind.all.css | 2 + 4 files changed, 267 insertions(+), 19 deletions(-) create mode 100644 resources/css/show-hint.css diff --git a/resources/css/show-hint.css b/resources/css/show-hint.css new file mode 100644 index 00000000000..5617ccca2bd --- /dev/null +++ b/resources/css/show-hint.css @@ -0,0 +1,36 @@ +.CodeMirror-hints { + position: absolute; + z-index: 10; + overflow: hidden; + list-style: none; + + margin: 0; + padding: 2px; + + -webkit-box-shadow: 2px 3px 5px rgba(0,0,0,.2); + -moz-box-shadow: 2px 3px 5px rgba(0,0,0,.2); + box-shadow: 2px 3px 5px rgba(0,0,0,.2); + border-radius: 3px; + border: 1px solid silver; + + background: white; + font-size: 90%; + font-family: monospace; + + max-height: 20em; + overflow-y: auto; +} + +.CodeMirror-hint { + margin: 0; + padding: 0 4px; + border-radius: 2px; + white-space: pre; + color: black; + cursor: pointer; +} + +li.CodeMirror-hint-active { + background: #08f; + color: white; +} diff --git a/src/main/frontend/extensions/code.cljs b/src/main/frontend/extensions/code.cljs index 884f3cd3e96..dcf0c69d24f 100644 --- a/src/main/frontend/extensions/code.cljs +++ b/src/main/frontend/extensions/code.cljs @@ -1,9 +1,10 @@ (ns frontend.extensions.code (:require [clojure.string :as string] - ["codemirror" :as cm] + ["codemirror" :as CodeMirror] ["codemirror/addon/edit/closebrackets"] ["codemirror/addon/edit/matchbrackets"] ["codemirror/addon/selection/active-line"] + ["codemirror/addon/hint/show-hint"] ["codemirror/mode/apl/apl"] ["codemirror/mode/asciiarmor/asciiarmor"] ["codemirror/mode/asn.1/asn.1"] @@ -136,17 +137,220 @@ [frontend.config :as config] [goog.dom :as gdom] [goog.object :as gobj] + [frontend.schema.handler.common-config :refer [Config-edn]] + [malli.util :as mu] + [malli.core :as m] [rum.core :as rum])) ;; codemirror -(def from-textarea (gobj/get cm "fromTextArea")) +(def from-textarea (gobj/get CodeMirror "fromTextArea")) +(def Pos (gobj/get CodeMirror "Pos")) (def textarea-ref-name "textarea") (def codemirror-ref-name "codemirror-instance") ;; export CodeMirror to global scope -(set! js/window -CodeMirror cm) +(set! js/window -CodeMirror CodeMirror) + + +(defn- all-tokens-by-cursur + "All tokens from the beginning of the document to the cursur(inclusive)." + [cm] + (let [cur (.getCursor cm) + line (.-line cur) + pos (.-ch cur)] + (concat (mapcat #(.getLineTokens cm %) (range line)) + (filter #(<= (.-end %) pos) (.getLineTokens cm line))))) + + +(defn- tokens->doc-state + "Parse tokens into document state of the last token." + [tokens] + (let [init-state {:current-config-path [] + :state-stack (list :ok)}] + (loop [state init-state + tokens tokens] + (if (empty? tokens) + state + (let [token (first tokens) + token-type (.-type token) + token-string (.-string token) + current-state (first (:state-stack state)) + next-state (cond + (or (nil? token-type) + (= token-type "comment") + (= token-type "meta") ;; TODO: handle meta prefix + (= current-state :error)) + state + + (= token-type "bracket") + (cond + ;; ignore map if it is inside a list or vector (query or function) + (and (= "{" token-string) + (some #(contains? #{:list :vector} %) + (:state-stack state))) + (assoc state :state-stack (conj (:state-stack state) :ignore-map)) + (= "{" token-string) + (assoc state :state-stack (conj (:state-stack state) :map)) + (= "(" token-string) + (assoc state :state-stack (conj (:state-stack state) :list)) + (= "[" token-string) + (assoc state :state-stack (conj (:state-stack state) :vector)) + + (and (= :ignore-map current-state) + (contains? #{"}" ")" "]"} token-string)) + (assoc state :state-stack (pop (:state-stack state))) + + (or (and (= "}" token-string) (= :map current-state)) + (and (= ")" token-string) (= :list current-state)) + (and (= "]" token-string) (= :vector current-state))) + (let [new-state-stack (pop (:state-stack state))] + (if (= (first new-state-stack) :key) + (assoc state + :state-stack (pop new-state-stack) + :current-config-path (pop (:current-config-path state))) + (assoc state :state-stack (pop (:state-stack state))))) + + :else + (assoc state :state-stack (conj (:state-stack state) :error))) + + (and (= current-state :map) (= token-type "atom")) + (assoc state + :state-stack (conj (:state-stack state) :key) + :current-config-path (conj (:current-config-path state) token-string)) + + (= current-state :key) + (assoc state + :state-stack (pop (:state-stack state)) + :current-config-path (pop (:current-config-path state))) + + (or (= current-state :list) (= current-state :vector) (= current-state :ignore-map)) + state + + :else + (assoc state :state-stack (conj (:state-stack state) :error)))] + (recur next-state (rest tokens))))))) + +(defn- doc-state-at-cursor + "Parse tokens into document state of last token." + [cm] + (let [tokens (all-tokens-by-cursur cm) + {:keys [current-config-path state-stack]} (tokens->doc-state tokens) + doc-state (first state-stack)] + [current-config-path doc-state])) + +(defn- malli-type->completion-postfix + [type] + (case type + :string "\"\"" + :map-of "{}" + :map "{}" + :set "#{}" + :vector "[]" + nil)) + +(.registerHelper CodeMirror "hint" "clojure" + (fn [cm _options] + (let [cur (.getCursor cm) + token (.getTokenAt cm cur) + token-type (.-type token) + token-string (.-string token) + result (atom {}) + [config-path doc-state] (doc-state-at-cursor cm)] + (cond + + ;; completion of config keys, triggered by `:` or shortcut + (and (= token-type "atom") + (string/starts-with? token-string ":") + (= doc-state :key)) + (do + (m/walk Config-edn + (fn [schema properties _children _opts] + (let [schema-path (mapv str properties)] + (cond + (empty? schema-path) + nil + + (empty? config-path) + (swap! result assoc (first schema-path) (m/type schema)) + + (= (count config-path) 1) + (when (string/starts-with? (first schema-path) (first config-path)) + (swap! result assoc (first schema-path) (m/type schema))) + + (= (count config-path) 2) + (when (and (= (count schema-path) 2) + (= (first schema-path) (first config-path)) + (string/starts-with? (second schema-path) (second config-path))) + (swap! result assoc (second schema-path) (m/type schema))))) + nil)) + (when (not-empty @result) + (let [from (Pos. (.-line cur) (.-start token)) + ;; `(.-ch cur)` is the cursor position, not the end of token. When completion is at the middle of a token, this is wrong + to (Pos. (.-line cur) (.-end token)) + add-postfix-after? (<= (.-end token) (.-ch cur)) + doc (.getValue cm) + list (->> (keys @result) + (remove (fn [text] + (re-find (re-pattern (str "[^;]*" text "\\s")) doc))) + sort + (map (fn [text] + (let [type (get @result text)] + {:text (str text (when add-postfix-after? + (str " " (malli-type->completion-postfix type)))) + :displayText (str text " " type)})))) + + completion (clj->js {:list list + :from from + :to to})] + completion))) + + ;; completion of :boolean, :enum, :keyword[TODO] + (and (nil? token-type) + (string/blank? (string/trim token-string)) + (not-empty config-path) + (= doc-state :key)) + (do + (m/walk Config-edn + (fn [schema properties _children _opts] + (let [schema-path (mapv str properties)] + (when (= config-path schema-path) + (case (m/type schema) + :boolean + (swap! result assoc + "true" nil + "false" nil) + + :enum + (let [{:keys [children]} (mu/to-map-syntax schema)] + (doseq [child children] + (swap! result assoc (str child) nil))) + + nil)) + nil))) + (when (not-empty @result) + (let [from (Pos. (.-line cur) (.-ch cur)) + to (Pos. (.-line cur) (.-ch cur)) + list (->> (keys @result) + sort + (map (fn [text] + {:text text + :displayText text}))) + completion (clj->js {:list list + :from from + :to to})] + completion))))))) + +(defn- complete-after + [cm pred] + (when (or (not pred) (pred)) + (js/setTimeout + (fn [] + (when (not (.-completionActive (.-state cm))) + (.showHint cm #js {:completeSingle false}))) + 100)) + (.-Pass CodeMirror)) (defn- extra-codemirror-options [] (get (state/get-config) @@ -163,7 +367,7 @@ :ext "findModeByExtension" :file-name "findModeByFileName" "findModeByName") - find-fn (gobj/get cm find-fn-name) + find-fn (gobj/get CodeMirror find-fn-name) cm-mode (find-fn mode)] (if cm-mode (.-mime cm-mode) @@ -181,6 +385,7 @@ (text->cm-mode original-mode :ext) ;; ref: src/main/frontend/components/file.cljs (text->cm-mode original-mode :name)) lisp-like? (contains? #{"scheme" "lisp" "clojure" "edn"} mode) + config-edit? (and (:file? config) (string/ends-with? (:file-path config) "config.edn")) textarea (gdom/getElement id) default-cm-options {:theme (str "solarized " theme) :autoCloseBrackets true @@ -191,16 +396,21 @@ (extra-codemirror-options) {:mode mode :tabIndex -1 ;; do not accept TAB-in, since TAB is bind globally - :extraKeys #js {"Esc" (fn [cm] + :extraKeys (merge {"Esc" (fn [cm] ;; Avoid reentrancy - (gobj/set cm "escPressed" true) - (code-handler/save-code-editor!) - (when-let [block-id (:block/uuid config)] - (let [block (db/pull [:block/uuid block-id])] - (editor-handler/edit-block! block :max block-id))))}} + (gobj/set cm "escPressed" true) + (code-handler/save-code-editor!) + (when-let [block-id (:block/uuid config)] + (let [block (db/pull [:block/uuid block-id])] + (editor-handler/edit-block! block :max block-id))))} + (when config-edit? + {"':'" complete-after + "Ctrl-Space" "autocomplete"}))} (when config/publishing? {:readOnly true :cursorBlinkRate -1}) + (when config-edit? + {:hintOptions {}}) user-options) editor (when textarea (from-textarea textarea (clj->js cm-options)))] diff --git a/src/main/frontend/schema/handler/common_config.cljc b/src/main/frontend/schema/handler/common_config.cljc index 6a3e6349377..4cd4f15a9ce 100644 --- a/src/main/frontend/schema/handler/common_config.cljc +++ b/src/main/frontend/schema/handler/common_config.cljc @@ -10,9 +10,8 @@ [:preferred-format [:or :keyword :string]] [:preferred-workflow [:enum :now :todo]] [:hidden [:vector :string]] - [:default-templates [:map-of - [:enum :journals] - :string]] + [:default-templates [:map + [:journals :string]]] [:journal/page-title-format :string] [:ui/enable-tooltip? :boolean] [:ui/show-brackets? :boolean] @@ -80,12 +79,13 @@ [:editor/extra-codemirror-options :map] [:editor/logical-outdenting? :boolean] [:editor/preferred-pasting-file? :boolean] - [:quick-capture-templates [:map - [:text {:optional true} :string] - [:media {:optional true} :string]]] - [:quick-capture-options [:map - [:insert-today? {:optional true} :boolean] - [:redirect-page? {:optional true} :boolean]]] + [:quick-capture-templates (mu/optional-keys [:map + [:text :string] + [:media :string]])] + [:quick-capture-options (mu/optional-keys [:map + [:insert-today? :boolean] + [:redirect-page? :boolean] + [:default-page :string]])] [:file-sync/ignore-files [:vector :string]] [:dwim/settings [:map-of :keyword :boolean]] [:file/name-format [:enum :legacy :triple-lowbar]] diff --git a/tailwind.all.css b/tailwind.all.css index 06a367f6985..4f1800b3ce5 100644 --- a/tailwind.all.css +++ b/tailwind.all.css @@ -13,6 +13,8 @@ @import "resources/css/katex.min.css"; @import "resources/css/codemirror.min.css"; @import "resources/css/codemirror.solarized.css"; +@import "resources/css/show-hint.css"; + @import "resources/css/animation.css"; @import "resources/css/table.css"; @import "resources/css/datepicker.css";