From 5480269d38ffaff7ffd6576d9a9407430429fb2d Mon Sep 17 00:00:00 2001
From: Jacky Zhao <j.zhao2k19@gmail.com>
Date: Sun, 09 Mar 2025 21:58:26 +0000
Subject: [PATCH] perf(explorer): client side explorer (#1810)
---
quartz/components/scripts/explorer.inline.ts | 407 +++++++++++++++++++++++++++++++++------------------------
1 files changed, 235 insertions(+), 172 deletions(-)
diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts
index 9c6c050..15f3a84 100644
--- a/quartz/components/scripts/explorer.inline.ts
+++ b/quartz/components/scripts/explorer.inline.ts
@@ -1,53 +1,38 @@
-import { FolderState } from "../ExplorerNode"
+import { FileTrieNode } from "../../util/fileTrie"
+import { FullSlug, resolveRelative, simplifySlug } from "../../util/path"
+import { ContentDetails } from "../../plugins/emitters/contentIndex"
-// Current state of folders
type MaybeHTMLElement = HTMLElement | undefined
-let currentExplorerState: FolderState[]
-const observer = new IntersectionObserver((entries) => {
- // If last element is observed, remove gradient of "overflow" class so element is visible
- const explorerUl = document.getElementById("explorer-ul")
- if (!explorerUl) return
- for (const entry of entries) {
- if (entry.isIntersecting) {
- explorerUl.classList.add("no-background")
- } else {
- explorerUl.classList.remove("no-background")
- }
- }
-})
+interface ParsedOptions {
+ folderClickBehavior: "collapse" | "link"
+ folderDefaultState: "collapsed" | "open"
+ useSavedState: boolean
+ sortFn: (a: FileTrieNode, b: FileTrieNode) => number
+ filterFn: (node: FileTrieNode) => boolean
+ mapFn: (node: FileTrieNode) => void
+ order: "sort" | "filter" | "map"[]
+}
+type FolderState = {
+ path: string
+ collapsed: boolean
+}
+
+let currentExplorerState: Array<FolderState>
function toggleExplorer(this: HTMLElement) {
- // Toggle collapsed state of entire explorer
- this.classList.toggle("collapsed")
-
- // Toggle collapsed aria state of entire explorer
- this.setAttribute(
- "aria-expanded",
- this.getAttribute("aria-expanded") === "true" ? "false" : "true",
- )
-
- const content = (
- this.nextElementSibling?.nextElementSibling
- ? this.nextElementSibling.nextElementSibling
- : this.nextElementSibling
- ) as MaybeHTMLElement
- if (!content) return
- content.classList.toggle("collapsed")
- content.classList.toggle("explorer-viewmode")
-
- // Prevent scroll under
- if (document.querySelector("#mobile-explorer")) {
- // Disable scrolling on the page when the explorer is opened on mobile
- const bodySelector = document.querySelector("#quartz-body")
- if (bodySelector) bodySelector.classList.toggle("lock-scroll")
+ 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",
+ )
}
}
function toggleFolder(evt: MouseEvent) {
evt.stopPropagation()
-
- // Element that was clicked
const target = evt.target as MaybeHTMLElement
if (!target) return
@@ -55,162 +40,240 @@
const isSvg = target.nodeName === "svg"
// corresponding <ul> element relative to clicked button/folder
- const childFolderContainer = (
+ const folderContainer = (
isSvg
- ? target.parentElement?.nextSibling
- : target.parentElement?.parentElement?.nextElementSibling
+ ? // svg -> div.folder-container
+ target.parentElement
+ : // button.folder-button -> div -> div.folder-container
+ target.parentElement?.parentElement
) as MaybeHTMLElement
- const currentFolderParent = (
- isSvg ? target.nextElementSibling : target.parentElement
- ) as MaybeHTMLElement
- if (!(childFolderContainer && currentFolderParent)) return
- // <li> element of folder (stores folder-path dataset)
+ if (!folderContainer) return
+ const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement
+ if (!childFolderContainer) return
+
childFolderContainer.classList.toggle("open")
// Collapse folder container
- const isCollapsed = childFolderContainer.classList.contains("open")
- setFolderState(childFolderContainer, !isCollapsed)
+ const isCollapsed = !childFolderContainer.classList.contains("open")
+ setFolderState(childFolderContainer, isCollapsed)
- // Save folder state to localStorage
- const fullFolderPath = currentFolderParent.dataset.folderpath as string
- toggleCollapsedByPath(currentExplorerState, fullFolderPath)
+ const currentFolderState = currentExplorerState.find(
+ (item) => item.path === folderContainer.dataset.folderpath,
+ )
+ if (currentFolderState) {
+ currentFolderState.collapsed = isCollapsed
+ } else {
+ currentExplorerState.push({
+ path: folderContainer.dataset.folderpath as FullSlug,
+ collapsed: isCollapsed,
+ })
+ }
+
const stringifiedFileTree = JSON.stringify(currentExplorerState)
localStorage.setItem("fileTree", stringifiedFileTree)
}
-function setupExplorer() {
- // Set click handler for collapsing entire explorer
- const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
+function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement {
+ const template = document.getElementById("template-file") as HTMLTemplateElement
+ const clone = template.content.cloneNode(true) as DocumentFragment
+ const li = clone.querySelector("li") as HTMLLIElement
+ const a = li.querySelector("a") as HTMLAnchorElement
+ a.href = resolveRelative(currentSlug, node.data?.slug!)
+ a.dataset.for = node.data?.slug
+ a.textContent = node.displayName
+
+ if (currentSlug === node.data?.slug) {
+ a.classList.add("active")
+ }
+
+ return li
+}
+
+function createFolderNode(
+ currentSlug: FullSlug,
+ node: FileTrieNode,
+ opts: ParsedOptions,
+): HTMLLIElement {
+ const template = document.getElementById("template-folder") as HTMLTemplateElement
+ const clone = template.content.cloneNode(true) as DocumentFragment
+ const li = clone.querySelector("li") as HTMLLIElement
+ const folderContainer = li.querySelector(".folder-container") as HTMLElement
+ const titleContainer = folderContainer.querySelector("div") as HTMLElement
+ const folderOuter = li.querySelector(".folder-outer") as HTMLElement
+ const ul = folderOuter.querySelector("ul") as HTMLUListElement
+
+ const folderPath = node.data?.slug!
+ folderContainer.dataset.folderpath = folderPath
+
+ if (opts.folderClickBehavior === "link") {
+ // Replace button with link for link behavior
+ const button = titleContainer.querySelector(".folder-button") as HTMLElement
+ const a = document.createElement("a")
+ a.href = resolveRelative(currentSlug, folderPath)
+ a.dataset.for = node.data?.slug
+ a.className = "folder-title"
+ a.textContent = node.displayName
+ button.replaceWith(a)
+ } else {
+ const span = titleContainer.querySelector(".folder-title") as HTMLElement
+ span.textContent = node.displayName
+ }
+
+ // if the saved state is collapsed or the default state is collapsed
+ const isCollapsed =
+ currentExplorerState.find((item) => item.path === folderPath)?.collapsed ??
+ opts.folderDefaultState === "collapsed"
+
+ // if this folder is a prefix of the current path we
+ // want to open it anyways
+ const simpleFolderPath = simplifySlug(folderPath)
+ const folderIsPrefixOfCurrentSlug =
+ simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length)
+
+ if (!isCollapsed || folderIsPrefixOfCurrentSlug) {
+ folderOuter.classList.add("open")
+ }
+
+ for (const child of node.children) {
+ const childNode = child.data
+ ? createFileNode(currentSlug, child)
+ : createFolderNode(currentSlug, child, opts)
+ ul.appendChild(childNode)
+ }
+
+ return li
+}
+
+async function setupExplorer(currentSlug: FullSlug) {
+ const allExplorers = document.querySelectorAll(".explorer") as NodeListOf<HTMLElement>
for (const explorer of allExplorers) {
+ const dataFns = JSON.parse(explorer.dataset.dataFns || "{}")
+ const opts: ParsedOptions = {
+ folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link",
+ folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open",
+ useSavedState: explorer.dataset.savestate === "true",
+ order: dataFns.order || ["filter", "map", "sort"],
+ sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(),
+ filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(),
+ mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(),
+ }
+
// Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
-
- // Convert to bool
- const useSavedFolderState = explorer?.dataset.savestate === "true"
-
- if (explorer) {
- // Get config
- const collapseBehavior = explorer.dataset.behavior
-
- // Add click handlers for all folders (click handler on folder "label")
- if (collapseBehavior === "collapse") {
- for (const item of document.getElementsByClassName(
- "folder-button",
- ) as HTMLCollectionOf<HTMLElement>) {
- window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
- item.addEventListener("click", toggleFolder)
- }
- }
-
- // Add click handler to main explorer
- window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
- explorer.addEventListener("click", toggleExplorer)
- }
-
- // Set up click handlers for each folder (click handler on folder "icon")
- for (const item of document.getElementsByClassName(
- "folder-icon",
- ) as HTMLCollectionOf<HTMLElement>) {
- item.addEventListener("click", toggleFolder)
- window.addCleanup(() => item.removeEventListener("click", toggleFolder))
- }
-
- // Get folder state from local storage
- const oldExplorerState: FolderState[] =
- storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
- const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
- const newExplorerState: FolderState[] = explorer.dataset.tree
- ? JSON.parse(explorer.dataset.tree)
- : []
- currentExplorerState = []
-
- for (const { path, collapsed } of newExplorerState) {
- currentExplorerState.push({
- path,
- collapsed: oldIndex.get(path) ?? collapsed,
- })
- }
-
- currentExplorerState.map((folderState) => {
- const folderLi = document.querySelector(
- `[data-folderpath='${folderState.path.replace("'", "-")}']`,
- ) as MaybeHTMLElement
- const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
- if (folderUl) {
- setFolderState(folderUl, folderState.collapsed)
- }
- })
- }
-}
-
-function toggleExplorerFolders() {
- const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
- /\/index$/g,
- "",
- )
- const allFolders = document.querySelectorAll(".folder-outer")
-
- allFolders.forEach((element) => {
- const folderUl = Array.from(element.children).find((child) =>
- child.matches("ul[data-folderul]"),
+ const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : []
+ const oldIndex = new Map(
+ serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]),
)
- if (folderUl) {
- if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
- if (!element.classList.contains("open")) {
- element.classList.add("open")
- }
+
+ const data = await fetchData
+ const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][]
+ const trie = FileTrieNode.fromEntries(entries)
+
+ // Apply functions in order
+ for (const fn of opts.order) {
+ switch (fn) {
+ case "filter":
+ if (opts.filterFn) trie.filter(opts.filterFn)
+ break
+ case "map":
+ if (opts.mapFn) trie.map(opts.mapFn)
+ break
+ case "sort":
+ if (opts.sortFn) trie.sort(opts.sortFn)
+ break
}
}
- })
-}
-window.addEventListener("resize", setupExplorer)
+ // Get folder paths for state management
+ const folderPaths = trie.getFolderPaths()
+ currentExplorerState = folderPaths.map((path) => ({
+ path,
+ collapsed: oldIndex.get(path) === true,
+ }))
-document.addEventListener("nav", () => {
- const explorer = document.querySelector("#mobile-explorer")
- if (explorer) {
- explorer.classList.add("collapsed")
- const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement
- if (content) {
- content.classList.add("collapsed")
- content.classList.toggle("explorer-viewmode")
+ const explorerUl = document.getElementById("explorer-ul")
+ if (!explorerUl) continue
+
+ // Create and insert new content
+ const fragment = document.createDocumentFragment()
+ for (const child of trie.children) {
+ const node = child.isFolder
+ ? createFolderNode(currentSlug, child, opts)
+ : createFileNode(currentSlug, child)
+
+ fragment.appendChild(node)
+ }
+ explorerUl.insertBefore(fragment, explorerUl.firstChild)
+
+ // restore explorer scrollTop position if it exists
+ const scrollTop = sessionStorage.getItem("explorerScrollTop")
+ if (scrollTop) {
+ explorerUl.scrollTop = parseInt(scrollTop)
+ } else {
+ // try to scroll to the active element if it exists
+ const activeElement = explorerUl.querySelector(".active")
+ if (activeElement) {
+ activeElement.scrollIntoView({ behavior: "smooth" })
+ }
+ }
+
+ // 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))
+ }
+
+ // Set up folder click handlers
+ if (opts.folderClickBehavior === "collapse") {
+ const folderButtons = explorer.getElementsByClassName(
+ "folder-button",
+ ) as HTMLCollectionOf<HTMLElement>
+ for (const button of folderButtons) {
+ window.addCleanup(() => button.removeEventListener("click", toggleFolder))
+ button.addEventListener("click", toggleFolder)
+ }
+ }
+
+ const folderIcons = explorer.getElementsByClassName(
+ "folder-icon",
+ ) as HTMLCollectionOf<HTMLElement>
+ for (const icon of folderIcons) {
+ window.addCleanup(() => icon.removeEventListener("click", toggleFolder))
+ icon.addEventListener("click", toggleFolder)
}
}
- setupExplorer()
+}
- observer.disconnect()
-
- // select pseudo element at end of list
- const lastItem = document.getElementById("explorer-end")
- if (lastItem) {
- observer.observe(lastItem)
- }
-
- // Hide explorer on mobile until it is requested
- const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
- hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
-
- toggleExplorerFolders()
+document.addEventListener("prenav", async (e: CustomEventMap["prenav"]) => {
+ // save explorer scrollTop position
+ const explorer = document.getElementById("explorer-ul")
+ if (!explorer) return
+ sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString())
})
-/**
- * Toggles the state of a given folder
- * @param folderElement <div class="folder-outer"> Element of folder (parent)
- * @param collapsed if folder should be set to collapsed or not
- */
+document.addEventListener("nav", async (e: CustomEventMap["nav"]) => {
+ const currentSlug = e.detail.url
+ 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")) {
+ explorer.classList.add("collapsed")
+ explorer.setAttribute("aria-expanded", "false")
+ }
+ }
+
+ const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
+ hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
+})
+
function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open")
}
-
-/**
- * Toggles visibility of a folder
- * @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
- * @param path path to folder (e.g. 'advanced/more/more2')
- */
-function toggleCollapsedByPath(array: FolderState[], path: string) {
- const entry = array.find((item) => item.path === path)
- if (entry) {
- entry.collapsed = !entry.collapsed
- }
-}
--
Gitblit v1.10.0