Skip to content

Commit

Permalink
🪡 Sticky Table Headers (#305)
Browse files Browse the repository at this point in the history
* Keep table headers in view when scrolling

* Keep elision buttons in view when scrolling

Co-authored-by: Martin Kavalar <martin@nextjournal.com>
  • Loading branch information
philippamarkovics and mk authored Dec 13, 2022
1 parent 5bb69d0 commit 0bf2437
Show file tree
Hide file tree
Showing 8 changed files with 265 additions and 21 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion resources/viewer-js-hash
Original file line number Diff line number Diff line change
@@ -1 +1 @@
4Wzh8dpMtSUFXJ2UmYNFZqSZ5JFU
3g3hotfUsaHvvKuPkMjwmjSbbLAA
27 changes: 18 additions & 9 deletions src/nextjournal/clerk/render.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
218 changes: 218 additions & 0 deletions src/nextjournal/clerk/render/navbar.cljs
Original file line number Diff line number Diff line change
@@ -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])]])))
17 changes: 16 additions & 1 deletion src/nextjournal/clerk/sci_env.cljs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
(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]
[goog.object]
[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]
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/nextjournal/clerk/static_app.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -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)])]]))
Expand Down
2 changes: 0 additions & 2 deletions src/nextjournal/clerk/view.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]
Expand All @@ -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"}]
Expand Down
Loading

0 comments on commit 0bf2437

Please sign in to comment.