From 6d5491fdcbccfad7af6c6dcc63ce2f67abd3850c Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Sat, 17 Jun 2023 12:07:40 -0700 Subject: [PATCH] collapsible toc --- index.d.ts | 2 +- quartz/components/Body.tsx | 4 +- quartz/components/Content.tsx | 2 +- quartz/components/TableOfContents.tsx | 80 +++++++++---------- quartz/components/scripts/clipboard.inline.ts | 50 ++++++------ quartz/components/scripts/spa.inline.ts | 4 +- quartz/components/scripts/toc.inline.ts | 35 ++++++++ quartz/components/styles/legacyToc.scss | 2 +- quartz/components/styles/toc.scss | 49 ++++++++---- quartz/plugins/emitters/contentPage.tsx | 2 +- quartz/plugins/index.ts | 13 ++- quartz/plugins/transformers/latex.ts | 28 ++++--- quartz/plugins/transformers/ofm.ts | 17 ++-- quartz/plugins/types.ts | 2 +- 14 files changed, 176 insertions(+), 114 deletions(-) create mode 100644 quartz/components/scripts/toc.inline.ts diff --git a/index.d.ts b/index.d.ts index ec4d32aad..26ca700cb 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,7 +5,7 @@ declare module '*.scss' { // dom custom event interface CustomEventMap { - "spa_nav": CustomEvent<{ url: string }>; + "nav": CustomEvent<{ url: string }>; } declare global { diff --git a/quartz/components/Body.tsx b/quartz/components/Body.tsx index f10cf3acb..6b1f234d1 100644 --- a/quartz/components/Body.tsx +++ b/quartz/components/Body.tsx @@ -4,9 +4,9 @@ import clipboardStyle from './styles/clipboard.scss' import { QuartzComponentConstructor, QuartzComponentProps } from "./types" function Body({ children }: QuartzComponentProps) { - return
+ return
{children} -
+ } Body.afterDOMLoaded = clipboardScript diff --git a/quartz/components/Content.tsx b/quartz/components/Content.tsx index 71d0f35d7..cc5d66aa6 100644 --- a/quartz/components/Content.tsx +++ b/quartz/components/Content.tsx @@ -5,7 +5,7 @@ import { toJsxRuntime } from "hast-util-to-jsx-runtime" function Content({ tree }: QuartzComponentProps) { // @ts-ignore (preact makes it angry) const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' }) - return content + return
{content}
} export default (() => Content) satisfies QuartzComponentConstructor diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx index afb838873..99e73e9c5 100644 --- a/quartz/components/TableOfContents.tsx +++ b/quartz/components/TableOfContents.tsx @@ -2,6 +2,9 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import legacyStyle from "./styles/legacyToc.scss" import modernStyle from "./styles/toc.scss" +// @ts-ignore +import script from "./scripts/toc.inline" + interface Options { layout: 'modern' | 'legacy' } @@ -10,56 +13,49 @@ const defaultOptions: Options = { layout: 'modern' } -export default ((opts?: Partial) => { - const layout = opts?.layout ?? defaultOptions.layout - function TableOfContents({ fileData }: QuartzComponentProps) { - if (!fileData.toc) { - return null - } +function TableOfContents({ fileData }: QuartzComponentProps) { + if (!fileData.toc) { + return null + } - return
-

Table of Contents

