Jacky Zhao
2025-03-10 a8001e9554a319782d8557acb8f19358996b5828
feat: support non-singleton explorer
1 files added
14 files modified
314 ■■■■ changed files
quartz/build.ts 11 ●●●●● patch | view | raw | blame | history
quartz/components/Backlinks.tsx 7 ●●●●● patch | view | raw | blame | history
quartz/components/Explorer.tsx 16 ●●●● patch | view | raw | blame | history
quartz/components/OverflowList.tsx 19 ●●●● patch | view | raw | blame | history
quartz/components/TableOfContents.tsx 133 ●●●● patch | view | raw | blame | history
quartz/components/pages/FolderContent.tsx 3 ●●●● patch | view | raw | blame | history
quartz/components/pages/TagContent.tsx 3 ●●●● patch | view | raw | blame | history
quartz/components/scripts/explorer.inline.ts 46 ●●●● patch | view | raw | blame | history
quartz/components/styles/explorer.scss 26 ●●●● patch | view | raw | blame | history
quartz/components/styles/toc.scss 1 ●●●● patch | view | raw | blame | history
quartz/components/types.ts 8 ●●●● patch | view | raw | blame | history
quartz/plugins/emitters/componentResources.ts 22 ●●●●● patch | view | raw | blame | history
quartz/styles/base.scss 9 ●●●●● patch | view | raw | blame | history
quartz/util/random.ts 3 ●●●●● patch | view | raw | blame | history
quartz/util/resources.tsx 7 ●●●●● patch | view | raw | blame | history
quartz/build.ts
@@ -19,6 +19,7 @@
import { Mutex } from "async-mutex"
import DepGraph from "./depgraph"
import { getStaticResourcesFromPlugins } from "./plugins"
import { randomIdNonSecure } from "./util/random"
type Dependencies = Record<string, DepGraph<FilePath> | null>
@@ -38,13 +39,9 @@
type FileEvent = "add" | "change" | "delete"
function newBuildId() {
  return Math.random().toString(36).substring(2, 8)
}
async function buildQuartz(argv: Argv, mut: Mutex, clientRefresh: () => void) {
  const ctx: BuildCtx = {
    buildId: newBuildId(),
    buildId: randomIdNonSecure(),
    argv,
    cfg,
    allSlugs: [],
@@ -162,7 +159,7 @@
    return
  }
  const buildId = newBuildId()
  const buildId = randomIdNonSecure()
  ctx.buildId = buildId
  buildData.lastBuildMs = new Date().getTime()
  const release = await mut.acquire()
@@ -359,7 +356,7 @@
    toRemove.add(filePath)
  }
  const buildId = newBuildId()
  const buildId = randomIdNonSecure()
  ctx.buildId = buildId
  buildData.lastBuildMs = new Date().getTime()
  const release = await mut.acquire()
