Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract hooks to own ns, inline code viewer and let it use hooks #257

Merged
merged 16 commits into from
Nov 10, 2022
Merged
File renamed without changes.
13 changes: 7 additions & 6 deletions notebooks/viewers/errors.clj
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@
42)


(clerk/with-viewer {:render-fn '(fn [x] (let [handle-error (nextjournal.clerk.render/use-error-handler)
ref(nextjournal.clerk.render/use-callback (fn [_]
(-> (js/Promise. (fn [resolve reject]
(js/setTimeout (fn [] (resolve 1)), 200)))
(.then (fn [x] (throw (js/Error "async error 💥"))))
(.catch handle-error))))]
(clerk/with-viewer {:render-fn '(fn [x] (let [handle-error (nextjournal.clerk.render.hooks/use-error-handler)
ref (nextjournal.clerk.render.hooks/use-callback
(fn [_]
(-> (js/Promise. (fn [resolve reject]
(js/setTimeout (fn [] (resolve 1)), 200)))
(.then (fn [x] (throw (js/Error "async error 💥"))))
(.catch handle-error))))]
[:h3 {:ref ref} (str x)]))}
42)
2 changes: 1 addition & 1 deletion resources/viewer-js-hash
Original file line number Diff line number Diff line change
@@ -1 +1 @@
HmD3zfMkY2Qm5Z1LTREMXQTdq8Y
HyZRVkbHxkzXU5g5XFyw5QDwF9x
201 changes: 32 additions & 169 deletions src/nextjournal/clerk/render.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@
(:require ["d3-require" :as d3-require]
["react" :as react]
["react-dom/client" :as react-client]
["use-sync-external-store/shim" :refer [useSyncExternalStore]]
[applied-science.js-interop :as j]
[cljs.reader]
[clojure.string :as str]
[editscript.core :as editscript]
[goog.object]
[goog.string :as gstring]
[nextjournal.clerk.render.code :as code]
[nextjournal.clerk.render.hooks :as hooks]
[nextjournal.clerk.viewer :as viewer]
[nextjournal.markdown.transform :as md.transform]
[nextjournal.ui.components.icon :as icon]
[nextjournal.ui.components.motion :as motion]
[nextjournal.ui.components.navbar :as navbar]
[nextjournal.view.context :as view-context]
[nextjournal.viewer.code :as code]
[nextjournal.viewer.katex :as katex]
[nextjournal.viewer.mathjax :as mathjax]
[reagent.core :as r]
Expand All @@ -24,120 +24,6 @@
[sci.core :as sci]
[shadow.cljs.modern :refer [defclass]]))

