From 08717394ff851af1513516223bd255ef7f9cda3d Mon Sep 17 00:00:00 2001 From: Jacky Zhao Date: Thu, 6 Mar 2025 22:35:24 -0800 Subject: [PATCH] start work on client side explorer --- package.json | 2 +- quartz.layout.ts | 3 +- quartz/components/Backlinks.tsx | 6 +- quartz/components/Explorer.tsx | 150 ++++---- quartz/components/ExplorerNode.tsx | 242 ------------- quartz/components/OverflowList.tsx | 40 ++ quartz/components/TableOfContents.tsx | 7 +- quartz/components/renderPage.tsx | 3 +- quartz/components/scripts/explorer.inline.ts | 361 ++++++++++--------- quartz/components/scripts/toc.inline.ts | 2 - quartz/components/scripts/util.ts | 1 + quartz/components/styles/backlinks.scss | 22 -- quartz/components/styles/explorer.scss | 79 ++-- quartz/components/styles/toc.scss | 8 - quartz/plugins/emitters/contentIndex.tsx | 2 + quartz/styles/base.scss | 22 +- quartz/util/clone.ts | 3 + quartz/util/fileTrie.test.ts | 189 ++++++++++ quartz/util/fileTrie.ts | 127 +++++++ quartz/util/path.ts | 5 +- 20 files changed, 681 insertions(+), 593 deletions(-) delete mode 100644 quartz/components/ExplorerNode.tsx create mode 100644 quartz/components/OverflowList.tsx create mode 100644 quartz/util/clone.ts create mode 100644 quartz/util/fileTrie.test.ts create mode 100644 quartz/util/fileTrie.ts diff --git a/package.json b/package.json index 92872d792..069c3b0c6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "docs": "npx quartz build --serve -d docs", "check": "tsc --noEmit && npx prettier . --check", "format": "npx prettier . --write", - "test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts", + "test": "for f in $(find ./quartz -name '*.test.ts'); do tsx $f; done", "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1" }, "engines": { diff --git a/quartz.layout.ts b/quartz.layout.ts index f45da0c92..e41518b4b 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -26,8 +26,9 @@ export const defaultContentPageLayout: PageLayout = { Component.PageTitle(), Component.MobileOnly(Component.Spacer()), Component.Search(), - Component.Darkmode(), + Component.Explorer(), + Component.Darkmode(), ], right: [ Component.Graph(), diff --git a/quartz/components/Backlinks.tsx b/quartz/components/Backlinks.tsx index e99055e31..735afe727 100644 --- a/quartz/components/Backlinks.tsx +++ b/quartz/components/Backlinks.tsx @@ -3,6 +3,7 @@ import style from "./styles/backlinks.scss" import { resolveRelative, simplifySlug } from "../util/path" import { i18n } from "../i18n" import { classNames } from "../util/lang" +import OverflowList from "./OverflowList" interface BacklinksOptions { hideWhenEmpty: boolean @@ -29,7 +30,7 @@ export default ((opts?: Partial) => { return (

{i18n(cfg.locale).components.backlinks.title}

-
    + {backlinkFiles.length > 0 ? ( backlinkFiles.map((f) => (
  • @@ -41,12 +42,13 @@ export default ((opts?: Partial) => { ) : (
  • {i18n(cfg.locale).components.backlinks.noBacklinksFound}
  • )} -
+
) } Backlinks.css = style + Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul") return Backlinks }) satisfies QuartzComponentConstructor diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index ac276a8bc..00faa0905 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -3,13 +3,25 @@ import style from "./styles/explorer.scss" // @ts-ignore import script from "./scripts/explorer.inline" -import { ExplorerNode, FileNode, Options } from "./ExplorerNode" -import { QuartzPluginData } from "../plugins/vfile" import { classNames } from "../util/lang" import { i18n } from "../i18n" +import { FileTrieNode } from "../util/fileTrie" +import OverflowList from "./OverflowList" -// Options interface defined in `ExplorerNode` to avoid circular dependency -const defaultOptions = { +type OrderEntries = "sort" | "filter" | "map" + +export interface Options { + title?: string + folderDefaultState: "collapsed" | "open" + folderClickBehavior: "collapse" | "link" + useSavedState: boolean + sortFn: (a: FileTrieNode, b: FileTrieNode) => number + filterFn: (node: FileTrieNode) => boolean + mapFn: (node: FileTrieNode) => void + order: OrderEntries[] +} + +const defaultOptions: Options = { folderClickBehavior: "collapse", folderDefaultState: "collapsed", useSavedState: true, @@ -17,8 +29,8 @@ const defaultOptions = { return node }, sortFn: (a, b) => { - // Sort order: folders first, then files. Sort folders and files alphabetically - if ((!a.file && !b.file) || (a.file && b.file)) { + // Sort order: folders first, then files. Sort folders and files alphabeticall + if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) { // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A return a.displayName.localeCompare(b.displayName, undefined, { @@ -27,75 +39,44 @@ const defaultOptions = { }) } - if (a.file && !b.file) { + if (!a.isFolder && b.isFolder) { return 1 } else { return -1 } }, - filterFn: (node) => node.name !== "tags", + filterFn: (node) => node.slugSegment !== "tags", order: ["filter", "map", "sort"], -} satisfies Options +} + +export type FolderState = { + path: string + collapsed: boolean +} export default ((userOpts?: Partial) => { - // Parse config const opts: Options = { ...defaultOptions, ...userOpts } - // memoized - let fileTree: FileNode - let jsonTree: string - let lastBuildId: string = "" - - function constructFileTree(allFiles: QuartzPluginData[]) { - // Construct tree from allFiles - fileTree = new FileNode("") - allFiles.forEach((file) => fileTree.add(file)) - - // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied) - if (opts.order) { - // Order is important, use loop with index instead of order.map() - for (let i = 0; i < opts.order.length; i++) { - const functionName = opts.order[i] - if (functionName === "map") { - fileTree.map(opts.mapFn) - } else if (functionName === "sort") { - fileTree.sort(opts.sortFn) - } else if (functionName === "filter") { - fileTree.filter(opts.filterFn) - } - } - } - - // Get all folders of tree. Initialize with collapsed state - // Stringify to pass json tree as data attribute ([data-tree]) - const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") - jsonTree = JSON.stringify(folders) - } - - const Explorer: QuartzComponent = ({ - ctx, - cfg, - allFiles, - displayClass, - fileData, - }: QuartzComponentProps) => { - if (ctx.buildId !== lastBuildId) { - lastBuildId = ctx.buildId - constructFileTree(allFiles) - } + const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => { return ( -
+
-
-
    - -
  • -
+
+
+ +
) } Explorer.css = style - Explorer.afterDOMLoaded = script + Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul") return Explorer }) satisfies QuartzComponentConstructor diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx deleted file mode 100644 index e57d67715..000000000 --- a/quartz/components/ExplorerNode.tsx +++ /dev/null @@ -1,242 +0,0 @@ -// @ts-ignore -import { QuartzPluginData } from "../plugins/vfile" -import { - joinSegments, - resolveRelative, - clone, - simplifySlug, - SimpleSlug, - FilePath, -} from "../util/path" - -type OrderEntries = "sort" | "filter" | "map" - -export interface Options { - title?: string - folderDefaultState: "collapsed" | "open" - folderClickBehavior: "collapse" | "link" - useSavedState: boolean - sortFn: (a: FileNode, b: FileNode) => number - filterFn: (node: FileNode) => boolean - mapFn: (node: FileNode) => void - order: OrderEntries[] -} - -type DataWrapper = { - file: QuartzPluginData - path: string[] -} - -export type FolderState = { - path: string - collapsed: boolean -} - -function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined { - if (!fp) { - return undefined - } - - return fp.split("/").at(idx) -} - -// Structure to add all files into a tree -export class FileNode { - children: Array - name: string // this is the slug segment - displayName: string - file: QuartzPluginData | null - depth: number - - constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) { - this.children = [] - this.name = slugSegment - this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment - this.file = file ? clone(file) : null - this.depth = depth ?? 0 - } - - private insert(fileData: DataWrapper) { - if (fileData.path.length === 0) { - return - } - - const nextSegment = fileData.path[0] - - // base case, insert here - if (fileData.path.length === 1) { - if (nextSegment === "") { - // index case (we are the root and we just found index.md), set our data appropriately - const title = fileData.file.frontmatter?.title - if (title && title !== "index") { - this.displayName = title - } - } else { - // direct child - this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1)) - } - - return - } - - // find the right child to insert into - fileData.path = fileData.path.splice(1) - const child = this.children.find((c) => c.name === nextSegment) - if (child) { - child.insert(fileData) - return - } - - const newChild = new FileNode( - nextSegment, - getPathSegment(fileData.file.relativePath, this.depth), - undefined, - this.depth + 1, - ) - newChild.insert(fileData) - this.children.push(newChild) - } - - // Add new file to tree - add(file: QuartzPluginData) { - this.insert({ file: file, path: simplifySlug(file.slug!).split("/") }) - } - - /** - * Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place - * @param filterFn function to filter tree with - */ - filter(filterFn: (node: FileNode) => boolean) { - this.children = this.children.filter(filterFn) - this.children.forEach((child) => child.filter(filterFn)) - } - - /** - * Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place - * @param mapFn function to use for mapping over tree - */ - map(mapFn: (node: FileNode) => void) { - mapFn(this) - this.children.forEach((child) => child.map(mapFn)) - } - - /** - * Get folder representation with state of tree. - * Intended to only be called on root node before changes to the tree are made - * @param collapsed default state of folders (collapsed by default or not) - * @returns array containing folder state for tree - */ - getFolderPaths(collapsed: boolean): FolderState[] { - const folderPaths: FolderState[] = [] - - const traverse = (node: FileNode, currentPath: string) => { - if (!node.file) { - const folderPath = joinSegments(currentPath, node.name) - if (folderPath !== "") { - folderPaths.push({ path: folderPath, collapsed }) - } - - node.children.forEach((child) => traverse(child, folderPath)) - } - } - - traverse(this, "") - return folderPaths - } - - // Sort order: folders first, then files. Sort folders and files alphabetically - /** - * Sorts tree according to sort/compare function - * @param sortFn compare function used for `.sort()`, also used recursively for children - */ - sort(sortFn: (a: FileNode, b: FileNode) => number) { - this.children = this.children.sort(sortFn) - this.children.forEach((e) => e.sort(sortFn)) - } -} - -type ExplorerNodeProps = { - node: FileNode - opts: Options - fileData: QuartzPluginData - fullPath?: string -} - -export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) { - // Get options - const folderBehavior = opts.folderClickBehavior - const isDefaultOpen = opts.folderDefaultState === "open" - - // Calculate current folderPath - const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : "" - const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/" - - return ( - <> - {node.file ? ( - // Single file node -
  • - - {node.displayName} - -
  • - ) : ( -
  • - {node.name !== "" && ( - // Node with entire folder - // Render svg button + folder name, then children - -
  • - )} - {/* Recursively render children of folder */} -
    -
      - {node.children.map((childNode, i) => ( - - ))} -
    -
    - - )} - - ) -} diff --git a/quartz/components/OverflowList.tsx b/quartz/components/OverflowList.tsx new file mode 100644 index 000000000..2d32ec3a9 --- /dev/null +++ b/quartz/components/OverflowList.tsx @@ -0,0 +1,40 @@ +import { JSX } from "preact" + +const OverflowList = ({ + children, + ...props +}: JSX.HTMLAttributes & { id: string }) => { + return ( +
      + {children} +
    • +
    + ) +} + +OverflowList.afterDOMLoaded = (id: string) => ` +document.addEventListener("nav", (e) => { + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const parentUl = entry.target.parentElement + console.log(parentUl) + if (entry.isIntersecting) { + parentUl.classList.remove("gradient-active") + } else { + parentUl.classList.add("gradient-active") + } + } + }) + + const ul = document.getElementById("${id}") + if (!ul) return + + const end = ul.querySelector(".overflow-end") + if (!end) return + + observer.observe(end) + window.addCleanup(() => observer.disconnect()) +}) +` + +export default OverflowList diff --git a/quartz/components/TableOfContents.tsx b/quartz/components/TableOfContents.tsx index ec457cfe5..485f434a8 100644 --- a/quartz/components/TableOfContents.tsx +++ b/quartz/components/TableOfContents.tsx @@ -6,6 +6,7 @@ import { classNames } from "../util/lang" // @ts-ignore import script from "./scripts/toc.inline" import { i18n } from "../i18n" +import OverflowList from "./OverflowList" interface Options { layout: "modern" | "legacy" @@ -50,7 +51,7 @@ const TableOfContents: QuartzComponent = ({
    - +
    ) } TableOfContents.css = modernStyle -TableOfContents.afterDOMLoaded = script +TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul") const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => { if (!fileData.toc) { diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index 9cebaa849..75ef82b24 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -3,7 +3,8 @@ import { QuartzComponent, QuartzComponentProps } from "./types" import HeaderConstructor from "./Header" import BodyConstructor from "./Body" import { JSResourceToScriptElement, StaticResources } from "../util/resources" -import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" +import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" +import { clone } from "../util/clone" import { visit } from "unist-util-visit" import { Root, Element, ElementContent } from "hast" import { GlobalConfiguration } from "../cfg" diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index 9c6c0508f..2a8a0cc91 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 } 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 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,204 @@ function toggleFolder(evt: MouseEvent) { const isSvg = target.nodeName === "svg" // corresponding
      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 - //
    • 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) + + 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, + }) + } - // Save folder state to localStorage - const fullFolderPath = currentFolderParent.dataset.folderpath as string - toggleCollapsedByPath(currentExplorerState, fullFolderPath) 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 +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 + 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 + } + + const isCollapsed = + currentExplorerState.find((item) => item.path === folderPath)?.collapsed ?? + opts.folderDefaultState === "collapsed" + if (!isCollapsed) { + 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 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) { - 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) { - 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) + + // Set up event handlers + const explorerButtons = explorer.querySelectorAll( + "button.explorer-toggle", + ) as NodeListOf + 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 + for (const button of folderButtons) { + window.addCleanup(() => button.removeEventListener("click", toggleFolder)) + button.addEventListener("click", toggleFolder) + } + } + + const folderIcons = explorer.getElementsByClassName( + "folder-icon", + ) as HTMLCollectionOf + 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) +document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { + const currentSlug = e.detail.url + const mobileExplorer = document.querySelector("#mobile-explorer") + if (mobileExplorer) { + mobileExplorer.classList.add("collapsed") } + await setupExplorer(currentSlug) // Hide explorer on mobile until it is requested const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer") hiddenUntilDoneLoading?.classList.remove("hide-until-loaded") - - toggleExplorerFolders() }) -/** - * Toggles the state of a given folder - * @param folderElement
      Element of folder (parent) - * @param collapsed if folder should be set to collapsed or not - */ 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 - } -} diff --git a/quartz/components/scripts/toc.inline.ts b/quartz/components/scripts/toc.inline.ts index 2cfb3f921..a518c1021 100644 --- a/quartz/components/scripts/toc.inline.ts +++ b/quartz/components/scripts/toc.inline.ts @@ -1,4 +1,3 @@ -const bufferPx = 150 const observer = new IntersectionObserver((entries) => { for (const entry of entries) { const slug = entry.target.id @@ -28,7 +27,6 @@ function toggleToc(this: HTMLElement) { function setupToc() { const toc = document.getElementById("toc") if (toc) { - const collapsed = toc.classList.contains("collapsed") const content = toc.nextElementSibling as HTMLElement | undefined if (!content) return toc.addEventListener("click", toggleToc) diff --git a/quartz/components/scripts/util.ts b/quartz/components/scripts/util.ts index ff486cf41..f71790104 100644 --- a/quartz/components/scripts/util.ts +++ b/quartz/components/scripts/util.ts @@ -37,6 +37,7 @@ export async function fetchCanonical(url: URL): Promise { if (!res.headers.get("content-type")?.startsWith("text/html")) { return res } + // reading the body can only be done once, so we need to clone the response // to allow the caller to read it if it's was not a redirect const text = await res.clone().text() diff --git a/quartz/components/styles/backlinks.scss b/quartz/components/styles/backlinks.scss index 7b3237b8a..71c13f04e 100644 --- a/quartz/components/styles/backlinks.scss +++ b/quartz/components/styles/backlinks.scss @@ -2,18 +2,6 @@ .backlinks { flex-direction: column; - /*&:after { - pointer-events: none; - content: ""; - width: 100%; - height: 50px; - position: absolute; - left: 0; - bottom: 0; - opacity: 1; - transition: opacity 0.3s ease; - background: linear-gradient(transparent 0px, var(--light)); - }*/ & > h3 { font-size: 1rem; @@ -31,14 +19,4 @@ } } } - - & > .overflow { - &:after { - display: none; - } - height: auto; - @media all and not ($desktop) { - height: 250px; - } - } } diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss index fbeb58d82..c7c211652 100644 --- a/quartz/components/styles/explorer.scss +++ b/quartz/components/styles/explorer.scss @@ -28,7 +28,6 @@ .explorer { display: flex; - height: 100%; flex-direction: column; overflow-y: hidden; @@ -63,19 +62,6 @@ display: flex; } } - - /*&:after { - pointer-events: none; - content: ""; - width: 100%; - height: 50px; - position: absolute; - left: 0; - bottom: 0; - opacity: 1; - transition: opacity 0.3s ease; - background: linear-gradient(transparent 0px, var(--light)); - }*/ } button#mobile-explorer, @@ -101,42 +87,22 @@ button#desktop-explorer { opacity: 0.8; } - &.collapsed .fold { + .explorer.collapsed > & .fold { transform: rotateZ(-90deg); } } -.folder-outer { - display: grid; - grid-template-rows: 0fr; - transition: grid-template-rows 0.3s ease-in-out; -} - -.folder-outer.open { - grid-template-rows: 1fr; -} - -.folder-outer > ul { - overflow: hidden; -} - #explorer-content { list-style: none; overflow: hidden; overflow-y: auto; - max-height: 0px; - transition: - max-height 0.35s ease, - visibility 0s linear 0.35s; + max-height: 100%; + transition: max-height 0.35s ease; margin-top: 0.5rem; - visibility: hidden; - &.collapsed { - max-height: 100%; - transition: - max-height 0.35s ease, - visibility 0s linear 0s; - visibility: visible; + .explorer.collapsed > & { + max-height: 0px; + transition: max-height 0.35s ease; } & ul { @@ -158,6 +124,23 @@ button#desktop-explorer { > #explorer-ul { max-height: none; } + + .folder-outer { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease-in-out; + } + + .folder-outer.open { + grid-template-rows: 1fr; + } + + .folder-outer > ul { + overflow: hidden; + margin-left: 6px; + padding-left: 0.8rem; + border-left: 1px solid var(--lightgray); + } } svg { @@ -227,17 +210,6 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg { color: var(--tertiary); } -.no-background::after { - background: none !important; -} - -#explorer-end { - // needs height so IntersectionObserver gets triggered - height: 4px; - // remove default margin from li - margin: 0; -} - .explorer { @media all and ($mobile) { #explorer-content { @@ -263,11 +235,6 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg { visibility: visible; } - ul.overflow { - max-height: 100%; - width: 100%; - } - &.collapsed { transform: translateX(0); visibility: visible; diff --git a/quartz/components/styles/toc.scss b/quartz/components/styles/toc.scss index 4988cd836..cb4ef3d43 100644 --- a/quartz/components/styles/toc.scss +++ b/quartz/components/styles/toc.scss @@ -61,10 +61,6 @@ button#toc { visibility: hidden; } - &.collapsed > .overflow::after { - opacity: 0; - } - & ul { list-style: none; margin: 0.5rem 0; @@ -80,10 +76,6 @@ button#toc { } } } - > ul.overflow { - max-height: none; - width: 100%; - } @for $i from 0 through 6 { & .depth-#{$i} { diff --git a/quartz/plugins/emitters/contentIndex.tsx b/quartz/plugins/emitters/contentIndex.tsx index 2810039fa..0cc70d897 100644 --- a/quartz/plugins/emitters/contentIndex.tsx +++ b/quartz/plugins/emitters/contentIndex.tsx @@ -11,6 +11,7 @@ import DepGraph from "../../depgraph" export type ContentIndexMap = Map export type ContentDetails = { + slug: FullSlug title: string links: SimpleSlug[] tags: string[] @@ -124,6 +125,7 @@ export const ContentIndex: QuartzEmitterPlugin> = (opts) => { const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { linkIndex.set(slug, { + slug, title: file.data.frontmatter?.title!, links: file.data.links ?? [], tags: file.data.frontmatter?.tags ?? [], diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss index 438949108..095bea755 100644 --- a/quartz/styles/base.scss +++ b/quartz/styles/base.scss @@ -551,6 +551,7 @@ ul.overflow, ol.overflow { max-height: 100%; overflow-y: auto; + width: 100%; // clearfix content: ""; @@ -559,18 +560,15 @@ ol.overflow { & > li:last-of-type { margin-bottom: 30px; } - /*&:after { - pointer-events: none; - content: ""; - width: 100%; - height: 50px; - position: absolute; - left: 0; - bottom: 0; - opacity: 1; - transition: opacity 0.3s ease; - background: linear-gradient(transparent 0px, var(--light)); - }*/ + + & > li.overflow-end { + height: 4px; + margin: 0; + } + + &.gradient-active { + mask-image: linear-gradient(to bottom, black calc(100% - 50px), transparent 100%); + } } .transclude { diff --git a/quartz/util/clone.ts b/quartz/util/clone.ts new file mode 100644 index 000000000..37318e22e --- /dev/null +++ b/quartz/util/clone.ts @@ -0,0 +1,3 @@ +import rfdc from "rfdc" + +export const clone = rfdc() diff --git a/quartz/util/fileTrie.test.ts b/quartz/util/fileTrie.test.ts new file mode 100644 index 000000000..e1709bf50 --- /dev/null +++ b/quartz/util/fileTrie.test.ts @@ -0,0 +1,189 @@ +import test, { describe, beforeEach } from "node:test" +import assert from "node:assert" +import { FileTrieNode } from "./fileTrie" + +interface TestData { + title: string + slug: string +} + +describe("FileTrie", () => { + let trie: FileTrieNode + + beforeEach(() => { + trie = new FileTrieNode("") + }) + + describe("constructor", () => { + test("should create an empty trie", () => { + assert.deepStrictEqual(trie.children, []) + assert.strictEqual(trie.slugSegment, "") + assert.strictEqual(trie.displayName, "") + assert.strictEqual(trie.data, null) + assert.strictEqual(trie.depth, 0) + }) + + test("should set displayName from data title", () => { + const data = { + title: "Test Title", + slug: "test", + } + + trie.add(data) + assert.strictEqual(trie.children[0].displayName, "Test Title") + }) + }) + + describe("add", () => { + test("should add a file at root level", () => { + const data = { + title: "Test", + slug: "test", + } + + trie.add(data) + assert.strictEqual(trie.children.length, 1) + assert.strictEqual(trie.children[0].slugSegment, "test") + assert.strictEqual(trie.children[0].data, data) + }) + + test("should handle index files", () => { + const data = { + title: "Index", + slug: "index", + } + + trie.add(data) + assert.strictEqual(trie.data, data) + assert.strictEqual(trie.children.length, 0) + }) + + test("should add nested files", () => { + const data1 = { + title: "Nested", + slug: "folder/test", + } + + const data2 = { + title: "Really nested index", + slug: "a/b/c/index", + } + + trie.add(data1) + trie.add(data2) + assert.strictEqual(trie.children.length, 2) + assert.strictEqual(trie.children[0].slugSegment, "folder") + assert.strictEqual(trie.children[0].children.length, 1) + assert.strictEqual(trie.children[0].children[0].slugSegment, "test") + assert.strictEqual(trie.children[0].children[0].data, data1) + + assert.strictEqual(trie.children[1].slugSegment, "a") + assert.strictEqual(trie.children[1].children.length, 1) + assert.strictEqual(trie.children[1].data, null) + + assert.strictEqual(trie.children[1].children[0].slugSegment, "b") + assert.strictEqual(trie.children[1].children[0].children.length, 1) + assert.strictEqual(trie.children[1].children[0].data, null) + + assert.strictEqual(trie.children[1].children[0].children[0].slugSegment, "c") + assert.strictEqual(trie.children[1].children[0].children[0].data, data2) + assert.strictEqual(trie.children[1].children[0].children[0].children.length, 0) + }) + }) + + describe("filter", () => { + test("should filter nodes based on condition", () => { + const data1 = { title: "Test1", slug: "test1" } + const data2 = { title: "Test2", slug: "test2" } + + trie.add(data1) + trie.add(data2) + + trie.filter((node) => node.slugSegment !== "test1") + assert.strictEqual(trie.children.length, 1) + assert.strictEqual(trie.children[0].slugSegment, "test2") + }) + }) + + describe("map", () => { + test("should apply function to all nodes", () => { + const data1 = { title: "Test1", slug: "test1" } + const data2 = { title: "Test2", slug: "test2" } + + trie.add(data1) + trie.add(data2) + + trie.map((node) => { + if (node.data) { + node.displayName = "Modified" + } + }) + + assert.strictEqual(trie.children[0].displayName, "Modified") + assert.strictEqual(trie.children[1].displayName, "Modified") + }) + }) + + describe("entries", () => { + test("should return all entries", () => { + const data1 = { title: "Test1", slug: "test1" } + const data2 = { title: "Test2", slug: "test2" } + + trie.add(data1) + trie.add(data2) + + const entries = trie.entries() + assert.strictEqual(entries.length, 3) + assert.deepStrictEqual( + entries.map(([path, node]) => [path, node.data]), + [ + ["", trie.data], + ["test1/index", data1], + ["test2/index", data2], + ], + ) + }) + }) + + describe("getFolderPaths", () => { + test("should return all folder paths", () => { + const data1 = { + title: "Root", + slug: "index", + } + const data2 = { + title: "Test", + slug: "folder/subfolder/test", + } + const data3 = { + title: "Folder Index", + slug: "abc/index", + } + + trie.add(data1) + trie.add(data2) + trie.add(data3) + const paths = trie.getFolderPaths() + + assert.deepStrictEqual(paths, ["folder", "folder/subfolder", "abc"]) + }) + }) + + describe("sort", () => { + test("should sort nodes according to sort function", () => { + const data1 = { title: "A", slug: "a" } + const data2 = { title: "B", slug: "b" } + const data3 = { title: "C", slug: "c" } + + trie.add(data3) + trie.add(data1) + trie.add(data2) + + trie.sort((a, b) => a.slugSegment.localeCompare(b.slugSegment)) + assert.deepStrictEqual( + trie.children.map((n) => n.slugSegment), + ["a", "b", "c"], + ) + }) + }) +}) diff --git a/quartz/util/fileTrie.ts b/quartz/util/fileTrie.ts new file mode 100644 index 000000000..dd21d3231 --- /dev/null +++ b/quartz/util/fileTrie.ts @@ -0,0 +1,127 @@ +import { ContentDetails } from "../plugins/emitters/contentIndex" +import { FullSlug, joinSegments } from "./path" + +interface FileTrieData { + slug: string + title: string +} + +export class FileTrieNode { + children: Array> + slugSegment: string + displayName: string + data: T | null + depth: number + isFolder: boolean + + constructor(segment: string, data?: T, depth: number = 0) { + this.children = [] + this.slugSegment = segment + this.displayName = data?.title ?? segment + this.data = data ?? null + this.depth = depth + this.isFolder = segment === "index" + } + + private insert(path: string[], file: T) { + if (path.length === 0) return + + const nextSegment = path[0] + + // base case, insert here + if (path.length === 1) { + if (nextSegment === "index") { + // index case (we are the root and we just found index.md) + this.data ??= file + const title = file.title + if (title !== "index") { + this.displayName = title + } + } else { + // direct child + this.children.push(new FileTrieNode(nextSegment, file, this.depth + 1)) + this.isFolder = true + } + + return + } + + // find the right child to insert into, creating it if it doesn't exist + path = path.splice(1) + let child = this.children.find((c) => c.slugSegment === nextSegment) + if (!child) { + child = new FileTrieNode(nextSegment, undefined, this.depth + 1) + this.children.push(child) + child.isFolder = true + } + + child.insert(path, file) + } + + // Add new file to trie + add(file: T) { + this.insert(file.slug.split("/"), file) + } + + /** + * Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place + */ + filter(filterFn: (node: FileTrieNode) => boolean) { + this.children = this.children.filter(filterFn) + this.children.forEach((child) => child.filter(filterFn)) + } + + /** + * Map over trie nodes. Behaves similar to `Array.prototype.map()`, but modifies tree in place + */ + map(mapFn: (node: FileTrieNode) => void) { + mapFn(this) + this.children.forEach((child) => child.map(mapFn)) + } + + /** + * Sort trie nodes according to sort/compare function + */ + sort(sortFn: (a: FileTrieNode, b: FileTrieNode) => number) { + this.children = this.children.sort(sortFn) + this.children.forEach((e) => e.sort(sortFn)) + } + + static fromEntries(entries: [FullSlug, T][]) { + const trie = new FileTrieNode("") + entries.forEach(([, entry]) => trie.add(entry)) + return trie + } + + /** + * Get all entries in the trie + * in the a flat array including the full path and the node + */ + entries(): [FullSlug, FileTrieNode][] { + const traverse = ( + node: FileTrieNode, + currentPath: string, + ): [FullSlug, FileTrieNode][] => { + const segments = [currentPath, node.slugSegment] + if (node.isFolder && node.depth > 0) { + segments.push("index") + } + + const fullPath = joinSegments(...segments) as FullSlug + const result: [FullSlug, FileTrieNode][] = [[fullPath, node]] + return result.concat(...node.children.map((child) => traverse(child, fullPath))) + } + + return traverse(this, "") + } + + /** + * Get all folder paths in the trie + * @returns array containing folder state for trie + */ + getFolderPaths() { + return this.entries() + .filter(([_, node]) => node.isFolder) + .map(([path, _]) => path) + } +} diff --git a/quartz/util/path.ts b/quartz/util/path.ts index 5835f15cc..8f8502979 100644 --- a/quartz/util/path.ts +++ b/quartz/util/path.ts @@ -1,9 +1,6 @@ import { slug as slugAnchor } from "github-slugger" import type { Element as HastElement } from "hast" -import rfdc from "rfdc" - -export const clone = rfdc() - +import { clone } from "./clone" // this file must be isomorphic so it can't use node libs (e.g. path) export const QUARTZ = "quartz"