quartz/components/Backlinks.tsx
@@ -3,7 +3,7 @@
import { resolveRelative, simplifySlug } from "../util/path"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
import OverflowList from "./OverflowList"
import OverflowListFactory from "./OverflowList"
interface BacklinksOptions {
  hideWhenEmpty: boolean
@@ -15,6 +15,7 @@
export default ((opts?: Partial<BacklinksOptions>) => {
  const options: BacklinksOptions = { ...defaultOptions, ...opts }
  const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
  const Backlinks: QuartzComponent = ({
    fileData,
@@ -30,7 +31,7 @@
    return (
      <div class={classNames(displayClass, "backlinks")}>
        <h3>{i18n(cfg.locale).components.backlinks.title}</h3>
        <OverflowList id="backlinks-ul">
        <OverflowList>
          {backlinkFiles.length > 0 ? (
            backlinkFiles.map((f) => (
              <li>
@@ -48,7 +49,7 @@
  }
  Backlinks.css = style
  Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul")
  Backlinks.afterDOMLoaded = overflowListAfterDOMLoaded
  return Backlinks
}) satisfies QuartzComponentConstructor
quartz/components/Explorer.tsx
@@ -6,7 +6,8 @@
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
import { FileTrieNode } from "../util/fileTrie"
import OverflowList from "./OverflowList"
import OverflowListFactory from "./OverflowList"
import { concatenateResources } from "../util/resources"
type OrderEntries = "sort" | "filter" | "map"
@@ -56,6 +57,7 @@
export default ((userOpts?: Partial<Options>) => {
  const opts: Options = { ...defaultOptions, ...userOpts }
  const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
  const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => {
    return (
@@ -73,8 +75,7 @@
      >
        <button
          type="button"
          id="mobile-explorer"
          class="explorer-toggle hide-until-loaded"
          class="explorer-toggle mobile-explorer hide-until-loaded"
          data-mobile={true}
          aria-controls="explorer-content"
        >
@@ -95,8 +96,7 @@
        </button>
        <button
          type="button"
          id="desktop-explorer"
          class="title-button explorer-toggle"
          class="title-button explorer-toggle desktop-explorer"
          data-mobile={false}
          aria-expanded={true}
        >
@@ -116,8 +116,8 @@
            <polyline points="6 9 12 15 18 9"></polyline>
          </svg>
        </button>
        <div id="explorer-content" aria-expanded={false}>
          <OverflowList id="explorer-ul" />
        <div class="explorer-content" aria-expanded={false}>
          <OverflowList class="explorer-ul" />
        </div>
        <template id="template-file">
          <li>
@@ -157,6 +157,6 @@
  }
  Explorer.css = style
  Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul")
  Explorer.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
  return Explorer
}) satisfies QuartzComponentConstructor
quartz/components/OverflowList.tsx
@@ -1,22 +1,31 @@
import { JSX } from "preact"
import { randomIdNonSecure } from "../util/random"
const OverflowList = ({
  children,
  ...props
}: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => {
  return (
    <ul class="overflow" {...props}>
    <ul {...props} class={[props.class, "overflow"].filter(Boolean).join(" ")} id={props.id}>
      {children}
      <li class="overflow-end" />
    </ul>
  )
}
OverflowList.afterDOMLoaded = (id: string) => `
export default () => {
  const id = randomIdNonSecure()
  return {
    OverflowList: (props: JSX.HTMLAttributes<HTMLUListElement>) => (
      <OverflowList {...props} id={id} />
    ),
    overflowListAfterDOMLoaded: `
document.addEventListener("nav", (e) => {
  const observer = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      const parentUl = entry.target.parentElement
      if (!parentUl) return
      if (entry.isIntersecting) {
        parentUl.classList.remove("gradient-active")
      } else {
@@ -34,6 +43,6 @@
  observer.observe(end)
  window.addCleanup(() => observer.disconnect())
})
`
export default OverflowList
`,
  }
}
quartz/components/TableOfContents.tsx
@@ -6,7 +6,8 @@
// @ts-ignore
import script from "./scripts/toc.inline"
import { i18n } from "../i18n"
import OverflowList from "./OverflowList"
import OverflowListFactory from "./OverflowList"
import { concatenateResources } from "../util/resources"
interface Options {
  layout: "modern" | "legacy"
@@ -16,41 +17,70 @@
  layout: "modern",
}
const TableOfContents: QuartzComponent = ({
  fileData,
  displayClass,
  cfg,
}: QuartzComponentProps) => {
  if (!fileData.toc) {
    return null
export default ((opts?: Partial<Options>) => {
  const layout = opts?.layout ?? defaultOptions.layout
  const { OverflowList, overflowListAfterDOMLoaded } = OverflowListFactory()
  const TableOfContents: QuartzComponent = ({
    fileData,
    displayClass,
    cfg,
  }: QuartzComponentProps) => {
    if (!fileData.toc) {
      return null
    }
    return (
      <div class={classNames(displayClass, "toc")}>
        <button
          type="button"
          class={fileData.collapseToc ? "collapsed toc-header" : "toc-header"}
          aria-controls="toc-content"
          aria-expanded={!fileData.collapseToc}
        >
          <h3>{i18n(cfg.locale).components.tableOfContents.title}</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 class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
          <OverflowList>
            {fileData.toc.map((tocEntry) => (
              <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
                <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
                  {tocEntry.text}
                </a>
              </li>
            ))}
          </OverflowList>
        </div>
      </div>
    )
  }
  return (
    <div class={classNames(displayClass, "toc")}>
      <button
        type="button"
        class={fileData.collapseToc ? "collapsed toc-header" : "toc-header"}
        aria-controls="toc-content"
        aria-expanded={!fileData.collapseToc}
      >
        <h3>{i18n(cfg.locale).components.tableOfContents.title}</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 class={fileData.collapseToc ? "collapsed toc-content" : "toc-content"}>
        <OverflowList id="toc-ul">
  TableOfContents.css = modernStyle
  TableOfContents.afterDOMLoaded = concatenateResources(script, overflowListAfterDOMLoaded)
  const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
    if (!fileData.toc) {
      return null
    }
    return (
      <details class="toc" open={!fileData.collapseToc}>
        <summary>
          <h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
        </summary>
        <ul>
          {fileData.toc.map((tocEntry) => (
            <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
              <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>
@@ -58,38 +88,11 @@
              </a>
            </li>
          ))}
        </OverflowList>
      </div>
    </div>
  )
}
TableOfContents.css = modernStyle
TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul")
const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
  if (!fileData.toc) {
    return null
        </ul>
      </details>
    )
  }
  return (
    <details class="toc" open={!fileData.collapseToc}>
      <summary>
        <h3>{i18n(cfg.locale).components.tableOfContents.title}</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>
          </li>
        ))}
      </ul>
    </details>
  )
}
LegacyTableOfContents.css = legacyStyle
  LegacyTableOfContents.css = legacyStyle
export default ((opts?: Partial<Options>) => {
  const layout = opts?.layout ?? defaultOptions.layout
  return layout === "modern" ? TableOfContents : LegacyTableOfContents
}) satisfies QuartzComponentConstructor
quartz/components/pages/FolderContent.tsx
@@ -9,6 +9,7 @@
import { i18n } from "../../i18n"
import { QuartzPluginData } from "../../plugins/vfile"
import { ComponentChildren } from "preact"
import { concatenateResources } from "../../util/resources"
interface FolderContentOptions {
  /**
@@ -104,6 +105,6 @@
    )
  }
  FolderContent.css = style + PageList.css
  FolderContent.css = concatenateResources(style, PageList.css)
  return FolderContent
}) satisfies QuartzComponentConstructor
quartz/components/pages/TagContent.tsx
@@ -7,6 +7,7 @@
import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n"
import { ComponentChildren } from "preact"
import { concatenateResources } from "../../util/resources"
interface TagContentOptions {
  sort?: SortFn
@@ -124,6 +125,6 @@
    }
  }
  TagContent.css = style + PageList.css
  TagContent.css = concatenateResources(style, PageList.css)
  return TagContent
}) satisfies QuartzComponentConstructor
quartz/components/scripts/explorer.inline.ts
@@ -21,14 +21,13 @@
let currentExplorerState: Array<FolderState>
function toggleExplorer(this: HTMLElement) {
  const explorers = document.querySelectorAll(".explorer")
  for (const explorer of explorers) {
    explorer.classList.toggle("collapsed")
    explorer.setAttribute(
      "aria-expanded",
      explorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
    )
  }
  const nearestExplorer = this.closest(".explorer") as HTMLElement
  if (!nearestExplorer) return
  nearestExplorer.classList.toggle("collapsed")
  nearestExplorer.setAttribute(
    "aria-expanded",
    nearestExplorer.getAttribute("aria-expanded") === "true" ? "false" : "true",
  )
}
function toggleFolder(evt: MouseEvent) {
@@ -145,7 +144,7 @@
}
async function setupExplorer(currentSlug: FullSlug) {
  const allExplorers = document.querySelectorAll(".explorer") as NodeListOf<HTMLElement>
  const allExplorers = document.querySelectorAll("div.explorer") as NodeListOf<HTMLElement>
  for (const explorer of allExplorers) {
    const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
@@ -192,7 +191,7 @@
      collapsed: oldIndex.get(path) === true,
    }))
    const explorerUl = document.getElementById("explorer-ul")
    const explorerUl = explorer.querySelector(".explorer-ul")
    if (!explorerUl) continue
    // Create and insert new content
@@ -219,14 +218,12 @@
    }
    // Set up event handlers
    const explorerButtons = explorer.querySelectorAll(
      "button.explorer-toggle",
    ) as NodeListOf<HTMLElement>
    if (explorerButtons) {
      window.addCleanup(() =>
        explorerButtons.forEach((button) => button.removeEventListener("click", toggleExplorer)),
      )
      explorerButtons.forEach((button) => button.addEventListener("click", toggleExplorer))
    const explorerButtons = explorer.getElementsByClassName(
      "explorer-toggle",
    ) as HTMLCollectionOf<HTMLElement>
    for (const button of explorerButtons) {
      button.addEventListener("click", toggleExplorer)
      window.addCleanup(() => button.removeEventListener("click", toggleExplorer))
    }
    // Set up folder click handlers
@@ -235,8 +232,8 @@
        "folder-button",
      ) as HTMLCollectionOf<HTMLElement>
      for (const button of folderButtons) {
        window.addCleanup(() => button.removeEventListener("click", toggleFolder))
        button.addEventListener("click", toggleFolder)
        window.addCleanup(() => button.removeEventListener("click", toggleFolder))
      }
    }
@@ -244,15 +241,15 @@
      "folder-icon",
    ) as HTMLCollectionOf<HTMLElement>
    for (const icon of folderIcons) {
      window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
      icon.addEventListener("click", toggleFolder)
      window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
    }
  }
}
document.addEventListener("prenav", async (e: CustomEventMap["prenav"]) => {
document.addEventListener("prenav", async () => {
  // save explorer scrollTop position
  const explorer = document.getElementById("explorer-ul")
  const explorer = document.querySelector(".explorer-ul")
  if (!explorer) return
  sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
})
@@ -262,9 +259,8 @@
  await setupExplorer(currentSlug)
  // if mobile hamburger is visible, collapse by default
  const mobileExplorer = document.getElementById("mobile-explorer")
  if (mobileExplorer && mobileExplorer.checkVisibility()) {
    for (const explorer of document.querySelectorAll(".explorer")) {
  for (const explorer of document.getElementsByClassName("mobile-explorer")) {
    if (explorer.checkVisibility()) {
      explorer.classList.add("collapsed")
      explorer.setAttribute("aria-expanded", "false")
    }
quartz/components/styles/explorer.scss
@@ -20,7 +20,7 @@
      margin: 0;
    }
    .hide-until-loaded ~ #explorer-content {
    .hide-until-loaded ~ .explorer-content {
      display: none;
    }
  }
@@ -30,6 +30,8 @@
  display: flex;
  flex-direction: column;
  overflow-y: hidden;
  min-height: 1.2rem;
  flex: 0 1 auto;
  &.collapsed {
    flex: 0 1 1.2rem;
@@ -52,20 +54,20 @@
    align-self: flex-start;
  }
  button#mobile-explorer {
  button.mobile-explorer {
    display: none;
  }
  button#desktop-explorer {
  button.desktop-explorer {
    display: flex;
  }
  @media all and ($mobile) {
    button#mobile-explorer {
    button.mobile-explorer {
      display: flex;
    }
    button#desktop-explorer {
    button.desktop-explorer {
      display: none;
    }
  }
@@ -86,8 +88,8 @@
  }
}
button#mobile-explorer,
button#desktop-explorer {
button.mobile-explorer,
button.desktop-explorer {
  background-color: transparent;
  border: none;
  text-align: left;
@@ -104,7 +106,7 @@
  }
}
#explorer-content {
.explorer-content {
  list-style: none;
  overflow: hidden;
  overflow-y: auto;
@@ -209,7 +211,7 @@
    &.collapsed {
      flex: 0 0 34px;
      & > #explorer-content {
      & > .explorer-content {
        transform: translateX(-100vw);
        visibility: hidden;
      }
@@ -218,13 +220,13 @@
    &:not(.collapsed) {
      flex: 0 0 34px;
      & > #explorer-content {
      & > .explorer-content {
        transform: translateX(0);
        visibility: visible;
      }
    }
    #explorer-content {
    .explorer-content {
      box-sizing: border-box;
      z-index: 100;
      position: absolute;
@@ -245,7 +247,7 @@
      visibility: hidden;
    }
    #mobile-explorer {
    .mobile-explorer {
      margin: 0;
      padding: 5px;
      z-index: 101;
quartz/components/styles/toc.scss
@@ -5,6 +5,7 @@
  flex-direction: column;
  overflow-y: hidden;
  min-height: 4rem;
  flex: 0 1 auto;
  &:has(button.toc-header.collapsed) {
    flex: 0 1 1.2rem;
quartz/components/types.ts
@@ -1,5 +1,5 @@
import { ComponentType, JSX } from "preact"
import { StaticResources } from "../util/resources"
import { StaticResources, StringResource } from "../util/resources"
import { QuartzPluginData } from "../plugins/vfile"
import { GlobalConfiguration } from "../cfg"
import { Node } from "hast"
@@ -19,9 +19,9 @@
  }
export type QuartzComponent = ComponentType<QuartzComponentProps> & {
  css?: string
  beforeDOMLoaded?: string
  afterDOMLoaded?: string
  css?: StringResource
  beforeDOMLoaded?: StringResource
  afterDOMLoaded?: StringResource
}
export type QuartzComponentConstructor<Options extends object | undefined = undefined> = (
quartz/plugins/emitters/componentResources.ts
@@ -36,17 +36,21 @@
    afterDOMLoaded: new Set<string>(),
  }
  function normalizeResource(resource: string | string[] | undefined): string[] {
    if (!resource) return []
    if (Array.isArray(resource)) return resource
    return [resource]
  }
  for (const component of allComponents) {
    const { css, beforeDOMLoaded, afterDOMLoaded } = component
    if (css) {
      componentResources.css.add(css)
    }
    if (beforeDOMLoaded) {
      componentResources.beforeDOMLoaded.add(beforeDOMLoaded)
    }
    if (afterDOMLoaded) {
      componentResources.afterDOMLoaded.add(afterDOMLoaded)
    }
    const normalizedCss = normalizeResource(css)
    const normalizedBeforeDOMLoaded = normalizeResource(beforeDOMLoaded)
    const normalizedAfterDOMLoaded = normalizeResource(afterDOMLoaded)
    normalizedCss.forEach((c) => componentResources.css.add(c))
    normalizedBeforeDOMLoaded.forEach((b) => componentResources.beforeDOMLoaded.add(b))
    normalizedAfterDOMLoaded.forEach((a) => componentResources.afterDOMLoaded.add(a))
  }
  return {
quartz/styles/base.scss
@@ -542,7 +542,7 @@
}
.spacer {
  flex: 1 1 auto;
  flex: 2 1 auto;
}
div:has(> .overflow) {
@@ -555,17 +555,14 @@
  max-height: 100%;
  overflow-y: auto;
  width: 100%;
  margin-bottom: 0;
  // clearfix
  content: "";
  clear: both;
  & > li:last-of-type {
    margin-bottom: 30px;
  }
  & > li.overflow-end {
    height: 4px;
    height: 1rem;
    margin: 0;
  }
quartz/util/random.ts
New file
@@ -0,0 +1,3 @@
export function randomIdNonSecure() {
  return Math.random().toString(36).substring(2, 8)
}
quartz/util/resources.tsx
@@ -65,3 +65,10 @@
  js: JSResource[]
  additionalHead: (JSX.Element | ((pageData: QuartzPluginData) => JSX.Element))[]
}
export type StringResource = string | string[] | undefined
export function concatenateResources(...resources: StringResource[]): StringResource {
  return resources
    .filter((resource): resource is string | string[] => resource !== undefined)
    .flat()
}