From 0bf24372b0fed5be6bf01ffb7c04b2e254526077 Mon Sep 17 00:00:00 2001 From: Philippa Markovics Date: Tue, 13 Dec 2022 15:58:54 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=A1=20Sticky=20Table=20Headers=20(#305?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Keep table headers in view when scrolling * Keep elision buttons in view when scrolling Co-authored-by: Martin Kavalar --- package.json | 1 + resources/viewer-js-hash | 2 +- src/nextjournal/clerk/render.cljs | 27 ++- src/nextjournal/clerk/render/navbar.cljs | 218 +++++++++++++++++++++++ src/nextjournal/clerk/sci_env.cljs | 17 +- src/nextjournal/clerk/static_app.cljs | 4 +- src/nextjournal/clerk/view.clj | 2 - src/nextjournal/clerk/viewer.cljc | 15 +- 8 files changed, 265 insertions(+), 21 deletions(-) create mode 100644 src/nextjournal/clerk/render/navbar.cljs diff --git a/package.json b/package.json index a8cd038ac..cb924dc72 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "use-sync-external-store": "^1.2.0", + "vh-sticky-table-header": "^1.2.1", "w3c-keyname": "2.2.4" }, "devDependencies": { diff --git a/resources/viewer-js-hash b/resources/viewer-js-hash index 87d11864c..06100b11a 100644 --- a/resources/viewer-js-hash +++ b/resources/viewer-js-hash @@ -1 +1 @@ -4Wzh8dpMtSUFXJ2UmYNFZqSZ5JFU \ No newline at end of file +3g3hotfUsaHvvKuPkMjwmjSbbLAA \ No newline at end of file diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index d2632493d..bd6f6c353 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -11,11 +11,11 @@ [goog.string :as gstring] [nextjournal.clerk.render.code :as code] [nextjournal.clerk.render.hooks :as hooks] + [nextjournal.clerk.render.navbar :as navbar] [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.katex :as katex] [nextjournal.viewer.mathjax :as mathjax] @@ -126,20 +126,25 @@ (defn render-notebook [{:as _doc xs :blocks :keys [bundle? css-class toc toc-visibility]}] (r/with-let [local-storage-key "clerk-navbar" + navbar-width 220 !state (r/atom {:toc (toc-items (:children toc)) :md-toc toc :dark-mode? (localstorage-get local-storage-dark-mode-key) :theme {:slide-over "bg-slate-100 dark:bg-gray-800 font-sans border-r dark:border-slate-900"} - :width 220 + :width navbar-width :mobile-width 300 :local-storage-key local-storage-key :set-hash? (not bundle?) + :scroll-el (js/document.querySelector "html") :open? (if-some [stored-open? (localstorage-get local-storage-key)] stored-open? (not= :collapsed toc-visibility))}) - root-ref-fn #(when % (setup-dark-mode! !state)) - ref-fn #(when % (swap! !state assoc :scroll-el %))] - (let [{:keys [md-toc]} @!state] + root-ref-fn #(when % (setup-dark-mode! !state))] + (let [{:keys [md-toc mobile? open?]} @!state + doc-inset (cond + mobile? 0 + open? navbar-width + :else 0)] (when-not (= md-toc toc) (swap! !state assoc :toc (toc-items (:children toc)) :md-toc toc :open? (not= :collapsed toc-visibility))) [:div.flex @@ -153,11 +158,15 @@ [icon/menu {:size 20}] [:span.uppercase.tracking-wider.ml-1.font-bold {:class "text-[12px]"} "ToC"]] - {:class "z-10 fixed right-2 top-2 md:right-auto md:left-3 md:top-3 text-slate-400 font-sans text-xs hover:underline cursor-pointer flex items-center bg-white dark:bg-gray-900 py-1 px-3 md:p-0 rounded-full md:rounded-none border md:border-0 border-slate-200 dark:border-gray-500 shadow md:shadow-none dark:text-slate-400 dark:hover:text-white"}] + {:class "z-10 fixed right-2 top-2 md:right-auto md:left-3 md:top-[7px] text-slate-400 font-sans text-xs hover:underline cursor-pointer flex items-center bg-white dark:bg-gray-900 py-1 px-3 md:p-0 rounded-full md:rounded-none border md:border-0 border-slate-200 dark:border-gray-500 shadow md:shadow-none dark:text-slate-400 dark:hover:text-white"}] [navbar/panel !state [navbar/navbar !state]]]) - [:div.flex-auto.h-screen.overflow-y-auto.scroll-container - {:ref ref-fn} - [:div {:class (or css-class "flex flex-col items-center viewer-notebook flex-auto")} + [:div.flex-auto.w-screen.scroll-container + [:> motion/div + {:key "viewer-notebook" + :initial {:margin-left doc-inset} + :animate {:margin-left doc-inset} + :transition navbar/spring + :class (or css-class "flex flex-col items-center viewer-notebook flex-auto")} (doall (map-indexed (fn [idx x] (let [{viewer-name :name} (viewer/->viewer x) diff --git a/src/nextjournal/clerk/render/navbar.cljs b/src/nextjournal/clerk/render/navbar.cljs new file mode 100644 index 000000000..41a351025 --- /dev/null +++ b/src/nextjournal/clerk/render/navbar.cljs @@ -0,0 +1,218 @@ +(ns nextjournal.clerk.render.navbar + (:require [nextjournal.devcards :as dc] + [nextjournal.ui.components.icon :as icon] + [nextjournal.ui.components.localstorage :as ls] + [nextjournal.ui.components.motion :as motion] + [applied-science.js-interop :as j] + [clojure.string :as str] + [reagent.core :as r] + ["emoji-regex" :as emoji-regex])) + +(def emoji-re (emoji-regex)) + +(defn stop-event! [event] + (.preventDefault event) + (.stopPropagation event)) + +(defn scroll-to-anchor! + "Uses framer-motion to animate scrolling to a section. + `offset` here is just a visual offset. It looks way nicer to stop + just before a section instead of having it glued to the top of + the viewport." + [!state anchor] + (let [{:keys [mobile? scroll-animation scroll-el set-hash? visible?]} @!state + scroll-top (.-scrollTop scroll-el) + offset 40] + (when scroll-animation + (.stop scroll-animation)) + (when scroll-el + (swap! !state assoc + :scroll-animation (motion/animate + scroll-top + (+ scroll-top (.. (js/document.getElementById (subs anchor 1)) getBoundingClientRect -top)) + {:onUpdate #(j/assoc! scroll-el :scrollTop (- % offset)) + :onComplete #(when set-hash? (.pushState js/history #js {} "" anchor)) + :type :spring + :duration 0.4 + :bounce 0.15}) + :visible? (if mobile? false visible?))))) + +(defn theme-class [theme key] + (-> {:project "py-3" + :toc "py-3" + :heading "mt-1 md:mt-0 text-xs md:text-[12px] uppercase tracking-wider text-slate-500 dark:text-slate-400 font-medium px-3 mb-1 leading-none" + :back "text-xs md:text-[12px] leading-normal text-slate-500 dark:text-slate-400 md:hover:bg-slate-200 md:dark:hover:bg-slate-700 font-normal px-3 py-1" + :expandable "text-base md:text-[14px] leading-normal md:hover:bg-slate-200 md:dark:hover:bg-slate-700 dark:text-white px-3 py-2 md:py-1" + :triangle "text-slate-500 dark:text-slate-400" + :item "text-base md:text-[14px] md:hover:bg-slate-200 md:dark:hover:bg-slate-700 dark:text-white px-3 py-2 md:py-1 leading-normal" + :icon "text-slate-500 dark:text-slate-400" + :slide-over "font-sans bg-white border-r" + :slide-over-unpinned "shadow-xl" + :toggle "text-slate-500 absolute right-2 top-[11px] cursor-pointer z-10"} + (merge theme) + (get key))) + +(defn toc-items [!state items & [options]] + (let [{:keys [theme]} @!state] + (into + [:div] + (map + (fn [{:keys [path title items]}] + [:<> + [:a.flex + {:href path + :class (theme-class theme :item) + :on-click (fn [event] + (stop-event! event) + (scroll-to-anchor! !state path))} + [:div (merge {} options) title]] + (when (seq items) + [:div.ml-3 + [toc-items !state items]])]) + items)))) + +(defn navbar-items [!state items update-at] + (let [{:keys [mobile? theme]} @!state] + (into + [:div] + (map-indexed + (fn [i {:keys [path title expanded? loading? items toc]}] + (let [label (or title (str/capitalize (last (str/split path #"/")))) + emoji (when (zero? (.search label emoji-re)) + (first (.match label emoji-re)))] + [:<> + (if (seq items) + [:div.flex.cursor-pointer + {:class (theme-class theme :expandable) + :on-click (fn [event] + (stop-event! event) + (swap! !state assoc-in (vec (conj update-at i :expanded?)) (not expanded?)))} + [:div.flex.items-center.justify-center.flex-shrink-0 + {:class "w-[20px] h-[20px] mr-[4px]"} + [:svg.transform.transition + {:viewBox "0 0 100 100" + :class (str (theme-class theme :triangle) " " + "w-[10px] h-[10px] " + (if expanded? "rotate-180" "rotate-90"))} + [:polygon {:points "5.9,88.2 50,11.8 94.1,88.2 " :fill "currentColor"}]]] + [:div label]] + [:a.flex + {:href path + :class (theme-class theme :item) + :on-click (fn [] + (when toc + (swap! !state assoc-in (vec (conj update-at i :loading?)) true) + (js/setTimeout + (fn [] + (swap! !state #(-> (assoc-in % (vec (conj update-at i :loading?)) false) + (assoc :toc toc)))) + 500)) + (when mobile? + (swap! !state assoc :visible? false)))} + [:div.flex.items-center.justify-center.flex-shrink-0 + {:class "w-[20px] h-[20px] mr-[4px]"} + (if loading? + [:svg.animate-spin.h-3.w-3.text-slate-500.dark:text-slate-400 + {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24"} + [:circle.opacity-25 {:cx "12" :cy "12" :r "10" :stroke "currentColor" :stroke-width "4"}] + [:path.opacity-75 {:fill "currentColor" :d "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"}]] + (if emoji + [:div emoji] + [:svg.h-4.w-4 + {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke "currentColor" + :class (theme-class theme :icon)} + [:path {:stroke-linecap "round" :stroke-linejoin "round" :stroke-width "2" :d "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"}]]))] + [:div + (if emoji + (subs label (count emoji)) + label)]]) + (when (and (seq items) expanded?) + [:div.ml-3 + [navbar-items !state items (vec (conj update-at i :items))]])])) + items)))) + +(defn navbar [!state] + (let [{:keys [items theme toc]} @!state] + [:div.relative.overflow-x-hidden.h-full + [:div.absolute.left-0.top-0.w-full.h-full.overflow-y-auto.transition.transform.pb-10 + {:class (str (theme-class theme :project) " " + (if toc "-translate-x-full" "translate-x-0"))} + [:div.px-3.mb-1 + {:class (theme-class theme :heading)} + "Project"] + [navbar-items !state (:items @!state) [:items]]] + [:div.absolute.left-0.top-0.w-full.h-full.overflow-y-auto.transition.transform + {:class (str (theme-class theme :toc) " " (if toc "translate-x-0" "translate-x-full"))} + (if (and (seq items) (seq toc)) + [:div.px-3.py-1.cursor-pointer + {:class (theme-class theme :back) + :on-click #(swap! !state dissoc :toc)} + "← Back to project"] + [:div.px-3.mb-1 + {:class (theme-class theme :heading)} + "TOC"]) + [toc-items !state toc (when (< (count toc) 2) {:class "font-medium"})]]])) + +(defn toggle-button [!state content & [opts]] + (let [{:keys [mobile? mobile-open? open?]} @!state] + [:div + (merge {:on-click #(swap! !state assoc + (if mobile? :mobile-open? :open?) (if mobile? (not mobile-open?) (not open?)) + :animation-mode (if mobile? :slide-over :push-in))} opts) + content])) + +(def spring {:type :spring :duration 0.35 :bounce 0.1}) + +(defn panel [!state content] + (r/with-let [{:keys [local-storage-key]} @!state + component-key (or local-storage-key (gensym)) + resize #(swap! !state assoc :mobile? (< js/innerWidth 640) :mobile-open? false) + ref-fn #(if % + (do + (when local-storage-key + (add-watch !state ::persist + (fn [_ _ old {:keys [open?]}] + (when (not= (:open? old) open?) + (ls/set-item! local-storage-key open?))))) + (js/addEventListener "resize" resize) + (resize)) + (js/removeEventListener "resize" resize))] + (let [{:keys [animating? animation-mode hide-toggle? open? mobile-open? mobile? mobile-width theme width]} @!state + slide-over-classes "fixed top-0 left-0 " + w (if mobile? mobile-width width)] + [:div.flex.h-screen + {:ref ref-fn} + [:> motion/animate-presence + {:initial false} + (when (and mobile? mobile-open?) + [:> motion/div + {:key (str component-key "-backdrop") + :class "fixed z-10 bg-gray-500 bg-opacity-75 left-0 top-0 bottom-0 right-0" + :initial {:opacity 0} + :animate {:opacity 1} + :exit {:opacity 0} + :on-click #(swap! !state assoc :mobile-open? false) + :transition spring}]) + (when (or mobile-open? (and (not mobile?) open?)) + [:> motion/div + {:key (str component-key "-nav") + :style {:width w} + :class (str "h-screen z-10 flex-shrink-0 fixed " + (theme-class theme :slide-over) " " + (when mobile? + (theme-class theme :slide-over-unpinned))) + :initial (if (= animation-mode :slide-over) {:x (* w -1)} {:margin-left (* w -1)}) + :animate (if (= animation-mode :slide-over) {:x 0} {:margin-left 0}) + :exit (if (= animation-mode :slide-over) {:x (* w -1)} {:margin-left (* w -1)}) + :transition spring + :on-animation-start #(swap! !state assoc :animating? true) + :on-animation-complete #(swap! !state assoc :animating? false)} + (when-not hide-toggle? + [toggle-button !state + (if mobile? + [:svg.h-5.w-5 {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"} + [:path {:stroke-linecap "round" :stroke-linejoin "round" :d "M6 18L18 6M6 6l12 12"}]] + [:svg.w-4.w-4 {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke "currentColor" :stroke-width "2"} + [:path {:stroke-linecap "round" :stroke-linejoin "round" :d "M15 19l-7-7 7-7"}]]) + {:class (theme-class theme :toggle)}]) + content])]]))) diff --git a/src/nextjournal/clerk/sci_env.cljs b/src/nextjournal/clerk/sci_env.cljs index deddbec75..c824b2808 100644 --- a/src/nextjournal/clerk/sci_env.cljs +++ b/src/nextjournal/clerk/sci_env.cljs @@ -1,6 +1,7 @@ (ns nextjournal.clerk.sci-env (:require ["@codemirror/view" :as codemirror-view] ["framer-motion" :as framer-motion] + ["vh-sticky-table-header" :refer [StickyTableHeader]] [cljs.reader] [clojure.string :as str] [edamame.core :as edamame] @@ -8,7 +9,7 @@ [nextjournal.clerk.parser] [nextjournal.clerk.render :as render] [nextjournal.clerk.render.code] - [nextjournal.clerk.render.hooks] + [nextjournal.clerk.render.hooks :as hooks] [nextjournal.clerk.trim-image] [nextjournal.clerk.viewer :as viewer] [nextjournal.view.context :as view-context] @@ -86,11 +87,25 @@ (def code-namespace (sci/copy-ns nextjournal.clerk.render.code (sci/create-ns 'nextjournal.clerk.render.code))) +(defn table-with-sticky-header [& children] + (let [!table-ref (hooks/use-ref nil) + !table-clone-ref (hooks/use-ref nil)] + (hooks/use-layout-effect (fn [] + (when (and @!table-ref (.querySelector @!table-ref "thead") @!table-clone-ref) + (let [sticky (StickyTableHeader. @!table-ref @!table-clone-ref #js{:max 0})] + (fn [] (.destroy sticky)))))) + [:div + [:div.overflow-x-auto.overflow-y-hidden.w-full + (into [:table.text-xs.sans-serif.text-gray-900.dark:text-white.not-prose {:ref !table-ref}] children)] + [:div.overflow-x-auto.overflow-y-hidden.w-full.shadow + [:table.text-xs.sans-serif.text-gray-900.dark:text-white.not-prose {:ref !table-clone-ref :style {:margin 0}}]]])) + (def initial-sci-opts {:async? true :disable-arity-checks true :classes {'js goog/global 'framer-motion framer-motion + 'table-with-sticky-header table-with-sticky-header :allow :all} :aliases {'j 'applied-science.js-interop 'reagent 'reagent.core diff --git a/src/nextjournal/clerk/static_app.cljs b/src/nextjournal/clerk/static_app.cljs index d4581a855..058a07d15 100644 --- a/src/nextjournal/clerk/static_app.cljs +++ b/src/nextjournal/clerk/static_app.cljs @@ -117,8 +117,8 @@ (let [{:keys [data path-params] :as match} @!match {:keys [view]} data view-data (merge @!state data path-params {:doc (get-in @!state [:path->doc (:path path-params "")])})] - [:div.flex.h-screen.bg-white.dark:bg-gray-900 - [:div.h-screen.overflow-y-auto.flex-auto.scroll-container + [:div.flex.min-h-screen.bg-white.dark:bg-gray-900 + [:div.flex-auto.w-screen.scroll-container (if view [view view-data] [:pre (pr-str match)])]])) diff --git a/src/nextjournal/clerk/view.clj b/src/nextjournal/clerk/view.clj index 5f8826f28..81555dd2c 100644 --- a/src/nextjournal/clerk/view.clj +++ b/src/nextjournal/clerk/view.clj @@ -44,7 +44,6 @@ (defn ->html [{:as state :keys [conn-ws?] :or {conn-ws? true}}] (hiccup/html5 - {:class "overflow-hidden min-h-screen"} [:head [:meta {:charset "UTF-8"}] [:meta {:name "viewport" :content "width=device-width, initial-scale=1"}] @@ -62,7 +61,6 @@ window.ws_send = msg => ws.send(msg)")]])) (defn ->static-app [{:as state :keys [current-path html]}] (hiccup/html5 - {:class "overflow-hidden min-h-screen"} [:head [:title (or (and current-path (-> state :path->doc (get current-path) v/->value :title)) "Clerk")] [:meta {:charset "UTF-8"}] diff --git a/src/nextjournal/clerk/viewer.cljc b/src/nextjournal/clerk/viewer.cljc index d07cc0d78..11affcf3d 100644 --- a/src/nextjournal/clerk/viewer.cljc +++ b/src/nextjournal/clerk/viewer.cljc @@ -528,17 +528,18 @@ (def table-markup-viewer {:name :table/markup :render-fn '(fn [head+body opts] - [:div.overflow-x-auto (into [:table.text-xs.sans-serif.text-gray-900.dark:text-white.not-prose] (nextjournal.clerk.render/inspect-children opts) head+body)])}) + [:div + (into [table-with-sticky-header] (nextjournal.clerk.render/inspect-children opts) head+body)])}) (def table-head-viewer {:name :table/head :render-fn '(fn [header-row {:as opts :keys [path number-col?]}] - [:thead.border-b.border-gray-300.dark:border-slate-700 + [:thead (into [:tr] (map-indexed (fn [i {:as header-cell :nextjournal/keys [value]}] (let [title (when (or (string? value) (keyword? value) (symbol? value)) value)] - [:th.relative.pl-6.pr-2.py-1.align-bottom.font-medium + [:th.pl-6.pr-2.py-1.align-bottom.font-medium.top-0.z-10.bg-white.dark:bg-slate-900.border-b.border-gray-300.dark:border-slate-700 (cond-> {:class (when (and (ifn? number-col?) (number-col? i)) "text-right")} title (assoc :title title)) [:div.flex.items-center (nextjournal.clerk.render/inspect-presented opts header-cell)]]))) header-row)])}) @@ -562,14 +563,16 @@ :fetch-fn (fn [fetch-fn] [:tr.border-t.dark:border-slate-700 - [:td.text-center.py-1 + [:td.py-1.relative {:col-span num-cols :class (if (fn? fetch-fn) "bg-indigo-50 hover:bg-indigo-100 dark:bg-gray-800 dark:hover:bg-slate-700 cursor-pointer" "text-gray-400 text-slate-500") :on-click (fn [_] (when (fn? fetch-fn) - (fetch-fn fetch-opts)))} - (- total offset) (when unbounded? "+") (if (fn? fetch-fn) " more…" " more elided")]])]))}) + (fetch-fn fetch-opts)))} + [:span.sticky + {:style {:left "min(50vw, 50%)"} :class "-translate-x-1/2"} + (- total offset) (when unbounded? "+") (if (fn? fetch-fn) " more…" " more elided")]]])]))}) (add-viewers [table-missing-viewer table-markup-viewer table-head-viewer