From d80187013d7b7b96db3d8b114b8d99f687170668 Mon Sep 17 00:00:00 2001 From: Martin Kavalar Date: Mon, 26 Jun 2023 10:56:49 +0200 Subject: [PATCH] First cut of Clerk CLJS Editor component (#527) Adds `nextjournal.clerk.render.editor`, a clojure-mode based editor for Clerk documents in the browser. Uses completions based on the sci environment. Parses and evaluates the doc as a Clerk doc in ClojureScript. https://snapshots.nextjournal.com/clerk/build/465f5351161eb28ad631bee197b34d6e0849bd6f/editor.html --------- Co-authored-by: Philippa Markovics --- .github/workflows/main.yml | 4 +- notebooks/editor.clj | 9 + notebooks/rule_30.clj | 3 +- render/deps.edn | 2 +- src/nextjournal/clerk/analyzer.clj | 5 +- src/nextjournal/clerk/builder.clj | 1 + src/nextjournal/clerk/parser.cljc | 3 + src/nextjournal/clerk/render.cljs | 31 +- src/nextjournal/clerk/render/code.cljs | 53 +++- src/nextjournal/clerk/render/editor.cljs | 277 ++++++++++++++++++ src/nextjournal/clerk/render/panel.cljs | 57 ++-- src/nextjournal/clerk/sci_env.cljs | 3 + .../clerk/sci_env/completions.cljs | 138 +++++++++ src/nextjournal/clerk/viewer.cljc | 21 +- 14 files changed, 539 insertions(+), 68 deletions(-) create mode 100644 notebooks/editor.clj create mode 100644 src/nextjournal/clerk/render/editor.cljs create mode 100644 src/nextjournal/clerk/sci_env/completions.cljs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b835c9eb7..2b59dcd88 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -143,7 +143,9 @@ jobs: uses: google-github-actions/setup-gcloud@v0.3.0 - name: 📓 Build Clerk Book - run: clojure -J-Dclojure.main.report=stderr -X:demo:nextjournal/clerk :git/sha '"${{ github.sha }}"' :git/url '"https://github.com/nextjournal/clerk"' :paths '["book.clj" "CHANGELOG.md"]' + run: | + cp notebooks/editor.clj editor.clj + clojure -J-Dclojure.main.report=stderr -X:demo:nextjournal/clerk :git/sha '"${{ github.sha }}"' :git/url '"https://github.com/nextjournal/clerk"' :paths '["book.clj" "CHANGELOG.md" "editor.clj"]' - name: 🏗 Build Clerk Static App with default Notebooks run: clojure -J-Dclojure.main.report=stderr -X:demo:nextjournal/clerk :git/sha '"${{ github.sha }}"' :git/url '"https://github.com/nextjournal/clerk"' :bundle true diff --git a/notebooks/editor.clj b/notebooks/editor.clj new file mode 100644 index 000000000..618833a46 --- /dev/null +++ b/notebooks/editor.clj @@ -0,0 +1,9 @@ +(ns editor + {:nextjournal.clerk/visibility {:code :hide} + :nextjournal.clerk/doc-css-class [:overflow-hidden :p-0]} + (:require [nextjournal.clerk :as clerk])) + +(clerk/with-viewer + {:render-fn 'nextjournal.clerk.render.editor/view + :transform-fn clerk/mark-presented} + (slurp "notebooks/rule_30.clj")) diff --git a/notebooks/rule_30.clj b/notebooks/rule_30.clj index cd4397e31..50087f5d1 100644 --- a/notebooks/rule_30.clj +++ b/notebooks/rule_30.clj @@ -10,7 +10,8 @@ {:pred (every-pred list? (partial every? (some-fn number? vector?))) :render-fn '#(into [:div.flex.flex-col] (nextjournal.clerk.render/inspect-children %2) %1)} {:pred (every-pred vector? (complement map-entry?) (partial every? number?)) - :render-fn '#(into [:div.flex.inline-flex] (nextjournal.clerk.render/inspect-children %2) %1)}]) + :render-fn '#(into [:div.flex.inline-flex] (nextjournal.clerk.render/inspect-children %2) %1)} + {:pred var? :transform-fn (clerk/update-val deref)}]) (clerk/add-viewers! viewers) diff --git a/render/deps.edn b/render/deps.edn index 0e13a6220..2b8f456b5 100644 --- a/render/deps.edn +++ b/render/deps.edn @@ -5,7 +5,7 @@ org.babashka/sci {:mvn/version "0.7.39"} reagent/reagent {:mvn/version "1.2.0"} io.github.babashka/sci.configs {:git/sha "0702ea5a21ad92e6d7cca6d36de84271083ea68f"} - io.github.nextjournal/clojure-mode {:git/sha "ac038ebf6e5da09dd2b8a31609e9ff4a65e36852"} + io.github.nextjournal/clojure-mode {:git/sha "1f55406087814a0dda6806396aa596dbe13ea302"} thheller/shadow-cljs {:mvn/version "2.23.1"} io.github.squint-cljs/cherry {;; :local/root "/Users/borkdude/dev/cherry" :git/sha "ac89d93f136ee8fab91f62949de5b5822ba08b3c"}}} diff --git a/src/nextjournal/clerk/analyzer.clj b/src/nextjournal/clerk/analyzer.clj index c15a6f30a..f1289a217 100644 --- a/src/nextjournal/clerk/analyzer.clj +++ b/src/nextjournal/clerk/analyzer.clj @@ -310,9 +310,6 @@ (throw (ex-info (str "The var `#'" missing-dep "` is being referenced, but Clerk can't find it in the namespace's source code. Did you remove it? This validation can fail when the namespace is mutated programmatically (e.g. using `clojure.core/intern` or side-effecting macros). You can turn off this check by adding `{:nextjournal.clerk/error-on-missing-vars :off}` to the namespace metadata.") {:var-name missing-dep :form form :file file #_#_:defined defined })))))))) -(defn filter-code-blocks-without-form [doc] - (update doc :blocks #(filterv (some-fn :form (complement parser/code?)) %))) - (defn ns-resolver [notebook-ns] (if notebook-ns (into {} (map (juxt key (comp ns-name val))) (ns-aliases notebook-ns)) @@ -363,7 +360,7 @@ (-> doc :blocks count range)) doc? (-> parser/add-block-settings parser/add-open-graph-metadata - filter-code-blocks-without-form)))))) + parser/filter-code-blocks-without-form)))))) #_(let [parsed (nextjournal.clerk.parser/parse-clojure-string "clojure.core/dec")] (build-graph (analyze-doc parsed))) diff --git a/src/nextjournal/clerk/builder.clj b/src/nextjournal/clerk/builder.clj index 140a75bc8..d3e00fff0 100644 --- a/src/nextjournal/clerk/builder.clj +++ b/src/nextjournal/clerk/builder.clj @@ -28,6 +28,7 @@ "controlling_width" "docs" "document_linking" + "editor" "hello" "how_clerk_works" "exec_status" diff --git a/src/nextjournal/clerk/parser.cljc b/src/nextjournal/clerk/parser.cljc index 9dda845de..3607cc4ab 100644 --- a/src/nextjournal/clerk/parser.cljc +++ b/src/nextjournal/clerk/parser.cljc @@ -372,6 +372,9 @@ #_(runnable-code-block? {:type :code :language "clojure" :info "clojure"}) #_(runnable-code-block? {:type :code :language "clojure" :info "clojure {:nextjournal.clerk/code-listing true}"}) +(defn filter-code-blocks-without-form [doc] + (update doc :blocks #(filterv (some-fn :form (complement code?)) %))) + (defn parse-markdown-string [{:as opts :keys [doc?]} s] (let [{:as ctx :keys [content]} (parse-markdown (markdown-context) s)] (loop [{:as state :keys [nodes] ::keys [md-slice]} {:blocks [] ::md-slice [] :nodes content :md-context ctx}] diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index 137d8f816..4894f8459 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -15,7 +15,6 @@ [nextjournal.clerk.render.code :as code] [nextjournal.clerk.render.context :as view-context] [nextjournal.clerk.render.hooks :as hooks] - [nextjournal.clerk.render.localstorage :as localstorage] [nextjournal.clerk.render.navbar :as navbar] [nextjournal.clerk.render.panel :as panel] [nextjournal.clerk.viewer :as viewer] @@ -34,12 +33,12 @@ (defn reagent-atom? [x] (satisfies? ratom/IReactiveAtom x)) -(defn dark-mode-toggle [!dark-mode?] +(defn dark-mode-toggle [] (let [spring {:type :spring :stiffness 200 :damping 10}] [:div.relative.dark-mode-toggle [:button.text-slate-400.hover:text-slate-600.dark:hover:text-white.cursor-pointer - {:on-click #(swap! !dark-mode? not)} - (if @!dark-mode? + {:on-click #(swap! code/!dark-mode? not)} + (if @code/!dark-mode? [:> (.-svg motion) {:xmlns "http://www.w3.org/2000/svg" :class "w-5 h-5 md:w-4 md:h-4" @@ -79,23 +78,6 @@ [:circle {:cx "20.9101" :cy "6.8555" :r "1.71143" :transform "rotate(-120 20.9101 6.8555)" :fill "currentColor"}] [:circle {:cx "12" :cy "1.71143" :r "1.71143" :fill "currentColor"}]]])]])) -(def local-storage-dark-mode-key "clerk-darkmode") - -(defn set-dark-mode! [dark-mode?] - (let [class-list (.-classList (js/document.querySelector "html"))] - (if dark-mode? - (.add class-list "dark") - (.remove class-list "dark"))) - (localstorage/set-item! local-storage-dark-mode-key dark-mode?)) - -(defn setup-dark-mode! [!dark-mode?] - (add-watch !dark-mode? ::dark-mode-watch - (fn [_ _ old dark-mode?] - (when (not= old dark-mode?) - (set-dark-mode! dark-mode?)))) - (when @!dark-mode? - (set-dark-mode! @!dark-mode?))) - (defonce !eval-counter (r/atom 0)) (defn exec-status [{:keys [progress cell-progress status]}] @@ -149,10 +131,9 @@ (defn render-notebook [{:as doc xs :blocks :keys [bundle? doc-css-class sidenotes? toc toc-visibility header footer]} {:as render-opts :keys [!expanded-at expandable-toc?]}] - (r/with-let [!dark-mode? (r/atom (localstorage/get-item local-storage-dark-mode-key)) - root-ref-fn (fn [el] + (r/with-let [root-ref-fn (fn [el] (when (and el (exists? js/document)) - (setup-dark-mode! !dark-mode?) + (code/setup-dark-mode!) (when-some [heading (when (and (exists? js/location) (not bundle?)) (try (some-> js/location .-hash not-empty js/decodeURI (subs 1) js/document.getElementById) (catch js/Error _ @@ -163,7 +144,7 @@ [:div.flex {:ref root-ref-fn} [:div.fixed.top-2.left-2.md:left-auto.md:right-2.z-10 - [dark-mode-toggle !dark-mode?]] + [dark-mode-toggle]] (when (and toc toc-visibility) [navbar/view toc (assoc render-opts :set-hash? (not bundle?) :toc-visibility toc-visibility)]) [:div.flex-auto.w-screen.scroll-container diff --git a/src/nextjournal/clerk/render/code.cljs b/src/nextjournal/clerk/render/code.cljs index 097e0bfe1..d0757d24e 100644 --- a/src/nextjournal/clerk/render/code.cljs +++ b/src/nextjournal/clerk/render/code.cljs @@ -1,15 +1,37 @@ (ns nextjournal.clerk.render.code (:require ["@codemirror/language" :refer [HighlightStyle syntaxHighlighting LanguageDescription]] - ["@codemirror/state" :refer [EditorState RangeSet RangeSetBuilder Text]] + ["@codemirror/state" :refer [Compartment EditorState RangeSet RangeSetBuilder Text]] ["@codemirror/view" :refer [EditorView Decoration]] ["@lezer/highlight" :refer [tags highlightTree]] ["@nextjournal/lang-clojure" :refer [clojureLanguage]] [applied-science.js-interop :as j] [clojure.string :as str] [nextjournal.clerk.render.hooks :as hooks] + [nextjournal.clerk.render.localstorage :as localstorage] [nextjournal.clojure-mode :as clojure-mode] + [reagent.core :as r] [shadow.esm])) +(def local-storage-dark-mode-key "clerk-darkmode") + +(def !dark-mode? + (r/atom (boolean (localstorage/get-item local-storage-dark-mode-key)))) + +(defn set-dark-mode! [dark-mode?] + (let [class-list (.-classList (js/document.querySelector "html"))] + (if dark-mode? + (.add class-list "dark") + (.remove class-list "dark"))) + (localstorage/set-item! local-storage-dark-mode-key dark-mode?)) + +(defn setup-dark-mode! [] + (add-watch !dark-mode? ::dark-mode-watch + (fn [_ _ old dark-mode?] + (when (not= old dark-mode?) + (set-dark-mode! dark-mode?)))) + (when @!dark-mode? + (set-dark-mode! @!dark-mode?))) + (def highlight-style (.define HighlightStyle (clj->js [{:tag (.-meta tags) :class "cmt-meta"} @@ -131,7 +153,7 @@ [highlight-imported-language {:code code :language language}])]]) ;; editable code viewer -(def theme +(defn get-theme [] (.theme EditorView (j/lit {"&.cm-focused" {:outline "none"} ".cm-line" {:padding "0" @@ -151,7 +173,22 @@ :overflow "hidden"} ".cm-tooltip > ul > li" {:padding "3px 10px 3px 0 !important"} ".cm-tooltip > ul > li:first-child" {:border-top-left-radius "3px" - :border-top-right-radius "3px"}}))) + :border-top-right-radius "3px"} + ".cm-tooltip.cm-tooltip-autocomplete" {:border "0" + :border-radius "6px" + :box-shadow "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)" + "& > ul" {:font-size "12px" + :font-family "'Fira Code', monospace" + :background "rgb(241 245 249)" + :border "1px solid rgb(203 213 225)" + :border-radius "6px"}} + ".cm-tooltip-autocomplete ul li[aria-selected]" {:background "rgb(79 70 229)" + :color "#fff"} + ".cm-tooltip.cm-tooltip-hover" {:background "rgb(241 245 249)" + :border-radius "6px" + :border "1px solid rgb(203 213 225)" + :box-shadow "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)" + :max-width "550px"}}) #js {:dark @!dark-mode?})) (def read-only (.. EditorView -editable (of false))) @@ -161,10 +198,17 @@ (when (.-docChanged tr) (f (.. tr -state sliceDoc))) #js {})))) +(def theme (Compartment.)) + +(defn use-dark-mode [!view] + (hooks/use-effect (fn [] + (add-watch !dark-mode? ::dark-mode #(.dispatch @!view #js {:effects (.reconfigure theme (get-theme))})) + #(remove-watch !dark-mode? ::dark-mode)))) + (def ^:export default-extensions #js [clojure-mode/default-extensions (syntaxHighlighting highlight-style) - theme]) + (.of theme (get-theme))]) (defn make-state [doc extensions] (.create EditorState (j/obj :doc doc :extensions extensions))) @@ -196,4 +240,5 @@ (j/lit {:changes [{:insert @!code-str :from 0 :to (.. state -doc -length)}]})))))) [@!code-str]) + (use-dark-mode !view) [:div {:ref !container-el}]))) diff --git a/src/nextjournal/clerk/render/editor.cljs b/src/nextjournal/clerk/render/editor.cljs new file mode 100644 index 000000000..bfbcdc1d6 --- /dev/null +++ b/src/nextjournal/clerk/render/editor.cljs @@ -0,0 +1,277 @@ +(ns nextjournal.clerk.render.editor + (:require ["@codemirror/autocomplete" :refer [autocompletion]] + ["@codemirror/language" :refer [syntaxTree]] + ["@codemirror/state" :refer [EditorState]] + ["@codemirror/view" :refer [keymap placeholder]] + [applied-science.js-interop :as j] + [clojure.string :as str] + [nextjournal.clerk.parser :as parser] + [nextjournal.clerk.render :as render] + [nextjournal.clerk.render.code :as code] + [nextjournal.clerk.render.hooks :as hooks] + [nextjournal.clerk.render.panel :as panel] + [nextjournal.clerk.sci-env.completions :as sci-completions] + [nextjournal.clerk.viewer :as v] + [nextjournal.clojure-mode.extensions.eval-region :as eval-region] + [nextjournal.clojure-mode.keymap :as clojure-mode.keymap] + [rewrite-clj.node :as n] + [rewrite-clj.parser :as p] + [sci.core :as sci] + [sci.ctx-store] + [shadow.esm])) + +(defn eval-string + ([source] (sci/eval-string* (sci.ctx-store/get-ctx) source)) + ([ctx source] + (when-some [code (not-empty (str/trim source))] + (try {:result (sci/eval-string* ctx code)} + (catch js/Error e + {:error (str (.-message e))}))))) + +(j/defn eval-at-cursor [on-result ^:js {:keys [state]}] + (some->> (eval-region/cursor-node-string state) + (eval-string) + (on-result)) + true) + +(j/defn eval-top-level [on-result ^:js {:keys [state]}] + (some->> (eval-region/top-level-string state) + (eval-string) + (on-result)) + true) + +(j/defn eval-cell [on-result ^:js {:keys [state]}] + (-> (.-doc state) + (str) + (eval-string) + (on-result)) + true) + +(defn autocomplete [^js context] + (let [node-before (.. (syntaxTree (.-state context)) (resolveInner (.-pos context) -1)) + text-before (.. context -state (sliceDoc (.-from node-before) (.-pos context)))] + #js {:from (.-from node-before) + :options (clj->js (map + (fn [{:as option :keys [candidate info]}] + (let [{:keys [arglists arglists-str]} info] + (cond-> {:label candidate :type (if arglists "function" "namespace")} + arglists (assoc :detail arglists-str)))) + (:completions (sci-completions/completions {:ctx (sci.ctx-store/get-ctx) :ns "user" :symbol text-before}))))})) + +(def completion-source + (autocompletion #js {:override #js [autocomplete]})) + +(defn info-at-point [^js view pos] + (let [node-before (.. (syntaxTree (.-state view)) (resolveInner pos -1)) + text-at-point (.. view -state (sliceDoc (.-from node-before) (.-to node-before)))] + (some->> (sci-completions/completions {:ctx (sci.ctx-store/get-ctx) :ns "user" :symbol text-at-point}) + :completions + (filter #(= (:candidate %) text-at-point)) + first + :info))) + +(defn get-block-id [!id->count {:as block :keys [var form type doc]}] + (let [id->count @!id->count + id (if var + var + (let [hash-fn hash] + (symbol (str *ns*) + (case type + :code (str "anon-expr-" (hash-fn form)) + :markdown (str "markdown-" (hash-fn doc))))))] + (swap! !id->count update id (fnil inc 0)) + (if (id->count id) + (symbol (str *ns*) (str (name id) "#" (inc (id->count id)))) + id))) + + +(defn analyze [form] + (cond-> {:form form} + (and (seq? form) + (str/starts-with? (str (first form)) "def")) + (assoc :var (second form)))) + +(defn ns-resolver [notebook-ns] + (into {} (map (juxt key (comp ns-name val))) '{clerk nextjournal.clerk})) + +(defn parse-ns-aliases [ns-form] + (some (fn [x] + (when (and (seq? x) + (= :require (first x))) + (into {} + (keep (fn [require-form] + (when (and (vector? require-form) + (= 3 (count require-form)) + (contains? #{:as :as-alias} (second require-form))) + ((juxt peek first) require-form)))) + (rest x)))) + ns-form)) + +;; TODO: unify with `analyzer/analyze-doc` and move to parser +(defn analyze-doc + ([doc] + (analyze-doc {:doc? true} doc)) + ([{:as state :keys [doc?]} doc] + (binding [*ns* *ns*] + (let [!id->count (atom {})] + (cond-> (reduce (fn [{:as state notebook-ns :ns :keys [ns-aliases]} i] + (let [{:as block :keys [type text]} (get-in doc [:blocks i])] + (if (not= type :code) + (assoc-in state [:blocks i :id] (get-block-id !id->count block)) + (let [node (p/parse-string text) + form (try (n/sexpr node (when ns-aliases {:auto-resolve ns-aliases})) + (catch js/Error e + (throw (ex-info (str "Clerk analysis failed reading block: " + (ex-message e)) + {:block block + :file (:file doc)} + e)))) + analyzed (cond-> (analyze form) + (:file doc) (assoc :file (:file doc))) + block-id (get-block-id !id->count (merge analyzed block)) + analyzed (assoc analyzed :id block-id)] + (cond-> state + (and (not ns-aliases) (parser/ns? form)) (assoc :ns-aliases (parse-ns-aliases form)) + doc? (update-in [:blocks i] merge analyzed) + doc? (assoc-in [:blocks i :text-without-meta] + (parser/text-with-clerk-metadata-removed text (ns-resolver notebook-ns))) + (and doc? (not (contains? state :ns))) (merge (parser/->doc-settings form) {:ns *ns*})))))) + (cond-> state + doc? (merge doc)) + (-> doc :blocks count range)) + doc? (-> parser/add-block-settings + parser/add-open-graph-metadata + parser/filter-code-blocks-without-form)))))) + +(defn eval-blocks [doc] + (update doc :blocks (partial map (fn [{:as cell :keys [type text var form]}] + (cond-> cell + (= :code type) + (assoc :result + {:nextjournal/value (cond->> (eval form) + var (hash-map :nextjournal.clerk/var-from-def))})))))) + +(defn eval-notebook [code] + (->> code + (parser/parse-clojure-string {:doc? true}) + (analyze-doc) + (eval-blocks) + (v/with-viewer v/notebook-viewer))) + +(defonce bar-height 26) + +(defn view [code-string] + (let [!notebook (hooks/use-state nil) + !eval-result (hooks/use-state nil) + !container-el (hooks/use-ref nil) + !info (hooks/use-state nil) + !show-docstring? (hooks/use-state false) + !view (hooks/use-ref nil) + !editor-panel (hooks/use-ref nil) + !notebook-panel (hooks/use-ref nil) + on-result #(reset! !eval-result %) + on-eval #(reset! !notebook (try + (eval-notebook (.. % -state -doc toString)) + (catch js/Error error (v/html [render/error-view error]))))] + (hooks/use-effect + (fn [] + (let [^js view + (reset! !view (code/make-view + (code/make-state code-string + (.concat code/default-extensions + #js [(placeholder "Show code with Option+Return") + (.of keymap clojure-mode.keymap/paredit) + completion-source + (.. EditorState -transactionExtender + (of (fn [^js tr] + (when (.-selection tr) + (reset! !eval-result nil) + (reset! !show-docstring? false) + (reset! !info (some-> (info-at-point @!view (.-to (first (.. tr -selection asSingle -ranges))))))) + #js {}))) + (eval-region/extension {:modifier "Meta"}) + (.of keymap + (j/lit + [{:key "Alt-Enter" + :run on-eval} + {:key "Mod-Enter" + :shift (partial eval-top-level on-result) + :run (partial eval-at-cursor on-result)} + {:key "Mod-i" + :preventDefault true + :run #(swap! !show-docstring? not)} + {:key "Escape" + :run #(reset! !show-docstring? false)}]))])) + @!container-el))] + (on-eval view) + #(.destroy view)))) + (code/use-dark-mode !view) + [:<> + [:style {:type "text/css"} (str ".notebook-viewer { padding-top: 2.5rem; } " + ".notebook-viewer .viewer:first-child { display: none; } " + "#clerk > div > div > .dark-mode-toggle { display: none !important; }")] + [:div.fixed.w-screen.h-screen.flex.flex-col.top-0.left-0 + [:div.flex + [:div.relative + {:ref !editor-panel :style {:width "50vw"}} + [:div.bg-slate-200.border-r.border-slate-300.dark:border-slate-600.px-4.py-3.dark:bg-slate-950.overflow-y-auto.relative + {:style {:height (str "calc(100vh - " (* bar-height 2) "px)")}} + [:div.h-screen {:ref !container-el}]] + [:div.absolute.right-0.top-0.bottom-0.z-1000.group + {:class "w-[9px] -mr-[5px]"} + [:div.absolute.h-full.bg-transparent.group-hover:bg-blue-500.transition.pointer-events-none + {:class "left-[3px] w-[3px]"}] + [panel/resizer {:axis :x :on-resize (fn [_ dx _] + (j/assoc-in! @!editor-panel [:style :width] (str (+ (.-offsetWidth @!editor-panel) dx) "px")) + (j/assoc-in! @!notebook-panel [:style :width] (str (- (.-offsetWidth @!notebook-panel) dx) "px")))}]]] + [:div.bg-white.dark:bg-slate-950.bg-white.flex.flex-col.overflow-y-auto + {:ref !notebook-panel + :style {:width "50vw" :height (str "calc(100vh - " (* bar-height 2) "px)")}} + (when-let [notebook @!notebook] + [:> render/ErrorBoundary {:hash (gensym)} + [render/inspect notebook]])]] + [:div.absolute.left-0.bottom-0.w-screen.font-mono.text-white.border-t.dark:border-slate-600 + [:div.bg-slate-900.dark:bg-slate-800.flex.px-4.font-mono.gap-4.items-center.text-white + {:class "text-[12px]" :style {:height bar-height}} + [:div.flex.gap-1.items-center + "Eval notebook" + [:div.font-inter.text-slate-300 "⌥↩"]] + [:div.flex.gap-1.items-center + "Eval at cursor" + [:div.font-inter.text-slate-300 "⌘↩"]] + [:div.flex.gap-1.items-center + "Eval top level" + [:div.font-inter.text-slate-300 "⇧⌘↩"]] + [:div.flex.gap-1.items-center + "Slurp forward" + [:div.font-inter.text-slate-300 "Ctrl→"]] + [:div.flex.gap-1.items-center + "Barf forward" + [:div.font-inter.text-slate-300 "Ctrl←"]] + [:div.flex.gap-1.items-center + "Splice" + [:div.font-inter.text-slate-300 "⌥S"]] + [:div.flex.gap-1.items-center + "Expand selection" + [:div.font-inter.text-slate-300 "⌘1"]] + [:div.flex.gap-1.items-center + "Contract selection" + [:div.font-inter.text-slate-300 "⌘2"]]] + [:div.w-screen.bg-slate-800.dark:bg-slate-950.px-4.font-mono.items-center.text-white.flex.items-center + {:class "text-[12px] py-[4px]" :style {:min-height bar-height}} + (when-let [{:keys [name arglists-str doc]} @!info] + [:div + [:div.flex.gap-4 + [:div.flex.gap-2 + [:span.font-bold (str name)] + [:span arglists-str]] + (when (and doc (not @!show-docstring?)) + [:div.flex.gap-1.items-center.text-slate-300 + "Show docstring" + [:div.font-inter.text-slate-400.flex-shrink-0 "⌘I"]])] + (when (and doc @!show-docstring?) + [:div.text-slate-300.mt-2.mb-1.leading-relaxed {:class "max-w-[640px]"} doc])])]] + (when-let [result @!eval-result] + [:div.border-t.border-slate-300.dark:border-slate-600.px-4.py-2.flex-shrink-0.absolute.left-0.w-screen.bg-white.dark:bg-slate-950 + {:style {:box-shadow "0 -2px 3px 0 rgb(0 0 0 / 0.025)" :bottom (* bar-height 2)}} + [render/inspect result]])]])) diff --git a/src/nextjournal/clerk/render/panel.cljs b/src/nextjournal/clerk/render/panel.cljs index 8305372ef..dfb1d5f7c 100644 --- a/src/nextjournal/clerk/render/panel.cljs +++ b/src/nextjournal/clerk/render/panel.cljs @@ -2,7 +2,8 @@ (:require [applied-science.js-interop :as j] [nextjournal.clerk.render.hooks :as hooks])) -(defn resizer [{:keys [on-resize on-resize-start on-resize-end] :or {on-resize-start #() on-resize-end #()}}] +(defn resizer [{:keys [axis on-resize on-resize-start on-resize-end] + :or {on-resize-start #() on-resize-end #()}}] (let [!direction (hooks/use-state nil) !mouse-down (hooks/use-state false) handle-mouse-down (fn [dir] @@ -23,31 +24,35 @@ (reset! !mouse-down false))] (js/addEventListener "mouseup" handle-mouse-up) #(js/removeEventListener "mouseup" handle-mouse-up)))) - [:<> - [:div.absolute.z-2.cursor-nwse-resize - {:on-mouse-down #(handle-mouse-down :top-left) - :class "w-[14px] h-[14px] -left-[7px] -top-[7px]"}] - [:div.absolute.z-1.left-0.w-full.cursor-ns-resize - {:on-mouse-down #(handle-mouse-down :top) - :class "h-[4px] -top-[4px]"}] - [:div.absolute.z-2.cursor-nesw-resize - {:on-mouse-down #(handle-mouse-down :top-right) - :class "w-[14px] h-[14px] -right-[7px] -top-[7px]"}] - [:div.absolute.z-1.top-0.h-full.cursor-ew-resize - {:on-mouse-down #(handle-mouse-down :right) - :class "w-[4px] -right-[2px]"}] - [:div.absolute.z-2.cursor-nwse-resize - {:on-mouse-down #(handle-mouse-down :bottom-right) - :class "w-[14px] h-[14px] -right-[7px] -bottom-[7px]"}] - [:div.absolute.z-1.bottom-0.w-full.cursor-ns-resize - {:on-mouse-down #(handle-mouse-down :bottom) - :class "h-[4px] -left-[2px]"}] - [:div.absolute.z-2.cursor-nesw-resize - {:on-mouse-down #(handle-mouse-down :bottom-left) - :class "w-[14px] h-[14px] -left-[7px] -bottom-[7px]"}] - [:div.absolute.z-1.left-0.top-0.h-full.cursor-ew-resize - {:on-mouse-down #(handle-mouse-down :left) - :class "w-[4px]"}]])) + (if axis + [:div.w-full.h-full + {:class (if (= axis :x) "cursor-col-resize" "cursor-row-resize") + :on-mouse-down #(handle-mouse-down (if (= axis :x) :left :up))}] + [:<> + [:div.absolute.z-2.cursor-nwse-resize + {:on-mouse-down #(handle-mouse-down :top-left) + :class "w-[14px] h-[14px] -left-[7px] -top-[7px]"}] + [:div.absolute.z-1.left-0.w-full.cursor-ns-resize + {:on-mouse-down #(handle-mouse-down :top) + :class "h-[4px] -top-[4px]"}] + [:div.absolute.z-2.cursor-nesw-resize + {:on-mouse-down #(handle-mouse-down :top-right) + :class "w-[14px] h-[14px] -right-[7px] -top-[7px]"}] + [:div.absolute.z-1.top-0.h-full.cursor-ew-resize + {:on-mouse-down #(handle-mouse-down :right) + :class "w-[4px] -right-[2px]"}] + [:div.absolute.z-2.cursor-nwse-resize + {:on-mouse-down #(handle-mouse-down :bottom-right) + :class "w-[14px] h-[14px] -right-[7px] -bottom-[7px]"}] + [:div.absolute.z-1.bottom-0.w-full.cursor-ns-resize + {:on-mouse-down #(handle-mouse-down :bottom) + :class "h-[4px] -left-[2px]"}] + [:div.absolute.z-2.cursor-nesw-resize + {:on-mouse-down #(handle-mouse-down :bottom-left) + :class "w-[14px] h-[14px] -left-[7px] -bottom-[7px]"}] + [:div.absolute.z-1.left-0.top-0.h-full.cursor-ew-resize + {:on-mouse-down #(handle-mouse-down :left) + :class "w-[4px]"}]]))) (defn header [{:keys [id title on-drag on-drag-start on-drag-end on-close] :or {on-drag-start #() on-drag-end #()}}] (let [!mouse-down (hooks/use-state false) diff --git a/src/nextjournal/clerk/sci_env.cljs b/src/nextjournal/clerk/sci_env.cljs index 9b504288e..950850fc9 100644 --- a/src/nextjournal/clerk/sci_env.cljs +++ b/src/nextjournal/clerk/sci_env.cljs @@ -19,6 +19,7 @@ [nextjournal.clerk.render :as render] [nextjournal.clerk.render.code] [nextjournal.clerk.render.context :as view-context] + [nextjournal.clerk.render.editor] [nextjournal.clerk.render.hooks] [nextjournal.clerk.render.navbar] [nextjournal.clerk.trim-image] @@ -163,6 +164,7 @@ "react" react} :ns-aliases '{clojure.math cljs.math} :namespaces (merge {'nextjournal.clerk.viewer viewer-namespace + 'nextjournal.clerk viewer-namespace ;; TODO: expose cljs variant of `nextjournal.clerk` with docstrings 'clojure.core {'read-string read-string 'implements? (sci/copy-var implements?* core-ns) 'time (sci/copy-var time core-ns) @@ -172,6 +174,7 @@ 'nextjournal.clerk.parser 'nextjournal.clerk.render 'nextjournal.clerk.render.code + 'nextjournal.clerk.render.editor 'nextjournal.clerk.render.hooks 'nextjournal.clerk.render.navbar diff --git a/src/nextjournal/clerk/sci_env/completions.cljs b/src/nextjournal/clerk/sci_env/completions.cljs new file mode 100644 index 000000000..9c783f15f --- /dev/null +++ b/src/nextjournal/clerk/sci_env/completions.cljs @@ -0,0 +1,138 @@ +(ns nextjournal.clerk.sci-env.completions + (:require [clojure.string :as str] + [goog.object :as gobject] + [sci.core :as sci] + [sci.ctx-store])) + +(defn format [fmt-str x] + (str/replace fmt-str "%s" x)) + +(defn fully-qualified-syms [ctx ns-sym] + (let [syms (sci/eval-string* ctx (format "(keys (ns-map '%s))" ns-sym)) + sym-strs (map #(str "`" %) syms) + sym-expr (str "[" (str/join " " sym-strs) "]") + syms (sci/eval-string* ctx sym-expr) + syms (remove #(str/starts-with? (str %) "nbb.internal") syms)] + syms)) + +(defn- ns-imports->completions [ctx query-ns query] + (let [[_ns-part name-part] (str/split query #"/") + resolved (sci/eval-string* ctx + (pr-str `(let [resolved# (resolve '~query-ns)] + (when-not (var? resolved#) + resolved#))))] + (when resolved + (when-let [[prefix imported] (if name-part + (let [ends-with-dot? (str/ends-with? name-part ".") + fields (str/split name-part #"\.") + fields (if ends-with-dot? + fields + (butlast fields))] + [(str query-ns "/" (when (seq fields) + (let [joined (str/join "." fields)] + (str joined ".")))) + (apply gobject/getValueByKeys resolved + fields)]) + [(str query-ns "/") resolved])] + (let [props (loop [obj imported + props []] + (if obj + (recur (js/Object.getPrototypeOf obj) + (into props (js/Object.getOwnPropertyNames obj))) + props)) + completions (map (fn [k] + [nil (str prefix k)]) props)] + completions))))) + +(defn- match [_alias->ns ns->alias query [sym-ns sym-name qualifier]] + (let [pat (re-pattern query)] + (or (when (and (= :unqualified qualifier) (re-find pat sym-name)) + [sym-ns sym-name]) + (when sym-ns + (or (when (re-find pat (str (get ns->alias (symbol sym-ns)) "/" sym-name)) + [sym-ns (str (get ns->alias (symbol sym-ns)) "/" sym-name)]) + (when (re-find pat (str sym-ns "/" sym-name)) + [sym-ns (str sym-ns "/" sym-name)])))))) + +(defn format-1 [fmt-str x] + (str/replace-first fmt-str "%s" x)) + +(defn info [{:keys [sym ctx] ns-str :ns}] + (if-not sym + {:status ["no-eldoc" "done"] + :err "Message should contain a `sym`"} + (let [code (-> "(when-let [the-var (ns-resolve '%s '%s)] (meta the-var))" + (format-1 ns-str) + (format-1 sym)) + [kind val] (try [::success (sci/eval-string* ctx code)] + (catch :default e + [::error (str e)])) + {:keys [doc file line name arglists]} val] + (if (and name (= kind ::success)) + (cond-> {:ns (some-> val :ns ns-name) + :arglists (pr-str arglists) + :eldoc (mapv #(mapv str %) arglists) + :arglists-str (.join (apply array arglists) "\n") + :status ["done"] + :name name} + doc (assoc :doc doc) + file (assoc :file file) + line (assoc :line line)) + {:status ["done" "no-eldoc"]})))) + +(defn completions [{:keys [ctx] ns-str :ns :as request}] + (try + (let [sci-ns (when ns-str + (sci/find-ns ctx (symbol ns-str)))] + (sci/binding [sci/ns (or sci-ns @sci/ns)] + (if-let [query (or (:symbol request) + (:prefix request))] + (let [has-namespace? (str/includes? query "/") + query-ns (when has-namespace? (some-> (str/split query #"/") + first symbol)) + from-current-ns (fully-qualified-syms ctx (sci/eval-string* ctx "(ns-name *ns*)")) + from-current-ns (map (fn [sym] + [(namespace sym) (name sym) :unqualified]) + from-current-ns) + alias->ns (sci/eval-string* ctx "(let [m (ns-aliases *ns*)] (zipmap (keys m) (map ns-name (vals m))))") + ns->alias (zipmap (vals alias->ns) (keys alias->ns)) + from-aliased-nss (doall (mapcat + (fn [alias] + (let [ns (get alias->ns alias) + syms (sci/eval-string* ctx (format "(keys (ns-publics '%s))" ns))] + (map (fn [sym] + [(str ns) (str sym) :qualified]) + syms))) + (keys alias->ns))) + all-namespaces (->> (sci/eval-string* ctx "(all-ns)") + (map (fn [ns] + [(str ns) nil :qualified]))) + from-imports (when has-namespace? (ns-imports->completions ctx query-ns query)) + fully-qualified-names (when-not from-imports + (when has-namespace? + (let [ns (get alias->ns query-ns query-ns) + syms (sci/eval-string* ctx (format "(and (find-ns '%s) + (keys (ns-publics '%s)))" + ns))] + (map (fn [sym] + [(str ns) (str sym) :qualified]) + syms)))) + svs (concat from-current-ns from-aliased-nss all-namespaces fully-qualified-names) + completions (keep (fn [entry] + (match alias->ns ns->alias query entry)) + svs) + completions (concat completions from-imports) + completions (->> (map (fn [[namespace name]] + (let [candidate (str name) + info (when namespace + (info {:ns (str namespace) :sym candidate :ctx ctx}))] + {:candidate candidate :info info})) + completions) + distinct vec)] + {:completions completions + :status ["done"]}) + {:status ["done"]}))) + (catch :default e + (js/console.error "ERROR" e) + {:completions [] + :status ["done"]}))) diff --git a/src/nextjournal/clerk/viewer.cljc b/src/nextjournal/clerk/viewer.cljc index 37f68df41..2e9de4fb6 100644 --- a/src/nextjournal/clerk/viewer.cljc +++ b/src/nextjournal/clerk/viewer.cljc @@ -1,4 +1,5 @@ (ns nextjournal.clerk.viewer + (:refer-clojure :exclude [var?]) (:require [clojure.string :as str] [clojure.pprint :as pprint] [clojure.datafy :as datafy] @@ -13,6 +14,7 @@ :cljs [[goog.crypt] [goog.crypt.Sha1] [reagent.ratom :as ratom] + [sci.core :as sci] [sci.impl.vars] [sci.lang] [applied-science.js-interop :as j]]) @@ -390,6 +392,10 @@ #_((update-val + 1) {:nextjournal/value 41}) +(defn var? [x] + (or (clojure.core/var? x) + #?(:cljs (instance? sci.lang.Var x)))) + (defn var-from-def? [x] (var? (get-safe x :nextjournal.clerk/var-from-def))) @@ -464,11 +470,14 @@ (defn datafy-scope [scope] (cond - #?@(:clj [(instance? clojure.lang.Namespace scope) (ns-name scope)]) + #?@(:clj [(instance? clojure.lang.Namespace scope) (ns-name scope)] + :cljs [(instance? sci.lang.Namespace scope) (sci/ns-name scope)]) (symbol? scope) scope (#{:default} scope) scope :else (throw (ex-info (str "Unsupported scope `" scope "`. Valid scopes are namespaces, symbol namespace names or `:default`.") {:scope scope})))) +(defn get-*ns* [] + (or *ns* #?(:cljs @sci.core/ns))) (defn get-viewers ([scope] (get-viewers scope nil)) @@ -505,7 +514,7 @@ (set/rename-keys viewer-opts-normalization)) {:as to-present :nextjournal/keys [auto-expand-results?]} (merge (dissoc (->opts wrapped-value) :!budget :nextjournal/budget) opts-from-block - (ensure-wrapped-with-viewers (or viewers (get-viewers *ns*)) value)) + (ensure-wrapped-with-viewers (or viewers (get-viewers (get-*ns*))) value)) presented-result (-> (present to-present) (update :nextjournal/render-opts (fn [{:as opts existing-id :id}] @@ -830,7 +839,7 @@ (def var-viewer {:name `var-viewer - :pred (some-fn var? #?(:cljs #(instance? sci.lang.Var %))) + :pred var? :transform-fn (comp #?(:cljs var->symbol :clj symbol) ->value) :render-fn '(fn [x] [:span.inspected-value [:span.cmt-meta "#'" (str x)]])}) @@ -1335,7 +1344,7 @@ #_(viewer-for default-viewers (with-viewer {:transform-fn identity} [:h1 "Hello Hiccup"])) (defn ensure-wrapped-with-viewers - ([x] (ensure-wrapped-with-viewers (get-viewers *ns*) x)) + ([x] (ensure-wrapped-with-viewers (get-viewers (get-*ns*)) x)) ([viewers x] (-> x ensure-wrapped @@ -1723,13 +1732,13 @@ xs))))))) (defn reset-viewers! - ([viewers] (reset-viewers! *ns* viewers)) + ([viewers] (reset-viewers! (get-*ns*) viewers)) ([scope viewers] (swap! !viewers assoc (datafy-scope scope) viewers) viewers)) (defn add-viewers! [viewers] - (reset-viewers! *ns* (add-viewers (get-default-viewers) viewers))) + (reset-viewers! (get-*ns*) (add-viewers (get-default-viewers) viewers))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; public convenience api