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

🪡 Sticky Table Headers #305

Merged
merged 6 commits into from
Dec 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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