+ return <> + +
-
- } - - TableOfContents.css = layout === "modern" ? modernStyle : legacyStyle - - if (layout === "modern") { - TableOfContents.afterDOMLoaded = ` -const bufferPx = 150 -const observer = new IntersectionObserver(entries => { - for (const entry of entries) { - const slug = entry.target.id - const tocEntryElement = document.querySelector(\`a[data-for="$\{slug\}"]\`) - const windowHeight = entry.rootBounds?.height - if (windowHeight && tocEntryElement) { - if (entry.boundingClientRect.y < windowHeight) { - tocEntryElement.classList.add("in-view") - } else { - tocEntryElement.classList.remove("in-view") - } - } - } -}) - -function init() { - const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") - headers.forEach(header => observer.observe(header)) + + } +TableOfContents.css = modernStyle +TableOfContents.afterDOMLoaded = script -init() - -document.addEventListener("spa_nav", (e) => { - observer.disconnect() - init() -}) -` +function LegacyTableOfContents({ fileData }: QuartzComponentProps) { + if (!fileData.toc) { + return null } - return TableOfContents + return
+ +

Table of Contents

+
+ +
+} +LegacyTableOfContents.css = legacyStyle + +export default ((opts?: Partial) => { + const layout = opts?.layout ?? defaultOptions.layout + return layout === "modern" ? TableOfContents : LegacyTableOfContents }) satisfies QuartzComponentConstructor diff --git a/quartz/components/scripts/clipboard.inline.ts b/quartz/components/scripts/clipboard.inline.ts index 8d0758a78..76d1b589d 100644 --- a/quartz/components/scripts/clipboard.inline.ts +++ b/quartz/components/scripts/clipboard.inline.ts @@ -3,27 +3,29 @@ const svgCopy = const svgCheck = '' -const els = document.getElementsByTagName("pre") -for (let i = 0; i < els.length; i++) { - const codeBlock = els[i].getElementsByTagName("code")[0] - const source = codeBlock.innerText.replace(/\n\n/g, "\n") - const button = document.createElement("button") - button.className = "clipboard-button" - button.type = "button" - button.innerHTML = svgCopy - button.ariaLabel = "Copy source" - button.addEventListener("click", () => { - navigator.clipboard.writeText(source).then( - () => { - button.blur() - button.innerHTML = svgCheck - setTimeout(() => { - button.innerHTML = svgCopy - button.style.borderColor = "" - }, 2000) - }, - (error) => console.error(error), - ) - }) - els[i].prepend(button) -} +document.addEventListener("nav", () => { + const els = document.getElementsByTagName("pre") + for (let i = 0; i < els.length; i++) { + const codeBlock = els[i].getElementsByTagName("code")[0] + const source = codeBlock.innerText.replace(/\n\n/g, "\n") + const button = document.createElement("button") + button.className = "clipboard-button" + button.type = "button" + button.innerHTML = svgCopy + button.ariaLabel = "Copy source" + button.addEventListener("click", () => { + navigator.clipboard.writeText(source).then( + () => { + button.blur() + button.innerHTML = svgCheck + setTimeout(() => { + button.innerHTML = svgCopy + button.style.borderColor = "" + }, 2000) + }, + (error) => console.error(error), + ) + }) + els[i].prepend(button) + } +}) diff --git a/quartz/components/scripts/spa.inline.ts b/quartz/components/scripts/spa.inline.ts index da347009a..a129dc417 100644 --- a/quartz/components/scripts/spa.inline.ts +++ b/quartz/components/scripts/spa.inline.ts @@ -30,7 +30,7 @@ const getOpts = ({ target }: Event): { url: URL, scroll?: boolean } | undefined } function notifyNav(slug: string) { - const event = new CustomEvent("spa_nav", { detail: { slug } }) + const event = new CustomEvent("nav", { detail: { slug } }) document.dispatchEvent(event) } @@ -96,6 +96,7 @@ function createRouter() { return }) } + return new class Router { go(pathname: string) { const url = new URL(pathname, window.location.toString()) @@ -113,6 +114,7 @@ function createRouter() { } createRouter() +notifyNav(document.body.dataset.slug!) if (!customElements.get('route-announcer')) { const attrs = { diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts new file mode 100644 index 000000000..405a21f14 --- /dev/null +++ b/quartz/components/scripts/toc.inline.ts @@ -0,0 +1,35 @@ +const bufferPx = 150 +const observer = new IntersectionObserver(entries => { + for (const entry of entries) { + const slug = entry.target.id + const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`) + const windowHeight = entry.rootBounds?.height + if (windowHeight && tocEntryElement) { + if (entry.boundingClientRect.y < windowHeight) { + tocEntryElement.classList.add("in-view") + } else { + tocEntryElement.classList.remove("in-view") + } + } + } +}) + +function toggleCollapsible(this: HTMLElement) { + this.classList.toggle("collapsed") + const content = this.nextElementSibling as HTMLElement + content.classList.toggle("collapsed") + content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px" +} + +document.addEventListener("nav", () => { + const toc = document.getElementById("toc")! + const content = toc.nextElementSibling as HTMLElement + content.style.maxHeight = content.scrollHeight + "px" + toc.removeEventListener("click", toggleCollapsible) + toc.addEventListener("click", toggleCollapsible) + + // update toc entry highlighting + observer.disconnect() + const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") + headers.forEach(header => observer.observe(header)) +}) diff --git a/quartz/components/styles/legacyToc.scss b/quartz/components/styles/legacyToc.scss index 33b9cca30..8889dcce3 100644 --- a/quartz/components/styles/legacyToc.scss +++ b/quartz/components/styles/legacyToc.scss @@ -1,4 +1,4 @@ -details.toc { +details#toc { & summary { cursor: pointer; diff --git a/quartz/components/styles/toc.scss b/quartz/components/styles/toc.scss index 3003f40f0..1f1a27b7f 100644 --- a/quartz/components/styles/toc.scss +++ b/quartz/components/styles/toc.scss @@ -1,22 +1,36 @@ -details.toc { - & summary { - cursor: pointer; +button#toc { + background-color: transparent; + border: none; + text-align: left; + cursor: pointer; + padding: 0; + color: var(--dark); + display: flex; + align-items: center; - list-style: none; - &::marker, &::-webkit-details-marker { - display: none; - } - - & > * { - display: inline-block; - margin: 0; - } - - & > h3 { - font-size: 1rem; - } + & h3 { + font-size: 1rem; + display: inline-block; + margin: 0; } - + + & .fold { + margin-left: 0.5rem; + transition: transform 0.3s ease; + opacity: 0.8; + } + + &.collapsed .fold { + transform: rotateZ(-90deg) + } +} + +#toc-content { + list-style: none; + overflow: hidden; + max-height: none; + transition: max-height 0.3s ease; + & ul { list-style: none; margin: 0.5rem 0; @@ -37,3 +51,4 @@ details.toc { } } } + diff --git a/quartz/plugins/emitters/contentPage.tsx b/quartz/plugins/emitters/contentPage.tsx index b6ded54e9..7afab9d3b 100644 --- a/quartz/plugins/emitters/contentPage.tsx +++ b/quartz/plugins/emitters/contentPage.tsx @@ -28,7 +28,7 @@ export const ContentPage: QuartzEmitterPlugin = (opts) => { return { name: "ContentPage", getQuartzComponents() { - return [opts.head, Header, ...opts.header, ...opts.body] + return [opts.head, Header, Body, ...opts.header, ...opts.body, ...opts.left, ...opts.right, ...opts.footer] }, async emit(_contentDir, cfg, content, resources, emit): Promise { const fps: string[] = [] diff --git a/quartz/plugins/index.ts b/quartz/plugins/index.ts index 32f8bc7e2..04de0d4c4 100644 --- a/quartz/plugins/index.ts +++ b/quartz/plugins/index.ts @@ -33,10 +33,6 @@ export function emitComponentResources(cfg: GlobalConfiguration, resources: Stat afterDOMLoaded: [] } - if (cfg.enableSPA) { - componentResources.afterDOMLoaded.push(spaRouterScript) - } - for (const component of allComponents) { const { css, beforeDOMLoaded, afterDOMLoaded } = component if (css) { @@ -50,6 +46,15 @@ export function emitComponentResources(cfg: GlobalConfiguration, resources: Stat } } + if (cfg.enableSPA) { + componentResources.afterDOMLoaded.push(spaRouterScript) + } else { + componentResources.afterDOMLoaded.push(` + const event = new CustomEvent("nav", { detail: { slug: document.body.dataset.slug } }) + document.dispatchEvent(event)` + ) + } + emit({ slug: "index", ext: ".css", diff --git a/quartz/plugins/transformers/latex.ts b/quartz/plugins/transformers/latex.ts index 3140ab47e..73bd07dfe 100644 --- a/quartz/plugins/transformers/latex.ts +++ b/quartz/plugins/transformers/latex.ts @@ -14,18 +14,20 @@ export const Katex: QuartzTransformerPlugin = () => ({ }] ] }, - externalResources: { - css: [ - // base css - "https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css", - ], - js: [ - { - // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md - src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js", - loadTime: "afterDOMReady", - contentType: 'external' - } - ] + externalResources() { + return { + css: [ + // base css + "https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css", + ], + js: [ + { + // fix copy behaviour: https://github.com/KaTeX/KaTeX/blob/main/contrib/copy-tex/README.md + src: "https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/copy-tex.min.js", + loadTime: "afterDOMReady", + contentType: 'external' + } + ] + } } }) diff --git a/quartz/plugins/transformers/ofm.ts b/quartz/plugins/transformers/ofm.ts index 1b4e07a52..aa8395360 100644 --- a/quartz/plugins/transformers/ofm.ts +++ b/quartz/plugins/transformers/ofm.ts @@ -6,6 +6,7 @@ import { slugify } from "../../path" import rehypeRaw from "rehype-raw" import { visit } from "unist-util-visit" import path from "path" +import { JSResource } from "../../resources" export interface Options { highlight: boolean @@ -235,6 +236,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin node.children.splice(0, 1, ...blockquoteContent) // add properties to base blockquote + // TODO: add the js to actually support collapsing callout node.data = { hProperties: { ...(node.data?.hProperties ?? {}), @@ -270,16 +272,19 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin htmlPlugins() { return [rehypeRaw] }, - externalResources: { - js: [{ + externalResources() { + const mermaidScript: JSResource = { script: ` -import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs'; -mermaid.initialize({ startOnLoad: true }); - `, + import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs'; + mermaid.initialize({ startOnLoad: true }); + `, loadTime: 'afterDOMReady', moduleType: 'module', contentType: 'inline' - }] + } + return { + js: opts.mermaid ? [mermaidScript] : [] + } } } } diff --git a/quartz/plugins/types.ts b/quartz/plugins/types.ts index c67e41da1..444fcffc0 100644 --- a/quartz/plugins/types.ts +++ b/quartz/plugins/types.ts @@ -16,7 +16,7 @@ export type QuartzTransformerPluginInstance = { name: string markdownPlugins(): PluggableList htmlPlugins(): PluggableList - externalResources?: Partial + externalResources?(): Partial } export type QuartzFilterPlugin = (opts?: Options) => QuartzFilterPluginInstance