Jacky Zhao
2023-06-17 6d5491fdcbccfad7af6c6dcc63ce2f67abd3850c
collapsible toc
1 files added
13 files modified
192 ■■■■■ changed files
index.d.ts 2 ●●● patch | view | raw | blame | history
quartz/components/Body.tsx 4 ●●●● patch | view | raw | blame | history
quartz/components/Content.tsx 2 ●●● patch | view | raw | blame | history
quartz/components/TableOfContents.tsx 74 ●●●● patch | view | raw | blame | history
quartz/components/scripts/clipboard.inline.ts 2 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/spa.inline.ts 4 ●●● patch | view | raw | blame | history
quartz/components/scripts/toc.inline.ts 35 ●●●●● patch | view | raw | blame | history
quartz/components/styles/legacyToc.scss 2 ●●● patch | view | raw | blame | history
quartz/components/styles/toc.scss 35 ●●●● patch | view | raw | blame | history
quartz/plugins/emitters/contentPage.tsx 2 ●●● patch | view | raw | blame | history
quartz/plugins/index.ts 13 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/latex.ts 4 ●●● patch | view | raw | blame | history
quartz/plugins/transformers/ofm.ts 11 ●●●● patch | view | raw | blame | history
quartz/plugins/types.ts 2 ●●● patch | view | raw | blame | history
index.d.ts
@@ -5,7 +5,7 @@
// dom custom event
interface CustomEventMap {
  "spa_nav": CustomEvent<{ url: string }>;
  "nav": CustomEvent<{ url: string }>;
}
declare global {
quartz/components/Body.tsx
@@ -4,9 +4,9 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
function Body({ children }: QuartzComponentProps) {
  return <article>
  return <div id="quartz-body">
    {children}
  </article>
  </div>
}
Body.afterDOMLoaded = clipboardScript
quartz/components/Content.tsx
@@ -5,7 +5,7 @@
function Content({ tree }: QuartzComponentProps) {
  // @ts-ignore (preact makes it angry)
  const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
  return content
  return <article>{content}</article>
}
export default (() => Content) satisfies QuartzComponentConstructor
quartz/components/TableOfContents.tsx
@@ -2,6 +2,9 @@
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,15 +13,39 @@
  layout: 'modern'
}
export default ((opts?: Partial<Options>) => {
  const layout = opts?.layout ?? defaultOptions.layout
  function TableOfContents({ fileData }: QuartzComponentProps) {
    if (!fileData.toc) {
      return null
    }
    return <details class="toc" open>
      <summary><h3>Table of Contents</h3></summary>
  return <>
    <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>
        {fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
          <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>{tocEntry.text}</a>
@@ -26,40 +53,9 @@
      </ul>
    </details>
  }
LegacyTableOfContents.css = legacyStyle
  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))
}
init()
document.addEventListener("spa_nav", (e) => {
  observer.disconnect()
  init()
})
`
  }
  return TableOfContents
export default ((opts?: Partial<Options>) => {
  const layout = opts?.layout ?? defaultOptions.layout
  return layout === "modern" ? TableOfContents : LegacyTableOfContents
}) satisfies QuartzComponentConstructor
quartz/components/scripts/clipboard.inline.ts
@@ -3,6 +3,7 @@
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>'
document.addEventListener("nav", () => {
const els = document.getElementsByTagName("pre")
for (let i = 0; i < els.length; i++) {
  const codeBlock = els[i].getElementsByTagName("code")[0]
@@ -27,3 +28,4 @@
  })
  els[i].prepend(button)
}
})
quartz/components/scripts/spa.inline.ts
@@ -30,7 +30,7 @@
}
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 @@
      return
    })
  }
  return new class Router {
    go(pathname: string) {
      const url = new URL(pathname, window.location.toString())
@@ -113,6 +114,7 @@
}
createRouter()
notifyNav(document.body.dataset.slug!)
if (!customElements.get('route-announcer')) {
  const attrs = {
quartz/components/scripts/toc.inline.ts
New 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))
})
quartz/components/styles/legacyToc.scss
@@ -1,4 +1,4 @@
details.toc {
details#toc {
  & summary {
    cursor: pointer;
quartz/components/styles/toc.scss
@@ -1,22 +1,36 @@
details.toc {
  & summary {
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;
    }
    & > * {
  & h3 {
    font-size: 1rem;
      display: inline-block;
      margin: 0;
    }
    & > h3 {
      font-size: 1rem;
  & .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 @@
    }
  }
}
quartz/plugins/emitters/contentPage.tsx
@@ -28,7 +28,7 @@
  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<string[]> {
      const fps: string[] = []
quartz/plugins/index.ts
@@ -33,10 +33,6 @@
    afterDOMLoaded: []
  }
  if (cfg.enableSPA) {
    componentResources.afterDOMLoaded.push(spaRouterScript)
  }
  for (const component of allComponents) {
    const { css, beforeDOMLoaded, afterDOMLoaded } = component
    if (css) {
@@ -50,6 +46,15 @@
    }
  }
  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",
quartz/plugins/transformers/latex.ts
@@ -14,7 +14,8 @@
      }]
    ]
  },
  externalResources: {
  externalResources() {
    return {
    css: [
      // base css
      "https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
@@ -28,4 +29,5 @@
      }
    ]
  }
  }
})
quartz/plugins/transformers/ofm.ts
@@ -6,6 +6,7 @@
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 @@
                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,8 +272,8 @@
    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 });
@@ -279,7 +281,10 @@
        loadTime: 'afterDOMReady',
        moduleType: 'module',
        contentType: 'inline'
      }]
      }
      return {
        js: opts.mermaid ? [mermaidScript] : []
      }
    }
  }
}
quartz/plugins/types.ts
@@ -16,7 +16,7 @@
  name: string
  markdownPlugins(): PluggableList
  htmlPlugins(): PluggableList
  externalResources?: Partial<StaticResources>
  externalResources?(): Partial<StaticResources>
}
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzFilterPluginInstance