Fork me on GitHub
#clerk
<
2024-04-07
>
yuhan09:04:52

How feasible would it be to get Clerk's API to expose some sort of "scroll the browser to this block" functionality? I've been trying to use it "in anger" as a dev tool, but the one thing that breaks the flow most often is having is the page reset to the top after every error and manually mousing over to the browser window to relocate my previous spot.

yuhan09:04:23

I recall Notespace had something like this - from Emacs there would be a command to tell the server "Show document and position the browser's view around my current cursor position", and you'd have a seamless side-by-side editor/browser view around whatever context you were working on. Feels like a relatively small feature that would go a long way in reducing UX friction

yuhan10:04:28

I'd be happy to help with implementing this with some pointers of which direction to look into - probably my lack of experience in CLJS / web dev side of things but Clerk's internals haven't been easy to introspect

mkvlr10:04:34

I consider the scrolling reset a bug, would you mind filing an issue?

mkvlr10:04:19

regarding implementing scroll to cell: it’s possible and I did a proof of concept a while back. Not finding the code right now, I’ll dig it up when I’m at my 💻

👍 1
yuhan10:04:15

Sounds great, I'd love to help test drive it

👍 1
mkvlr10:04:30

@UCPS050BV thanks for the issue!

mkvlr15:04:38

here’s something I built a while back

;; # 📜 Scroll Sync

;; Let's explore programmatically scrolling a given block into the
;; view.
(ns scroll-sync
  (:require [nextjournal.clerk :as clerk]))


