Skip to content

Commit

Permalink
feat(editor): add auto-completion for config editing
Browse files Browse the repository at this point in the history
  • Loading branch information
andelf committed Feb 12, 2023
1 parent 1cbb587 commit 5b1aa73
Show file tree
Hide file tree
Showing 4 changed files with 265 additions and 19 deletions.
36 changes: 36 additions & 0 deletions 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;
}
228 changes: 218 additions & 10 deletions 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"]
Expand Down Expand Up @@ -136,17 +137,218 @@
[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 #(<= (.-start %) 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))
list (->> (keys @result)
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)
Expand All @@ -163,7 +365,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)
Expand All @@ -181,6 +383,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? (= "edn" mode)
textarea (gdom/getElement id)
default-cm-options {:theme (str "solarized " theme)
:autoCloseBrackets true
Expand All @@ -191,16 +394,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)))]
Expand Down
18 changes: 9 additions & 9 deletions src/main/frontend/schema/handler/common_config.cljc
Expand Up @@ -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]
Expand Down Expand Up @@ -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]]
Expand Down
2 changes: 2 additions & 0 deletions tailwind.all.css
Expand Up @@ -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";
Expand Down

0 comments on commit 5b1aa73

Please sign in to comment.