;; a type for wrapping react/useState to support reset! and swap!
(deftype WrappedState [st]
IIndexed
(-nth [coll i] (aget st i))
(-nth [coll i nf] (or (aget st i) nf))
IDeref
(-deref [^js this] (aget st 0))
IReset
(-reset! [^js this new-value]
;; `constantly` here ensures that if we reset state to a fn,
;; it is stored as-is and not applied to prev value.
((aget st 1) (constantly new-value)))
ISwap
(-swap! [this f] ((aget st 1) f))
(-swap! [this f a] ((aget st 1) #(f % a)))
(-swap! [this f a b] ((aget st 1) #(f % a b)))
(-swap! [this f a b xs] ((aget st 1) #(apply f % a b xs))))

(defn- as-array [x] (cond-> x (not (array? x)) to-array))

(defn use-memo
"React hook: useMemo. Defaults to an empty `deps` array."
([f] (react/useMemo f #js[]))
([f deps] (react/useMemo f (as-array deps))))

(defn use-callback
"React hook: useCallback. Defaults to an empty `deps` array."
([x] (use-callback x #js []))
([x deps] (react/useCallback x (to-array deps))))

(defn- wrap-effect
;; utility for wrapping function to return `js/undefined` for non-functions
[f] #(let [v (f)] (if (fn? v) v js/undefined)))

(defn use-effect
"React hook: useEffect. Defaults to an empty `deps` array.
Wraps `f` to return js/undefined for any non-function value."
([f] (react/useEffect (wrap-effect f) #js[]))
([f deps] (react/useEffect (wrap-effect f) (as-array deps))))

(defn use-state
"React hook: useState. Can be used like react/useState but also behaves like an atom."
[init]
(WrappedState. (react/useState init)))

(defn- specify-atom! [ref-obj]
(specify! ref-obj
IDeref
(-deref [^js this] (.-current this))
IReset
(-reset! [^js this new-value] (set! (.-current this) new-value))
ISwap
(-swap!
([o f] (reset! o (f o)))
([o f a] (reset! o (f o a)))
([o f a b] (reset! o (f o a b)))
([o f a b xs] (reset! o (apply f o a b xs))))))

(defn use-ref
"React hook: useRef. Can also be used like an atom."
([] (use-ref nil))
([init] (specify-atom! (react/useRef init))))

(defn- eval-fn
"Invoke (f x) if f is a function, otherwise return f"
[f x]
(if (fn? f)
(f x)
f))

(defn use-force-update []
(-> (react/useReducer inc 0)
(aget 1)))

(defn use-state-with-deps
;; see https://github.com/peterjuras/use-state-with-deps/blob/main/src/index.ts
"React hook: like `use-state` but will reset state to `init` when `deps` change.
- init may be a function, receiving previous state
- deps will be compared using clojure ="
[init deps]
(let [!state (use-ref
(use-memo
#(eval-fn init nil)))
!prev-deps (use-ref deps)
_ (when-not (= deps @!prev-deps)
(reset! !state (eval-fn init @!state))
(reset! !prev-deps deps))
force-update! (use-force-update)
update-fn (use-callback
(fn [x]
(let [prev-state @!state
next-state (eval-fn x prev-state)]
(when (not= prev-state next-state)
(reset! !state next-state)
(force-update!))
next-state)))]
(WrappedState. #js[@!state update-fn])))


(defn use-sync-external-store [subscribe get-snapshot]
(useSyncExternalStore subscribe get-snapshot))

(defn use-watch
"Hook for reading value of an IWatchable. Compatible with reading Reagent reactions non-reactively."
[x]
(let [id (use-callback #js{})]
(use-sync-external-store
(use-callback
(fn [changed!]
(add-watch x id (fn [_ _ _ _] (changed!)))
#(remove-watch x id))
#js[x])
#(binding [reagent.ratom/*ratom-context* nil] @x))))

(r/set-default-compiler! (r/create-compiler {:function-components true}))

(declare inspect inspect-presented reagent-viewer html html-viewer)
Expand Down Expand Up @@ -330,6 +216,7 @@
(set! hash (j/get props :hash))
(set! handle-error (fn [error]
(set! (.-state this) #js {:error error}))))

Object
(render [this ^js props]
(j/let [^js {{:keys [error]} :state
Expand All @@ -348,12 +235,6 @@

(def default-loading-view "Loading...")

(defn use-error-handler []
(let [[_ set-error] (use-state nil)]
(use-callback (fn [error]
(set-error (fn [] (throw error))))
[set-error])))

;; TODO: drop this
(defn read-string [s]
(js/nextjournal.clerk.sci_env.read-string s))
Expand All @@ -375,28 +256,28 @@
true (-> viewer/assign-expanded-at (get :nextjournal/expanded-at {}))))

(defn render-result [{:as result :nextjournal/keys [fetch-opts hash presented]} {:as opts :keys [auto-expand-results?]}]
(let [!desc (use-state-with-deps presented [hash])
!expanded-at (use-state (when (map? @!desc)
(->expanded-at auto-expand-results? @!desc)))
fetch-fn (use-callback (when fetch-opts
(fn [opts]
(.then (fetch! fetch-opts opts)
(fn [more]
(swap! !desc viewer/merge-presentations more opts)
(swap! !expanded-at #(merge (->expanded-at auto-expand-results? @!desc) %))))))
[hash])
on-key-down (use-callback (fn [event]
(if (.-altKey event)
(swap! !expanded-at assoc :prompt-multi-expand? true)
(swap! !expanded-at dissoc :prompt-multi-expand?))))
on-key-up (use-callback #(swap! !expanded-at dissoc :prompt-multi-expand?))
ref-fn (use-callback #(if %
(when (exists? js/document)
(js/document.addEventListener "keydown" on-key-down)
(js/document.addEventListener "keyup" on-key-up))
(when (exists? js/document)
(js/document.removeEventListener "keydown" on-key-down)
(js/document.removeEventListener "up" on-key-up))))]
(let [!desc (hooks/use-state-with-deps presented [hash])
!expanded-at (hooks/use-state (when (map? @!desc)
(->expanded-at auto-expand-results? @!desc)))
fetch-fn (hooks/use-callback (when fetch-opts
(fn [opts]
(.then (fetch! fetch-opts opts)
(fn [more]
(swap! !desc viewer/merge-presentations more opts)
(swap! !expanded-at #(merge (->expanded-at auto-expand-results? @!desc) %))))))
[hash])
on-key-down (hooks/use-callback (fn [event]
(if (.-altKey event)
(swap! !expanded-at assoc :prompt-multi-expand? true)
(swap! !expanded-at dissoc :prompt-multi-expand?))))
on-key-up (hooks/use-callback #(swap! !expanded-at dissoc :prompt-multi-expand?))
ref-fn (hooks/use-callback #(if %
(when (exists? js/document)
(js/document.addEventListener "keydown" on-key-down)
(js/document.addEventListener "keyup" on-key-up))
(when (exists? js/document)
(js/document.removeEventListener "keydown" on-key-down)
(js/document.removeEventListener "up" on-key-up))))]
(when @!desc
[view-context/provide {:fetch-fn fetch-fn}
[:> ErrorBoundary {:hash hash}
Expand Down Expand Up @@ -741,35 +622,16 @@
;; TODO: remove
(def reagent-viewer render-reagent)

(defn use-promise
"React hook which resolves a promise and handles errors."
[p]
(let [handle-error (use-error-handler)
!state (use-state nil)]
(use-effect (fn []
(-> p
(.then #(reset! !state %))
(.catch handle-error)))
#js [])
@!state))

(defn ^js use-d3-require [package]
(let [p (react/useMemo #(apply d3-require/require
(cond-> package
(string? package)
list))
#js[(str package)])]
(use-promise p)))

(defn with-d3-require [{:keys [package loading-view]
:or {loading-view default-loading-view}} f]
(if-let [package (use-d3-require package)]
(if-let [package (hooks/use-d3-require package)]
(f package)
loading-view))

(defn render-vega-lite [value]
(let [handle-error (use-error-handler)
vega-embed (use-d3-require "vega-embed@6.11.1")
(let [handle-error (hooks/use-error-handler)
vega-embed (hooks/use-d3-require "vega-embed@6.11.1")
ref-fn (react/useCallback #(when %
(-> (.embed vega-embed % (clj->js (dissoc value :embed/opts)) (clj->js (:embed/opts value {})))
(.catch handle-error)))
Expand All @@ -781,7 +643,7 @@
default-loading-view))))

(defn render-plotly [value]
(let [plotly (use-d3-require "plotly.js-dist@2.15.1")
(let [plotly (hooks/use-d3-require "plotly.js-dist@2.15.1")
ref-fn (react/useCallback #(when %
(.newPlot plotly % (clj->js value)))
#js[value plotly])]
Expand All @@ -792,14 +654,15 @@
default-loading-view))))

(def render-mathjax mathjax/viewer)
(def render-code code/viewer)

(def render-code code/render-code)

(def expand-icon
[:svg {:xmlns "http://www.w3.org/2000/svg" :viewBox "0 0 20 20" :fill "currentColor" :width 12 :height 12}
[:path {:fill-rule "evenodd" :d "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" :clip-rule "evenodd"}]])

(defn render-folded-code [code-string]
(r/with-let [!hidden? (r/atom true)]
(let [!hidden? (hooks/use-state true)]
(if @!hidden?
[:div.relative.pl-12.font-sans.text-slate-400.cursor-pointer.flex.overflow-y-hidden.group
[:span.hover:text-slate-500
Expand Down
83 changes: 83 additions & 0 deletions src/nextjournal/clerk/render/code.cljs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
(ns nextjournal.clerk.render.code
(:require ["@codemirror/language" :refer [syntaxHighlighting HighlightStyle]]
["@lezer/highlight" :refer [tags]]
["@codemirror/state" :refer [EditorState]]
["@codemirror/view" :refer [EditorView]]
[nextjournal.clerk.render.hooks :as hooks]
[applied-science.js-interop :as j]
[nextjournal.clojure-mode :as clojure-mode]))

;; code viewer
(def theme
(.theme EditorView
(j/lit {"&.cm-focused" {:outline "none"}
".cm-line" {:padding "0"
:line-height "1.6"
:font-size "15px"
:font-family "\"Fira Mono\", monospace"}
".cm-matchingBracket" {:border-bottom "1px solid var(--teal-color)"
:color "inherit"}

;; only show cursor when focused
".cm-cursor" {:visibility "hidden"}
"&.cm-focused .cm-cursor" {:visibility "visible"
:animation "steps(1) cm-blink 1.2s infinite"}
"&.cm-focused .cm-selectionBackground" {:background-color "Highlight"}
".cm-tooltip" {:border "1px solid rgba(0,0,0,.1)"
:border-radius "3px"
: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"}})))

(def highlight-style
(.define HighlightStyle
(clj->js [{:tag (.-meta tags) :class "cmt-meta"}
{:tag (.-link tags) :class "cmt-link"}
{:tag (.-heading tags) :class "cmt-heading"}
{:tag (.-emphasis tags) :class "cmt-italic"}
{:tag (.-strong tags) :class "cmt-strong"}
{:tag (.-strikethrough tags) :class "cmt-strikethrough"}
{:tag (.-keyword tags) :class "cmt-keyword"}
{:tag (.-atom tags) :class "cmt-atom"}
{:tag (.-bool tags) :class "cmt-bool"}
{:tag (.-url tags) :class "cmt-url"}
{:tag (.-contentSeparator tags) :class "cmt-contentSeparator"}
{:tag (.-labelName tags) :class "cmt-labelName"}
{:tag (.-literal tags) :class "cmt-literal"}
{:tag (.-inserted tags) :class "cmt-inserted"}
{:tag (.-string tags) :class "cmt-string"}
{:tag (.-deleted tags) :class "cmt-deleted"}
{:tag (.-regexp tags) :class "cmt-regexp"}
{:tag (.-escape tags) :class "cmt-escape"}
{:tag (.. tags (special (.-string tags))) :class "cmt-string"}
{:tag (.. tags (definition (.-variableName tags))) :class "cmt-variableName"}
{:tag (.. tags (local (.-variableName tags))) :class "cmt-variableName"}
{:tag (.-typeName tags) :class "cmt-typeName"}
{:tag (.-namespace tags) :class "cmt-namespace"}
{:tag (.-className tags) :class "cmt-className"}
{:tag (.. tags (special (.-variableName tags))) :class "cmt-variableName"}
{:tag (.-macroName tags) :class "cmt-macroName"}
{:tag (.. tags (definition (.-propertyName tags))) :class "cmt-propertyName"}
{:tag (.-comment tags) :class "cmt-comment"}
{:tag (.-invalid tags) :class "cmt-invalid"}])))

(def extensions
#js [clojure-mode/default-extensions
(syntaxHighlighting highlight-style)
(.. EditorView -editable (of false))
theme])

(defn make-state [doc]
(.create EditorState (j/obj :doc doc :extensions extensions)))

(defn make-view [state parent]
(EditorView. (j/obj :state state :parent parent)))

(defn render-code [value]
(let [!container-el (hooks/use-ref nil)
!view (hooks/use-ref nil)]
(hooks/use-layout-effect (fn [] (let [^js view (reset! !view (make-view (make-state value) @!container-el))]
#(.destroy view))))
(hooks/use-effect (fn [] (.setState @!view (make-state value))) [value])
[:div {:ref !container-el}]))
Loading