;; We'll use this small helper function to unqualify symbols from the
;; sharp quote.
(defn template [f]
  (clojure.walk/postwalk (fn [x] (if (and (qualified-symbol? x)
                                          (#{(str *ns*) "clojure.core"} (namespace x)))
                                   (symbol (name x))
                                   x))
                         f))


(defn dom-selector [block-id]
  "Generate a dom selector from a given block id."
  (format "[data-block-id='%s']"
          (cond-> block-id
            (not (clojure.string/starts-with? (name block-id) "markdown"))
            (str "-result"))))


;; We'll run this form in the browser to scroll to a given block.
(defn build-scroll-to-form [block-id]
  (template `(if-let [el (.querySelector js/document ~(dom-selector block-id))]
               (.scrollIntoViewIfNeeded el)
               (js/console.log "no el found for dom selector" ~(dom-selector block-id)))))


(defn scroll-to! [block-id]
  (nextjournal.clerk.webserver/broadcast!
   {:type :patch-state!
    :patch [[[:eval] :r (nextjournal.clerk.viewer/->viewer-eval (build-scroll-to-form block-id))]]}))


;; run this form to scroll to a random block
#_(scroll-to! (rand-nth (map :id (:blocks @nextjournal.clerk.webserver/!doc))))

mkvlr15:04:13

@U01RN5VR18X you took that a bit further, right? Want to share what you’re using here as wel?

Karl Xaver17:04:27

Yeah, I did some exploration but haven't used it ever since:

;; # 📜 Scroll Sync
;;
;; Let's explore programmatically scrolling a given block into the
;; view.
;; ## TL;DR jump to [the Emacs side](#the-emacs-side) to try it

(ns scroll-sync
  {:nextjournal.clerk/visibility {:code :show :result :hide}
   :nextjournal.clerk/toc        true } 
  (:require
   [clojure.walk :as walk]
   [nextjournal.clerk :as clerk]
   [nextjournal.clerk.webserver :as server]
   [nextjournal.clerk.viewer :as viewer]))

;; ## Scrolling to a known block
;;
;; We'll use this small helper function to unqualify symbols from the
;; sharp quote.
(defn template [f]
  (walk/postwalk
   (fn [x] (if (and (qualified-symbol? x)
                   (#{(str *ns*) "clojure.core"} (namespace x)))
            (symbol (name x))
            x))
   f))

;; Generate a dom selector:
(defn shows-result? [block]
  (= :show (-> block :settings :nextjournal.clerk/visibility :result)))

(defn dom-selector
  "Generate a dom selector for a given block. For code blocks, prefer the result when it exists."
  [block]
  #_(when block)
  (format "[data-block-id=\"%s\"]"
          (case (:type block)
            :markdown (:id block)
            :code     (str (:id block) (if (shows-result? block) "-result" "-code"))
            (throw (Error. (pr-str "Failed:" block))))))

;; We'll run this form in the browser to scroll to a given block.
(defn build-scroll-to-form [block]
  (template `(if-let [el (.querySelector js/document ~(dom-selector block))]
               #_(.scrollIntoViewIfNeeded el)
               (.scrollIntoView el (js-obj
                                    "behavior" "auto" #_"smooth"
                                    "inline" "start"))
               (js/console.log "no el found for dom selector" ~(dom-selector block)))))

(defn scroll-to! [block]
  (nextjournal.clerk.webserver/broadcast!
   {:type  :patch-state!
    :patch [[[:eval] :r (nextjournal.clerk.viewer/->viewer-eval (build-scroll-to-form block))]]})
  (build-scroll-to-form block))

;; Run this form to scroll to a random block:
(comment
  (scroll-to! (rand-nth (:blocks @nextjournal.clerk.webserver/!doc))))


;; ## Targeting a block
;;
;; Emacs knows nothing about blocks, so we can't target them directly.
;;
;;### Target via source _line number_ 
;;
;; Not all lines correspond to a block, e.g. empty lines. And there'll
;; be mismatches when the source is out of sync with the rendered doc.
;; To avoid the need to handle these cases, we try to get close instead
;; of performing a strict positional check:

(def block-location-unknown? (complement :loc))

(defn block-after-line? [n {:keys [loc]}]
  (< (:end-line loc) n))

(defn get-block-at-line [n]
  (first
   ;; Assumption: the blocks are in the same order as the source line numbers
   (drop-while (some-fn
                block-location-unknown?
                (partial block-after-line? n))
               (:blocks @server/!doc))))

(comment
  (scroll-to! (get-block-at-line 40)))

;; ... but this won't work for markdown blocks.

;;#### ❓ Markdown blocks don't know their place
;;
;; Markdown blocks currently have no `:loc` key, so we can't use it for targeting. They don't seem
;; to contain any relation to the source.
;;
;; Possible solutions:
;; - add `:loc` to markdown blocks
;; - make a guess using the surrounding code blocks
;; - fall back to textual search

;;##### Solution 1: add `:loc` to markdown blocks
;;
;; In a quick and dirty attempt, I changed the `parse-clojure-string` @
;; 
;;
;; With this, scrolling to markdown blocks works without any further changes (for `.clj` notebooks). I haven't checked
;; the performance implications though.


;;##### Solution 2: Make a guess via surrounding code blocks
;;
;; Minimal approach: return the preceding block in cases were the line number isn't found in the
;; block we stopped searching at.
(defn block-contains-line? [n block]
  (when-some [{:keys [line end-line]} (:loc block)]
    (<= line n end-line)))

(defn get-block-near-line [n]
  (reduce
   (fn [prev block]
     (if (or (block-location-unknown? block)
             (block-after-line? n block))
       block
       (reduced
        (if (block-contains-line? n block)
          block
          prev))))
   (:blocks @server/!doc)))

(comment
  (scroll-to! (get-block-near-line 148)))

;; Problematic: big markdown blocks (will just scroll to the top)

;;## The Emacs side
;;
;;### ① Load this notebook namespace in your cider repl (`C-c C-k`).
;;
;;### ② Eval elisp:
;;
;; 
emacs-lisp ;; ;; (with-eval-after-load 'cider ;; ;; (defun clerk-scroll! (line) ;; (cider-interactive-eval ;; (format "(scroll-sync/scroll-to! (scroll-sync/get-block-near-line %s))" line) ;; ;; comment out the lambda for debugging ;; (lambda (result) 'silent))) ;; ;; (defun clerk-scroll-to-block-at-point () ;; (interactive) ;; (clerk-scroll! (line-number-at-pos (point) :absolute))) ;; ;; (defun clerk--follow (window new-window-start) ;; (clerk-scroll! (line-number-at-pos new-window-start :absolute))) ;; ;; (define-minor-mode clerk-follow-mode ;; "Scroll the current notebook alongside Emacs. ;; Only works with cider connected to a repl with clerk." ;; :local t :lighter "clerk>>" ;; (if clerk-follow-mode ;; (add-hook 'window-scroll-functions #'clerk--follow 0 :local) ;; (remove-hook 'window-scroll-functions #'clerk--follow :local))) ;; ;; (define-key cider-mode-map (kbd "C-<return>") #'clerk-scroll-to-block-at-point)) ;;
;;### ③ Scroll to block at point with `C-RET` or try `M-x clerk-follow-mode`
;;### 
;;### ❓ The block abstraction alone is not fine grained enough for scrolling
;; ... especially with big markdown blocks.
;; Ideally I'd want browser & editor to start at the same line.
;;### Consider fine tuning with textual/regex-search on the client or use that in place of targeting blocks altogether


^{:nextjournal.clerk/visibility {:code :hide}}
(comment
  
  (clerk/serve! {:browse? true})
  (clerk/clear-cache!)
  
  (do
    (def blocks (:blocks @server/!doc))
    (def id->block (into {} (map (juxt :id identity) (:blocks @server/!doc))))
    (def first-code-block (first (filter (comp (partial = :code) :type) blocks)))
    (def last-code-block (last (filter (comp (partial = :code) :type) blocks)))
    (def first-markdown-block (first (filter (comp (partial = :markdown) :type) blocks)))
    (def last-markdown-block (last (filter (comp (partial = :markdown) :type) blocks))))

  (id->block 'scroll-sync/dom-selector)
  (scroll-to! first-code-block)
  (scroll-to! last-markdown-block)
  (map :type blocks))

Karl Xaver17:04:54

Ran a quick check and it still works with the latest clerk on my machine. I won't go deeper into this, so if you see anything of use to you, feel free to take it further.

🙏 2
yuhan20:04:13

ahh.. just tried it out and it seems to be working in its own ns, but once I attempted to use it elsewhere things went haywire 😧 soon I was getting StackOverflows on every eval and had to forcibly shut down the repl Will investigate more when I have the time, thanks for providing a great starting point :)

🖤 1