collapsible toc

This commit is contained in:
Jacky Zhao 2023-06-17 12:07:40 -07:00
parent 95cb3efe33
commit 73dfcab5eb
14 changed files with 176 additions and 114 deletions

2
index.d.ts vendored
View File

@ -5,7 +5,7 @@ declare module '*.scss' {
// dom custom event // dom custom event
interface CustomEventMap { interface CustomEventMap {
"spa_nav": CustomEvent<{ url: string }>; "nav": CustomEvent<{ url: string }>;
} }
declare global { declare global {

View File

@ -4,9 +4,9 @@ import clipboardStyle from './styles/clipboard.scss'
import { QuartzComponentConstructor, QuartzComponentProps } from "./types" import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function Body({ children }: QuartzComponentProps) { function Body({ children }: QuartzComponentProps) {
return <article> return <div id="quartz-body">
{children} {children}
</article> </div>
} }
Body.afterDOMLoaded = clipboardScript Body.afterDOMLoaded = clipboardScript

View File

@ -5,7 +5,7 @@ import { toJsxRuntime } from "hast-util-to-jsx-runtime"
function Content({ tree }: QuartzComponentProps) { function Content({ tree }: QuartzComponentProps) {
// @ts-ignore (preact makes it angry) // @ts-ignore (preact makes it angry)
const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' }) const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
return content return <article>{content}</article>
} }
export default (() => Content) satisfies QuartzComponentConstructor export default (() => Content) satisfies QuartzComponentConstructor

View File

@ -2,6 +2,9 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import legacyStyle from "./styles/legacyToc.scss" import legacyStyle from "./styles/legacyToc.scss"
import modernStyle from "./styles/toc.scss" import modernStyle from "./styles/toc.scss"
// @ts-ignore
import script from "./scripts/toc.inline"
interface Options { interface Options {
layout: 'modern' | 'legacy' layout: 'modern' | 'legacy'
} }
@ -10,56 +13,49 @@ const defaultOptions: Options = {
layout: 'modern' layout: 'modern'
} }
export default ((opts?: Partial<Options>) => { function TableOfContents({ fileData }: QuartzComponentProps) {
const layout = opts?.layout ?? defaultOptions.layout
function TableOfContents({ fileData }: QuartzComponentProps) {
if (!fileData.toc) { if (!fileData.toc) {
return null return null
} }
return <details class="toc" open> return <>
<summary><h3>Table of Contents</h3></summary> <button type="button" id="toc">
<h3>Table of Contents</h3>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="fold">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div id="toc-content">
<ul>
{fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>{tocEntry.text}</a>
</li>)}
</ul>
</div>
</>
}
TableOfContents.css = modernStyle
TableOfContents.afterDOMLoaded = script
function LegacyTableOfContents({ fileData }: QuartzComponentProps) {
if (!fileData.toc) {
return null
}
return <details id="toc" open>
<summary>
<h3>Table of Contents</h3>
</summary>
<ul> <ul>
{fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}> {fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>{tocEntry.text}</a> <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>{tocEntry.text}</a>
</li>)} </li>)}
</ul> </ul>
</details> </details>
}
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))
} }
LegacyTableOfContents.css = legacyStyle
init() export default ((opts?: Partial<Options>) => {
const layout = opts?.layout ?? defaultOptions.layout
document.addEventListener("spa_nav", (e) => { return layout === "modern" ? TableOfContents : LegacyTableOfContents
observer.disconnect()
init()
})
`
}
return TableOfContents
}) satisfies QuartzComponentConstructor }) satisfies QuartzComponentConstructor

View File

@ -3,8 +3,9 @@ const svgCopy =
const svgCheck = const svgCheck =
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>' '<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>'
const els = document.getElementsByTagName("pre") document.addEventListener("nav", () => {
for (let i = 0; i < els.length; i++) { const els = document.getElementsByTagName("pre")
for (let i = 0; i < els.length; i++) {
const codeBlock = els[i].getElementsByTagName("code")[0] const codeBlock = els[i].getElementsByTagName("code")[0]
const source = codeBlock.innerText.replace(/\n\n/g, "\n") const source = codeBlock.innerText.replace(/\n\n/g, "\n")
const button = document.createElement("button") const button = document.createElement("button")
@ -26,4 +27,5 @@ for (let i = 0; i < els.length; i++) {
) )
}) })
els[i].prepend(button) els[i].prepend(button)
} }
})

View File

@ -30,7 +30,7 @@ const getOpts = ({ target }: Event): { url: URL, scroll?: boolean } | undefined
} }
function notifyNav(slug: string) { function notifyNav(slug: string) {
const event = new CustomEvent("spa_nav", { detail: { slug } }) const event = new CustomEvent("nav", { detail: { slug } })
document.dispatchEvent(event) document.dispatchEvent(event)
} }
@ -96,6 +96,7 @@ function createRouter() {
return return
}) })
} }
return new class Router { return new class Router {
go(pathname: string) { go(pathname: string) {
const url = new URL(pathname, window.location.toString()) const url = new URL(pathname, window.location.toString())
@ -113,6 +114,7 @@ function createRouter() {
} }
createRouter() createRouter()
notifyNav(document.body.dataset.slug!)
if (!customElements.get('route-announcer')) { if (!customElements.get('route-announcer')) {
const attrs = { const attrs = {

View File

@ -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))
})

View File

@ -1,4 +1,4 @@
details.toc { details#toc {
& summary { & summary {
cursor: pointer; cursor: pointer;

View File

@ -1,21 +1,35 @@
details.toc { button#toc {
& summary { background-color: transparent;
border: none;
text-align: left;
cursor: pointer; cursor: pointer;
padding: 0;
color: var(--dark);
display: flex;
align-items: center;
list-style: none; & h3 {
&::marker, &::-webkit-details-marker { font-size: 1rem;
display: none;
}
& > * {
display: inline-block; display: inline-block;
margin: 0; margin: 0;
} }
& > h3 { & .fold {
font-size: 1rem; 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 { & ul {
list-style: none; list-style: none;
@ -37,3 +51,4 @@ details.toc {
} }
} }
} }

View File

@ -28,7 +28,7 @@ export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => {
return { return {
name: "ContentPage", name: "ContentPage",
getQuartzComponents() { 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<string[]> { async emit(_contentDir, cfg, content, resources, emit): Promise<string[]> {
const fps: string[] = [] const fps: string[] = []

View File

@ -33,10 +33,6 @@ export function emitComponentResources(cfg: GlobalConfiguration, resources: Stat
afterDOMLoaded: [] afterDOMLoaded: []
} }
if (cfg.enableSPA) {
componentResources.afterDOMLoaded.push(spaRouterScript)
}
for (const component of allComponents) { for (const component of allComponents) {
const { css, beforeDOMLoaded, afterDOMLoaded } = component const { css, beforeDOMLoaded, afterDOMLoaded } = component
if (css) { 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({ emit({
slug: "index", slug: "index",
ext: ".css", ext: ".css",

View File

@ -14,7 +14,8 @@ export const Katex: QuartzTransformerPlugin = () => ({
}] }]
] ]
}, },
externalResources: { externalResources() {
return {
css: [ css: [
// base css // base css
"https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css", "https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
@ -28,4 +29,5 @@ export const Katex: QuartzTransformerPlugin = () => ({
} }
] ]
} }
}
}) })

View File

@ -6,6 +6,7 @@ import { slugify } from "../../path"
import rehypeRaw from "rehype-raw" import rehypeRaw from "rehype-raw"
import { visit } from "unist-util-visit" import { visit } from "unist-util-visit"
import path from "path" import path from "path"
import { JSResource } from "../../resources"
export interface Options { export interface Options {
highlight: boolean highlight: boolean
@ -235,6 +236,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
node.children.splice(0, 1, ...blockquoteContent) node.children.splice(0, 1, ...blockquoteContent)
// add properties to base blockquote // add properties to base blockquote
// TODO: add the js to actually support collapsing callout
node.data = { node.data = {
hProperties: { hProperties: {
...(node.data?.hProperties ?? {}), ...(node.data?.hProperties ?? {}),
@ -270,16 +272,19 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
htmlPlugins() { htmlPlugins() {
return [rehypeRaw] return [rehypeRaw]
}, },
externalResources: { externalResources() {
js: [{ const mermaidScript: JSResource = {
script: ` script: `
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs'; import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true }); mermaid.initialize({ startOnLoad: true });
`, `,
loadTime: 'afterDOMReady', loadTime: 'afterDOMReady',
moduleType: 'module', moduleType: 'module',
contentType: 'inline' contentType: 'inline'
}] }
return {
js: opts.mermaid ? [mermaidScript] : []
}
} }
} }
} }

View File

@ -16,7 +16,7 @@ export type QuartzTransformerPluginInstance = {
name: string name: string
markdownPlugins(): PluggableList markdownPlugins(): PluggableList
htmlPlugins(): PluggableList htmlPlugins(): PluggableList
externalResources?: Partial<StaticResources> externalResources?(): Partial<StaticResources>
} }
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzFilterPluginInstance export type QuartzFilterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzFilterPluginInstance