The Trans Directory
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

perf(explorer): client side explorer (#1810)

* start work on client side explorer

* fix tests

* fmt

* generic test flag

* add prenav hook

* add highlight class

* make flex more consistent, remove transition

* open folders that are prefixes of current path

* make mobile look nice

* more style fixes

authored by

Jacky Zhao and committed by
GitHub
5480269d a2011054

+777 -654
+12
docs/advanced/creating components.md
··· 161 161 }) 162 162 ``` 163 163 164 + You can also add the equivalent of a `beforeunload` event for [[SPA Routing]] via the `prenav` event. 165 + 166 + ```ts 167 + document.addEventListener("prenav", () => { 168 + // executed after an SPA navigation is triggered but 169 + // before the page is replaced 170 + // one usage pattern is to store things in sessionStorage 171 + // in the prenav and then conditionally load then in the consequent 172 + // nav 173 + }) 174 + ``` 175 + 164 176 It is best practice to track any event handlers via `window.addCleanup` to prevent memory leaks. 165 177 This will get called on page navigation. 166 178
+1
index.d.ts
··· 5 5 6 6 // dom custom event 7 7 interface CustomEventMap { 8 + prenav: CustomEvent<{}> 8 9 nav: CustomEvent<{ url: FullSlug }> 9 10 themechange: CustomEvent<{ theme: "light" | "dark" }> 10 11 }
+1 -1
package.json
··· 16 16 "docs": "npx quartz build --serve -d docs", 17 17 "check": "tsc --noEmit && npx prettier . --check", 18 18 "format": "npx prettier . --write", 19 - "test": "tsx ./quartz/util/path.test.ts && tsx ./quartz/depgraph.test.ts", 19 + "test": "tsx --test", 20 20 "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1" 21 21 }, 22 22 "engines": {
+1 -1
quartz.config.ts
··· 8 8 */ 9 9 const config: QuartzConfig = { 10 10 configuration: { 11 - pageTitle: "🪴 Quartz 4", 11 + pageTitle: "Quartz 4", 12 12 pageTitleSuffix: "", 13 13 enableSPA: true, 14 14 enablePopovers: true,
+4 -2
quartz/components/Backlinks.tsx
··· 3 3 import { resolveRelative, simplifySlug } from "../util/path" 4 4 import { i18n } from "../i18n" 5 5 import { classNames } from "../util/lang" 6 + import OverflowList from "./OverflowList" 6 7 7 8 interface BacklinksOptions { 8 9 hideWhenEmpty: boolean ··· 29 30 return ( 30 31 <div class={classNames(displayClass, "backlinks")}> 31 32 <h3>{i18n(cfg.locale).components.backlinks.title}</h3> 32 - <ul class="overflow"> 33 + <OverflowList id="backlinks-ul"> 33 34 {backlinkFiles.length > 0 ? ( 34 35 backlinkFiles.map((f) => ( 35 36 <li> ··· 41 42 ) : ( 42 43 <li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li> 43 44 )} 44 - </ul> 45 + </OverflowList> 45 46 </div> 46 47 ) 47 48 } 48 49 49 50 Backlinks.css = style 51 + Backlinks.afterDOMLoaded = OverflowList.afterDOMLoaded("backlinks-ul") 50 52 51 53 return Backlinks 52 54 }) satisfies QuartzComponentConstructor
+79 -73
quartz/components/Explorer.tsx
··· 3 3 4 4 // @ts-ignore 5 5 import script from "./scripts/explorer.inline" 6 - import { ExplorerNode, FileNode, Options } from "./ExplorerNode" 7 - import { QuartzPluginData } from "../plugins/vfile" 8 6 import { classNames } from "../util/lang" 9 7 import { i18n } from "../i18n" 8 + import { FileTrieNode } from "../util/fileTrie" 9 + import OverflowList from "./OverflowList" 10 10 11 - // Options interface defined in `ExplorerNode` to avoid circular dependency 12 - const defaultOptions = { 13 - folderClickBehavior: "collapse", 11 + type OrderEntries = "sort" | "filter" | "map" 12 + 13 + export interface Options { 14 + title?: string 15 + folderDefaultState: "collapsed" | "open" 16 + folderClickBehavior: "collapse" | "link" 17 + useSavedState: boolean 18 + sortFn: (a: FileTrieNode, b: FileTrieNode) => number 19 + filterFn: (node: FileTrieNode) => boolean 20 + mapFn: (node: FileTrieNode) => void 21 + order: OrderEntries[] 22 + } 23 + 24 + const defaultOptions: Options = { 14 25 folderDefaultState: "collapsed", 26 + folderClickBehavior: "collapse", 15 27 useSavedState: true, 16 28 mapFn: (node) => { 17 29 return node 18 30 }, 19 31 sortFn: (a, b) => { 20 - // Sort order: folders first, then files. Sort folders and files alphabetically 21 - if ((!a.file && !b.file) || (a.file && b.file)) { 32 + // Sort order: folders first, then files. Sort folders and files alphabeticall 33 + if ((!a.isFolder && !b.isFolder) || (a.isFolder && b.isFolder)) { 22 34 // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" 23 35 // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A 24 36 return a.displayName.localeCompare(b.displayName, undefined, { ··· 27 39 }) 28 40 } 29 41 30 - if (a.file && !b.file) { 42 + if (!a.isFolder && b.isFolder) { 31 43 return 1 32 44 } else { 33 45 return -1 34 46 } 35 47 }, 36 - filterFn: (node) => node.name !== "tags", 48 + filterFn: (node) => node.slugSegment !== "tags", 37 49 order: ["filter", "map", "sort"], 38 - } satisfies Options 50 + } 51 + 52 + export type FolderState = { 53 + path: string 54 + collapsed: boolean 55 + } 39 56 40 57 export default ((userOpts?: Partial<Options>) => { 41 - // Parse config 42 58 const opts: Options = { ...defaultOptions, ...userOpts } 43 59 44 - // memoized 45 - let fileTree: FileNode 46 - let jsonTree: string 47 - let lastBuildId: string = "" 48 - 49 - function constructFileTree(allFiles: QuartzPluginData[]) { 50 - // Construct tree from allFiles 51 - fileTree = new FileNode("") 52 - allFiles.forEach((file) => fileTree.add(file)) 53 - 54 - // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied) 55 - if (opts.order) { 56 - // Order is important, use loop with index instead of order.map() 57 - for (let i = 0; i < opts.order.length; i++) { 58 - const functionName = opts.order[i] 59 - if (functionName === "map") { 60 - fileTree.map(opts.mapFn) 61 - } else if (functionName === "sort") { 62 - fileTree.sort(opts.sortFn) 63 - } else if (functionName === "filter") { 64 - fileTree.filter(opts.filterFn) 65 - } 66 - } 67 - } 68 - 69 - // Get all folders of tree. Initialize with collapsed state 70 - // Stringify to pass json tree as data attribute ([data-tree]) 71 - const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") 72 - jsonTree = JSON.stringify(folders) 73 - } 74 - 75 - const Explorer: QuartzComponent = ({ 76 - ctx, 77 - cfg, 78 - allFiles, 79 - displayClass, 80 - fileData, 81 - }: QuartzComponentProps) => { 82 - if (ctx.buildId !== lastBuildId) { 83 - lastBuildId = ctx.buildId 84 - constructFileTree(allFiles) 85 - } 60 + const Explorer: QuartzComponent = ({ cfg, displayClass }: QuartzComponentProps) => { 86 61 return ( 87 - <div class={classNames(displayClass, "explorer")}> 62 + <div 63 + class={classNames(displayClass, "explorer")} 64 + data-behavior={opts.folderClickBehavior} 65 + data-collapsed={opts.folderDefaultState} 66 + data-savestate={opts.useSavedState} 67 + data-data-fns={JSON.stringify({ 68 + order: opts.order, 69 + sortFn: opts.sortFn.toString(), 70 + filterFn: opts.filterFn.toString(), 71 + mapFn: opts.mapFn.toString(), 72 + })} 73 + > 88 74 <button 89 75 type="button" 90 76 id="mobile-explorer" 91 - class="collapsed hide-until-loaded" 92 - data-behavior={opts.folderClickBehavior} 93 - data-collapsed={opts.folderDefaultState} 94 - data-savestate={opts.useSavedState} 95 - data-tree={jsonTree} 77 + class="explorer-toggle hide-until-loaded" 96 78 data-mobile={true} 97 79 aria-controls="explorer-content" 98 - aria-expanded={false} 99 80 > 100 81 <svg 101 82 xmlns="http://www.w3.org/2000/svg" ··· 105 86 stroke-width="2" 106 87 stroke-linecap="round" 107 88 stroke-linejoin="round" 108 - class="lucide lucide-menu" 89 + class="lucide-menu" 109 90 > 110 91 <line x1="4" x2="20" y1="12" y2="12" /> 111 92 <line x1="4" x2="20" y1="6" y2="6" /> ··· 115 96 <button 116 97 type="button" 117 98 id="desktop-explorer" 118 - class="title-button" 119 - data-behavior={opts.folderClickBehavior} 120 - data-collapsed={opts.folderDefaultState} 121 - data-savestate={opts.useSavedState} 122 - data-tree={jsonTree} 99 + class="title-button explorer-toggle" 123 100 data-mobile={false} 124 - aria-controls="explorer-content" 125 101 aria-expanded={true} 126 102 > 127 103 <h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2> ··· 140 116 <polyline points="6 9 12 15 18 9"></polyline> 141 117 </svg> 142 118 </button> 143 - <div id="explorer-content"> 144 - <ul class="overflow" id="explorer-ul"> 145 - <ExplorerNode node={fileTree} opts={opts} fileData={fileData} /> 146 - <li id="explorer-end" /> 147 - </ul> 119 + <div id="explorer-content" aria-expanded={false}> 120 + <OverflowList id="explorer-ul" /> 148 121 </div> 122 + <template id="template-file"> 123 + <li> 124 + <a href="#"></a> 125 + </li> 126 + </template> 127 + <template id="template-folder"> 128 + <li> 129 + <div class="folder-container"> 130 + <svg 131 + xmlns="http://www.w3.org/2000/svg" 132 + width="12" 133 + height="12" 134 + viewBox="5 8 14 8" 135 + fill="none" 136 + stroke="currentColor" 137 + stroke-width="2" 138 + stroke-linecap="round" 139 + stroke-linejoin="round" 140 + class="folder-icon" 141 + > 142 + <polyline points="6 9 12 15 18 9"></polyline> 143 + </svg> 144 + <div> 145 + <button class="folder-button"> 146 + <span class="folder-title"></span> 147 + </button> 148 + </div> 149 + </div> 150 + <div class="folder-outer"> 151 + <ul class="content"></ul> 152 + </div> 153 + </li> 154 + </template> 149 155 </div> 150 156 ) 151 157 } 152 158 153 159 Explorer.css = style 154 - Explorer.afterDOMLoaded = script 160 + Explorer.afterDOMLoaded = script + OverflowList.afterDOMLoaded("explorer-ul") 155 161 return Explorer 156 162 }) satisfies QuartzComponentConstructor
-242
quartz/components/ExplorerNode.tsx
··· 1 - // @ts-ignore 2 - import { QuartzPluginData } from "../plugins/vfile" 3 - import { 4 - joinSegments, 5 - resolveRelative, 6 - clone, 7 - simplifySlug, 8 - SimpleSlug, 9 - FilePath, 10 - } from "../util/path" 11 - 12 - type OrderEntries = "sort" | "filter" | "map" 13 - 14 - export interface Options { 15 - title?: string 16 - folderDefaultState: "collapsed" | "open" 17 - folderClickBehavior: "collapse" | "link" 18 - useSavedState: boolean 19 - sortFn: (a: FileNode, b: FileNode) => number 20 - filterFn: (node: FileNode) => boolean 21 - mapFn: (node: FileNode) => void 22 - order: OrderEntries[] 23 - } 24 - 25 - type DataWrapper = { 26 - file: QuartzPluginData 27 - path: string[] 28 - } 29 - 30 - export type FolderState = { 31 - path: string 32 - collapsed: boolean 33 - } 34 - 35 - function getPathSegment(fp: FilePath | undefined, idx: number): string | undefined { 36 - if (!fp) { 37 - return undefined 38 - } 39 - 40 - return fp.split("/").at(idx) 41 - } 42 - 43 - // Structure to add all files into a tree 44 - export class FileNode { 45 - children: Array<FileNode> 46 - name: string // this is the slug segment 47 - displayName: string 48 - file: QuartzPluginData | null 49 - depth: number 50 - 51 - constructor(slugSegment: string, displayName?: string, file?: QuartzPluginData, depth?: number) { 52 - this.children = [] 53 - this.name = slugSegment 54 - this.displayName = displayName ?? file?.frontmatter?.title ?? slugSegment 55 - this.file = file ? clone(file) : null 56 - this.depth = depth ?? 0 57 - } 58 - 59 - private insert(fileData: DataWrapper) { 60 - if (fileData.path.length === 0) { 61 - return 62 - } 63 - 64 - const nextSegment = fileData.path[0] 65 - 66 - // base case, insert here 67 - if (fileData.path.length === 1) { 68 - if (nextSegment === "") { 69 - // index case (we are the root and we just found index.md), set our data appropriately 70 - const title = fileData.file.frontmatter?.title 71 - if (title && title !== "index") { 72 - this.displayName = title 73 - } 74 - } else { 75 - // direct child 76 - this.children.push(new FileNode(nextSegment, undefined, fileData.file, this.depth + 1)) 77 - } 78 - 79 - return 80 - } 81 - 82 - // find the right child to insert into 83 - fileData.path = fileData.path.splice(1) 84 - const child = this.children.find((c) => c.name === nextSegment) 85 - if (child) { 86 - child.insert(fileData) 87 - return 88 - } 89 - 90 - const newChild = new FileNode( 91 - nextSegment, 92 - getPathSegment(fileData.file.relativePath, this.depth), 93 - undefined, 94 - this.depth + 1, 95 - ) 96 - newChild.insert(fileData) 97 - this.children.push(newChild) 98 - } 99 - 100 - // Add new file to tree 101 - add(file: QuartzPluginData) { 102 - this.insert({ file: file, path: simplifySlug(file.slug!).split("/") }) 103 - } 104 - 105 - /** 106 - * Filter FileNode tree. Behaves similar to `Array.prototype.filter()`, but modifies tree in place 107 - * @param filterFn function to filter tree with 108 - */ 109 - filter(filterFn: (node: FileNode) => boolean) { 110 - this.children = this.children.filter(filterFn) 111 - this.children.forEach((child) => child.filter(filterFn)) 112 - } 113 - 114 - /** 115 - * Filter FileNode tree. Behaves similar to `Array.prototype.map()`, but modifies tree in place 116 - * @param mapFn function to use for mapping over tree 117 - */ 118 - map(mapFn: (node: FileNode) => void) { 119 - mapFn(this) 120 - this.children.forEach((child) => child.map(mapFn)) 121 - } 122 - 123 - /** 124 - * Get folder representation with state of tree. 125 - * Intended to only be called on root node before changes to the tree are made 126 - * @param collapsed default state of folders (collapsed by default or not) 127 - * @returns array containing folder state for tree 128 - */ 129 - getFolderPaths(collapsed: boolean): FolderState[] { 130 - const folderPaths: FolderState[] = [] 131 - 132 - const traverse = (node: FileNode, currentPath: string) => { 133 - if (!node.file) { 134 - const folderPath = joinSegments(currentPath, node.name) 135 - if (folderPath !== "") { 136 - folderPaths.push({ path: folderPath, collapsed }) 137 - } 138 - 139 - node.children.forEach((child) => traverse(child, folderPath)) 140 - } 141 - } 142 - 143 - traverse(this, "") 144 - return folderPaths 145 - } 146 - 147 - // Sort order: folders first, then files. Sort folders and files alphabetically 148 - /** 149 - * Sorts tree according to sort/compare function 150 - * @param sortFn compare function used for `.sort()`, also used recursively for children 151 - */ 152 - sort(sortFn: (a: FileNode, b: FileNode) => number) { 153 - this.children = this.children.sort(sortFn) 154 - this.children.forEach((e) => e.sort(sortFn)) 155 - } 156 - } 157 - 158 - type ExplorerNodeProps = { 159 - node: FileNode 160 - opts: Options 161 - fileData: QuartzPluginData 162 - fullPath?: string 163 - } 164 - 165 - export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) { 166 - // Get options 167 - const folderBehavior = opts.folderClickBehavior 168 - const isDefaultOpen = opts.folderDefaultState === "open" 169 - 170 - // Calculate current folderPath 171 - const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : "" 172 - const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/" 173 - 174 - return ( 175 - <> 176 - {node.file ? ( 177 - // Single file node 178 - <li key={node.file.slug}> 179 - <a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}> 180 - {node.displayName} 181 - </a> 182 - </li> 183 - ) : ( 184 - <li> 185 - {node.name !== "" && ( 186 - // Node with entire folder 187 - // Render svg button + folder name, then children 188 - <div class="folder-container"> 189 - <svg 190 - xmlns="http://www.w3.org/2000/svg" 191 - width="12" 192 - height="12" 193 - viewBox="5 8 14 8" 194 - fill="none" 195 - stroke="currentColor" 196 - stroke-width="2" 197 - stroke-linecap="round" 198 - stroke-linejoin="round" 199 - class="folder-icon" 200 - > 201 - <polyline points="6 9 12 15 18 9"></polyline> 202 - </svg> 203 - {/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */} 204 - <div key={node.name} data-folderpath={folderPath}> 205 - {folderBehavior === "link" ? ( 206 - <a href={href} data-for={node.name} class="folder-title"> 207 - {node.displayName} 208 - </a> 209 - ) : ( 210 - <button class="folder-button"> 211 - <span class="folder-title">{node.displayName}</span> 212 - </button> 213 - )} 214 - </div> 215 - </div> 216 - )} 217 - {/* Recursively render children of folder */} 218 - <div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}> 219 - <ul 220 - // Inline style for left folder paddings 221 - style={{ 222 - paddingLeft: node.name !== "" ? "1.4rem" : "0", 223 - }} 224 - class="content" 225 - data-folderul={folderPath} 226 - > 227 - {node.children.map((childNode, i) => ( 228 - <ExplorerNode 229 - node={childNode} 230 - key={i} 231 - opts={opts} 232 - fullPath={folderPath} 233 - fileData={fileData} 234 - /> 235 - ))} 236 - </ul> 237 - </div> 238 - </li> 239 - )} 240 - </> 241 - ) 242 - }
+39
quartz/components/OverflowList.tsx
··· 1 + import { JSX } from "preact" 2 + 3 + const OverflowList = ({ 4 + children, 5 + ...props 6 + }: JSX.HTMLAttributes<HTMLUListElement> & { id: string }) => { 7 + return ( 8 + <ul class="overflow" {...props}> 9 + {children} 10 + <li class="overflow-end" /> 11 + </ul> 12 + ) 13 + } 14 + 15 + OverflowList.afterDOMLoaded = (id: string) => ` 16 + document.addEventListener("nav", (e) => { 17 + const observer = new IntersectionObserver((entries) => { 18 + for (const entry of entries) { 19 + const parentUl = entry.target.parentElement 20 + if (entry.isIntersecting) { 21 + parentUl.classList.remove("gradient-active") 22 + } else { 23 + parentUl.classList.add("gradient-active") 24 + } 25 + } 26 + }) 27 + 28 + const ul = document.getElementById("${id}") 29 + if (!ul) return 30 + 31 + const end = ul.querySelector(".overflow-end") 32 + if (!end) return 33 + 34 + observer.observe(end) 35 + window.addCleanup(() => observer.disconnect()) 36 + }) 37 + ` 38 + 39 + export default OverflowList
+4 -3
quartz/components/TableOfContents.tsx
··· 6 6 // @ts-ignore 7 7 import script from "./scripts/toc.inline" 8 8 import { i18n } from "../i18n" 9 + import OverflowList from "./OverflowList" 9 10 10 11 interface Options { 11 12 layout: "modern" | "legacy" ··· 50 51 </svg> 51 52 </button> 52 53 <div id="toc-content" class={fileData.collapseToc ? "collapsed" : ""}> 53 - <ul class="overflow"> 54 + <OverflowList id="toc-ul"> 54 55 {fileData.toc.map((tocEntry) => ( 55 56 <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}> 56 57 <a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}> ··· 58 59 </a> 59 60 </li> 60 61 ))} 61 - </ul> 62 + </OverflowList> 62 63 </div> 63 64 </div> 64 65 ) 65 66 } 66 67 TableOfContents.css = modernStyle 67 - TableOfContents.afterDOMLoaded = script 68 + TableOfContents.afterDOMLoaded = script + OverflowList.afterDOMLoaded("toc-ul") 68 69 69 70 const LegacyTableOfContents: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => { 70 71 if (!fileData.toc) {
+2 -1
quartz/components/renderPage.tsx
··· 3 3 import HeaderConstructor from "./Header" 4 4 import BodyConstructor from "./Body" 5 5 import { JSResourceToScriptElement, StaticResources } from "../util/resources" 6 - import { clone, FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" 6 + import { FullSlug, RelativeURL, joinSegments, normalizeHastElement } from "../util/path" 7 + import { clone } from "../util/clone" 7 8 import { visit } from "unist-util-visit" 8 9 import { Root, Element, ElementContent } from "hast" 9 10 import { GlobalConfiguration } from "../cfg"
+220 -157
quartz/components/scripts/explorer.inline.ts
··· 1 - import { FolderState } from "../ExplorerNode" 1 + import { FileTrieNode } from "../../util/fileTrie" 2 + import { FullSlug, resolveRelative, simplifySlug } from "../../util/path" 3 + import { ContentDetails } from "../../plugins/emitters/contentIndex" 2 4 3 - // Current state of folders 4 5 type MaybeHTMLElement = HTMLElement | undefined 5 - let currentExplorerState: FolderState[] 6 6 7 - const observer = new IntersectionObserver((entries) => { 8 - // If last element is observed, remove gradient of "overflow" class so element is visible 9 - const explorerUl = document.getElementById("explorer-ul") 10 - if (!explorerUl) return 11 - for (const entry of entries) { 12 - if (entry.isIntersecting) { 13 - explorerUl.classList.add("no-background") 14 - } else { 15 - explorerUl.classList.remove("no-background") 16 - } 17 - } 18 - }) 7 + interface ParsedOptions { 8 + folderClickBehavior: "collapse" | "link" 9 + folderDefaultState: "collapsed" | "open" 10 + useSavedState: boolean 11 + sortFn: (a: FileTrieNode, b: FileTrieNode) => number 12 + filterFn: (node: FileTrieNode) => boolean 13 + mapFn: (node: FileTrieNode) => void 14 + order: "sort" | "filter" | "map"[] 15 + } 19 16 20 - function toggleExplorer(this: HTMLElement) { 21 - // Toggle collapsed state of entire explorer 22 - this.classList.toggle("collapsed") 17 + type FolderState = { 18 + path: string 19 + collapsed: boolean 20 + } 23 21 24 - // Toggle collapsed aria state of entire explorer 25 - this.setAttribute( 26 - "aria-expanded", 27 - this.getAttribute("aria-expanded") === "true" ? "false" : "true", 28 - ) 29 - 30 - const content = ( 31 - this.nextElementSibling?.nextElementSibling 32 - ? this.nextElementSibling.nextElementSibling 33 - : this.nextElementSibling 34 - ) as MaybeHTMLElement 35 - if (!content) return 36 - content.classList.toggle("collapsed") 37 - content.classList.toggle("explorer-viewmode") 38 - 39 - // Prevent scroll under 40 - if (document.querySelector("#mobile-explorer")) { 41 - // Disable scrolling on the page when the explorer is opened on mobile 42 - const bodySelector = document.querySelector("#quartz-body") 43 - if (bodySelector) bodySelector.classList.toggle("lock-scroll") 22 + let currentExplorerState: Array<FolderState> 23 + function toggleExplorer(this: HTMLElement) { 24 + const explorers = document.querySelectorAll(".explorer") 25 + for (const explorer of explorers) { 26 + explorer.classList.toggle("collapsed") 27 + explorer.setAttribute( 28 + "aria-expanded", 29 + explorer.getAttribute("aria-expanded") === "true" ? "false" : "true", 30 + ) 44 31 } 45 32 } 46 33 47 34 function toggleFolder(evt: MouseEvent) { 48 35 evt.stopPropagation() 49 - 50 - // Element that was clicked 51 36 const target = evt.target as MaybeHTMLElement 52 37 if (!target) return 53 38 ··· 55 40 const isSvg = target.nodeName === "svg" 56 41 57 42 // corresponding <ul> element relative to clicked button/folder 58 - const childFolderContainer = ( 43 + const folderContainer = ( 59 44 isSvg 60 - ? target.parentElement?.nextSibling 61 - : target.parentElement?.parentElement?.nextElementSibling 45 + ? // svg -> div.folder-container 46 + target.parentElement 47 + : // button.folder-button -> div -> div.folder-container 48 + target.parentElement?.parentElement 62 49 ) as MaybeHTMLElement 63 - const currentFolderParent = ( 64 - isSvg ? target.nextElementSibling : target.parentElement 65 - ) as MaybeHTMLElement 66 - if (!(childFolderContainer && currentFolderParent)) return 67 - // <li> element of folder (stores folder-path dataset) 50 + if (!folderContainer) return 51 + const childFolderContainer = folderContainer.nextElementSibling as MaybeHTMLElement 52 + if (!childFolderContainer) return 53 + 68 54 childFolderContainer.classList.toggle("open") 69 55 70 56 // Collapse folder container 71 - const isCollapsed = childFolderContainer.classList.contains("open") 72 - setFolderState(childFolderContainer, !isCollapsed) 57 + const isCollapsed = !childFolderContainer.classList.contains("open") 58 + setFolderState(childFolderContainer, isCollapsed) 59 + 60 + const currentFolderState = currentExplorerState.find( 61 + (item) => item.path === folderContainer.dataset.folderpath, 62 + ) 63 + if (currentFolderState) { 64 + currentFolderState.collapsed = isCollapsed 65 + } else { 66 + currentExplorerState.push({ 67 + path: folderContainer.dataset.folderpath as FullSlug, 68 + collapsed: isCollapsed, 69 + }) 70 + } 73 71 74 - // Save folder state to localStorage 75 - const fullFolderPath = currentFolderParent.dataset.folderpath as string 76 - toggleCollapsedByPath(currentExplorerState, fullFolderPath) 77 72 const stringifiedFileTree = JSON.stringify(currentExplorerState) 78 73 localStorage.setItem("fileTree", stringifiedFileTree) 79 74 } 80 75 81 - function setupExplorer() { 82 - // Set click handler for collapsing entire explorer 83 - const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement> 76 + function createFileNode(currentSlug: FullSlug, node: FileTrieNode): HTMLLIElement { 77 + const template = document.getElementById("template-file") as HTMLTemplateElement 78 + const clone = template.content.cloneNode(true) as DocumentFragment 79 + const li = clone.querySelector("li") as HTMLLIElement 80 + const a = li.querySelector("a") as HTMLAnchorElement 81 + a.href = resolveRelative(currentSlug, node.data?.slug!) 82 + a.dataset.for = node.data?.slug 83 + a.textContent = node.displayName 84 + 85 + if (currentSlug === node.data?.slug) { 86 + a.classList.add("active") 87 + } 88 + 89 + return li 90 + } 91 + 92 + function createFolderNode( 93 + currentSlug: FullSlug, 94 + node: FileTrieNode, 95 + opts: ParsedOptions, 96 + ): HTMLLIElement { 97 + const template = document.getElementById("template-folder") as HTMLTemplateElement 98 + const clone = template.content.cloneNode(true) as DocumentFragment 99 + const li = clone.querySelector("li") as HTMLLIElement 100 + const folderContainer = li.querySelector(".folder-container") as HTMLElement 101 + const titleContainer = folderContainer.querySelector("div") as HTMLElement 102 + const folderOuter = li.querySelector(".folder-outer") as HTMLElement 103 + const ul = folderOuter.querySelector("ul") as HTMLUListElement 104 + 105 + const folderPath = node.data?.slug! 106 + folderContainer.dataset.folderpath = folderPath 107 + 108 + if (opts.folderClickBehavior === "link") { 109 + // Replace button with link for link behavior 110 + const button = titleContainer.querySelector(".folder-button") as HTMLElement 111 + const a = document.createElement("a") 112 + a.href = resolveRelative(currentSlug, folderPath) 113 + a.dataset.for = node.data?.slug 114 + a.className = "folder-title" 115 + a.textContent = node.displayName 116 + button.replaceWith(a) 117 + } else { 118 + const span = titleContainer.querySelector(".folder-title") as HTMLElement 119 + span.textContent = node.displayName 120 + } 121 + 122 + // if the saved state is collapsed or the default state is collapsed 123 + const isCollapsed = 124 + currentExplorerState.find((item) => item.path === folderPath)?.collapsed ?? 125 + opts.folderDefaultState === "collapsed" 126 + 127 + // if this folder is a prefix of the current path we 128 + // want to open it anyways 129 + const simpleFolderPath = simplifySlug(folderPath) 130 + const folderIsPrefixOfCurrentSlug = 131 + simpleFolderPath === currentSlug.slice(0, simpleFolderPath.length) 132 + 133 + if (!isCollapsed || folderIsPrefixOfCurrentSlug) { 134 + folderOuter.classList.add("open") 135 + } 136 + 137 + for (const child of node.children) { 138 + const childNode = child.data 139 + ? createFileNode(currentSlug, child) 140 + : createFolderNode(currentSlug, child, opts) 141 + ul.appendChild(childNode) 142 + } 143 + 144 + return li 145 + } 146 + 147 + async function setupExplorer(currentSlug: FullSlug) { 148 + const allExplorers = document.querySelectorAll(".explorer") as NodeListOf<HTMLElement> 84 149 85 150 for (const explorer of allExplorers) { 151 + const dataFns = JSON.parse(explorer.dataset.dataFns || "{}") 152 + const opts: ParsedOptions = { 153 + folderClickBehavior: (explorer.dataset.behavior || "collapse") as "collapse" | "link", 154 + folderDefaultState: (explorer.dataset.collapsed || "collapsed") as "collapsed" | "open", 155 + useSavedState: explorer.dataset.savestate === "true", 156 + order: dataFns.order || ["filter", "map", "sort"], 157 + sortFn: new Function("return " + (dataFns.sortFn || "undefined"))(), 158 + filterFn: new Function("return " + (dataFns.filterFn || "undefined"))(), 159 + mapFn: new Function("return " + (dataFns.mapFn || "undefined"))(), 160 + } 161 + 86 162 // Get folder state from local storage 87 163 const storageTree = localStorage.getItem("fileTree") 88 - 89 - // Convert to bool 90 - const useSavedFolderState = explorer?.dataset.savestate === "true" 164 + const serializedExplorerState = storageTree && opts.useSavedState ? JSON.parse(storageTree) : [] 165 + const oldIndex = new Map( 166 + serializedExplorerState.map((entry: FolderState) => [entry.path, entry.collapsed]), 167 + ) 91 168 92 - if (explorer) { 93 - // Get config 94 - const collapseBehavior = explorer.dataset.behavior 169 + const data = await fetchData 170 + const entries = [...Object.entries(data)] as [FullSlug, ContentDetails][] 171 + const trie = FileTrieNode.fromEntries(entries) 95 172 96 - // Add click handlers for all folders (click handler on folder "label") 97 - if (collapseBehavior === "collapse") { 98 - for (const item of document.getElementsByClassName( 99 - "folder-button", 100 - ) as HTMLCollectionOf<HTMLElement>) { 101 - window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer)) 102 - item.addEventListener("click", toggleFolder) 103 - } 173 + // Apply functions in order 174 + for (const fn of opts.order) { 175 + switch (fn) { 176 + case "filter": 177 + if (opts.filterFn) trie.filter(opts.filterFn) 178 + break 179 + case "map": 180 + if (opts.mapFn) trie.map(opts.mapFn) 181 + break 182 + case "sort": 183 + if (opts.sortFn) trie.sort(opts.sortFn) 184 + break 104 185 } 105 - 106 - // Add click handler to main explorer 107 - window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer)) 108 - explorer.addEventListener("click", toggleExplorer) 109 186 } 110 187 111 - // Set up click handlers for each folder (click handler on folder "icon") 112 - for (const item of document.getElementsByClassName( 113 - "folder-icon", 114 - ) as HTMLCollectionOf<HTMLElement>) { 115 - item.addEventListener("click", toggleFolder) 116 - window.addCleanup(() => item.removeEventListener("click", toggleFolder)) 117 - } 188 + // Get folder paths for state management 189 + const folderPaths = trie.getFolderPaths() 190 + currentExplorerState = folderPaths.map((path) => ({ 191 + path, 192 + collapsed: oldIndex.get(path) === true, 193 + })) 118 194 119 - // Get folder state from local storage 120 - const oldExplorerState: FolderState[] = 121 - storageTree && useSavedFolderState ? JSON.parse(storageTree) : [] 122 - const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed])) 123 - const newExplorerState: FolderState[] = explorer.dataset.tree 124 - ? JSON.parse(explorer.dataset.tree) 125 - : [] 126 - currentExplorerState = [] 195 + const explorerUl = document.getElementById("explorer-ul") 196 + if (!explorerUl) continue 127 197 128 - for (const { path, collapsed } of newExplorerState) { 129 - currentExplorerState.push({ 130 - path, 131 - collapsed: oldIndex.get(path) ?? collapsed, 132 - }) 198 + // Create and insert new content 199 + const fragment = document.createDocumentFragment() 200 + for (const child of trie.children) { 201 + const node = child.isFolder 202 + ? createFolderNode(currentSlug, child, opts) 203 + : createFileNode(currentSlug, child) 204 + 205 + fragment.appendChild(node) 133 206 } 207 + explorerUl.insertBefore(fragment, explorerUl.firstChild) 134 208 135 - currentExplorerState.map((folderState) => { 136 - const folderLi = document.querySelector( 137 - `[data-folderpath='${folderState.path.replace("'", "-")}']`, 138 - ) as MaybeHTMLElement 139 - const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement 140 - if (folderUl) { 141 - setFolderState(folderUl, folderState.collapsed) 209 + // restore explorer scrollTop position if it exists 210 + const scrollTop = sessionStorage.getItem("explorerScrollTop") 211 + if (scrollTop) { 212 + explorerUl.scrollTop = parseInt(scrollTop) 213 + } else { 214 + // try to scroll to the active element if it exists 215 + const activeElement = explorerUl.querySelector(".active") 216 + if (activeElement) { 217 + activeElement.scrollIntoView({ behavior: "smooth" }) 142 218 } 143 - }) 144 - } 145 - } 219 + } 146 220 147 - function toggleExplorerFolders() { 148 - const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace( 149 - /\/index$/g, 150 - "", 151 - ) 152 - const allFolders = document.querySelectorAll(".folder-outer") 221 + // Set up event handlers 222 + const explorerButtons = explorer.querySelectorAll( 223 + "button.explorer-toggle", 224 + ) as NodeListOf<HTMLElement> 225 + if (explorerButtons) { 226 + window.addCleanup(() => 227 + explorerButtons.forEach((button) => button.removeEventListener("click", toggleExplorer)), 228 + ) 229 + explorerButtons.forEach((button) => button.addEventListener("click", toggleExplorer)) 230 + } 153 231 154 - allFolders.forEach((element) => { 155 - const folderUl = Array.from(element.children).find((child) => 156 - child.matches("ul[data-folderul]"), 157 - ) 158 - if (folderUl) { 159 - if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) { 160 - if (!element.classList.contains("open")) { 161 - element.classList.add("open") 162 - } 232 + // Set up folder click handlers 233 + if (opts.folderClickBehavior === "collapse") { 234 + const folderButtons = explorer.getElementsByClassName( 235 + "folder-button", 236 + ) as HTMLCollectionOf<HTMLElement> 237 + for (const button of folderButtons) { 238 + window.addCleanup(() => button.removeEventListener("click", toggleFolder)) 239 + button.addEventListener("click", toggleFolder) 163 240 } 164 241 } 165 - }) 166 - } 167 242 168 - window.addEventListener("resize", setupExplorer) 169 - 170 - document.addEventListener("nav", () => { 171 - const explorer = document.querySelector("#mobile-explorer") 172 - if (explorer) { 173 - explorer.classList.add("collapsed") 174 - const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement 175 - if (content) { 176 - content.classList.add("collapsed") 177 - content.classList.toggle("explorer-viewmode") 243 + const folderIcons = explorer.getElementsByClassName( 244 + "folder-icon", 245 + ) as HTMLCollectionOf<HTMLElement> 246 + for (const icon of folderIcons) { 247 + window.addCleanup(() => icon.removeEventListener("click", toggleFolder)) 248 + icon.addEventListener("click", toggleFolder) 178 249 } 179 250 } 180 - setupExplorer() 251 + } 181 252 182 - observer.disconnect() 253 + document.addEventListener("prenav", async (e: CustomEventMap["prenav"]) => { 254 + // save explorer scrollTop position 255 + const explorer = document.getElementById("explorer-ul") 256 + if (!explorer) return 257 + sessionStorage.setItem("explorerScrollTop", explorer.scrollTop.toString()) 258 + }) 183 259 184 - // select pseudo element at end of list 185 - const lastItem = document.getElementById("explorer-end") 186 - if (lastItem) { 187 - observer.observe(lastItem) 260 + document.addEventListener("nav", async (e: CustomEventMap["nav"]) => { 261 + const currentSlug = e.detail.url 262 + await setupExplorer(currentSlug) 263 + 264 + // if mobile hamburger is visible, collapse by default 265 + const mobileExplorer = document.getElementById("mobile-explorer") 266 + if (mobileExplorer && mobileExplorer.checkVisibility()) { 267 + for (const explorer of document.querySelectorAll(".explorer")) { 268 + explorer.classList.add("collapsed") 269 + explorer.setAttribute("aria-expanded", "false") 270 + } 188 271 } 189 272 190 - // Hide explorer on mobile until it is requested 191 273 const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer") 192 274 hiddenUntilDoneLoading?.classList.remove("hide-until-loaded") 193 - 194 - toggleExplorerFolders() 195 275 }) 196 276 197 - /** 198 - * Toggles the state of a given folder 199 - * @param folderElement <div class="folder-outer"> Element of folder (parent) 200 - * @param collapsed if folder should be set to collapsed or not 201 - */ 202 277 function setFolderState(folderElement: HTMLElement, collapsed: boolean) { 203 278 return collapsed ? folderElement.classList.remove("open") : folderElement.classList.add("open") 204 279 } 205 - 206 - /** 207 - * Toggles visibility of a folder 208 - * @param array array of FolderState (`fileTree`, either get from local storage or data attribute) 209 - * @param path path to folder (e.g. 'advanced/more/more2') 210 - */ 211 - function toggleCollapsedByPath(array: FolderState[], path: string) { 212 - const entry = array.find((item) => item.path === path) 213 - if (entry) { 214 - entry.collapsed = !entry.collapsed 215 - } 216 - }
+5 -1
quartz/components/scripts/spa.inline.ts
··· 75 75 76 76 if (!contents) return 77 77 78 + // notify about to nav 79 + const event: CustomEventMap["prenav"] = new CustomEvent("prenav", { detail: {} }) 80 + document.dispatchEvent(event) 81 + 78 82 // cleanup old 79 83 cleanupFns.forEach((fn) => fn()) 80 84 cleanupFns.clear() ··· 108 112 } 109 113 } 110 114 111 - // now, patch head 115 + // now, patch head, re-executing scripts 112 116 const elementsToRemove = document.head.querySelectorAll(":not([spa-preserve])") 113 117 elementsToRemove.forEach((el) => el.remove()) 114 118 const elementsToAdd = html.head.querySelectorAll(":not([spa-preserve])")
-2
quartz/components/scripts/toc.inline.ts
··· 1 - const bufferPx = 150 2 1 const observer = new IntersectionObserver((entries) => { 3 2 for (const entry of entries) { 4 3 const slug = entry.target.id ··· 28 27 function setupToc() { 29 28 const toc = document.getElementById("toc") 30 29 if (toc) { 31 - const collapsed = toc.classList.contains("collapsed") 32 30 const content = toc.nextElementSibling as HTMLElement | undefined 33 31 if (!content) return 34 32 toc.addEventListener("click", toggleToc)
+1
quartz/components/scripts/util.ts
··· 37 37 if (!res.headers.get("content-type")?.startsWith("text/html")) { 38 38 return res 39 39 } 40 + 40 41 // reading the body can only be done once, so we need to clone the response 41 42 // to allow the caller to read it if it's was not a redirect 42 43 const text = await res.clone().text()
-22
quartz/components/styles/backlinks.scss
··· 2 2 3 3 .backlinks { 4 4 flex-direction: column; 5 - /*&:after { 6 - pointer-events: none; 7 - content: ""; 8 - width: 100%; 9 - height: 50px; 10 - position: absolute; 11 - left: 0; 12 - bottom: 0; 13 - opacity: 1; 14 - transition: opacity 0.3s ease; 15 - background: linear-gradient(transparent 0px, var(--light)); 16 - }*/ 17 5 18 6 & > h3 { 19 7 font-size: 1rem; ··· 29 17 & > a { 30 18 background-color: transparent; 31 19 } 32 - } 33 - } 34 - 35 - & > .overflow { 36 - &:after { 37 - display: none; 38 - } 39 - height: auto; 40 - @media all and not ($desktop) { 41 - height: 250px; 42 20 } 43 21 } 44 22 }
+1
quartz/components/styles/darkmode.scss
··· 8 8 height: 20px; 9 9 margin: 0 10px; 10 10 text-align: inherit; 11 + flex-shrink: 0; 11 12 12 13 & svg { 13 14 position: absolute;
+69 -107
quartz/components/styles/explorer.scss
··· 16 16 box-sizing: border-box; 17 17 position: sticky; 18 18 background-color: var(--light); 19 + padding: 1rem 0 1rem 0; 20 + margin: 0; 19 21 } 20 22 21 - // Hide Explorer on mobile until done loading. 22 - // Prevents ugly animation on page load. 23 23 .hide-until-loaded ~ #explorer-content { 24 24 display: none; 25 25 } ··· 28 28 29 29 .explorer { 30 30 display: flex; 31 - height: 100%; 32 31 flex-direction: column; 33 32 overflow-y: hidden; 33 + flex: 0 1 auto; 34 + &.collapsed { 35 + flex: 0 1 1.2rem; 36 + & .fold { 37 + transform: rotateZ(-90deg); 38 + } 39 + } 40 + 41 + & .fold { 42 + margin-left: 0.5rem; 43 + transition: transform 0.3s ease; 44 + opacity: 0.8; 45 + } 34 46 35 47 @media all and ($mobile) { 36 48 order: -1; ··· 64 76 } 65 77 } 66 78 67 - /*&:after { 68 - pointer-events: none; 69 - content: ""; 70 - width: 100%; 71 - height: 50px; 72 - position: absolute; 73 - left: 0; 74 - bottom: 0; 75 - opacity: 1; 76 - transition: opacity 0.3s ease; 77 - background: linear-gradient(transparent 0px, var(--light)); 78 - }*/ 79 + svg { 80 + pointer-events: all; 81 + transition: transform 0.35s ease; 82 + 83 + & > polyline { 84 + pointer-events: none; 85 + } 86 + } 79 87 } 80 88 81 89 button#mobile-explorer, ··· 94 102 display: inline-block; 95 103 margin: 0; 96 104 } 97 - 98 - & .fold { 99 - margin-left: 0.5rem; 100 - transition: transform 0.3s ease; 101 - opacity: 0.8; 102 - } 103 - 104 - &.collapsed .fold { 105 - transform: rotateZ(-90deg); 106 - } 107 - } 108 - 109 - .folder-outer { 110 - display: grid; 111 - grid-template-rows: 0fr; 112 - transition: grid-template-rows 0.3s ease-in-out; 113 - } 114 - 115 - .folder-outer.open { 116 - grid-template-rows: 1fr; 117 - } 118 - 119 - .folder-outer > ul { 120 - overflow: hidden; 121 105 } 122 106 123 107 #explorer-content { 124 108 list-style: none; 125 109 overflow: hidden; 126 110 overflow-y: auto; 127 - max-height: 0px; 128 - transition: 129 - max-height 0.35s ease, 130 - visibility 0s linear 0.35s; 131 111 margin-top: 0.5rem; 132 - visibility: hidden; 133 - 134 - &.collapsed { 135 - max-height: 100%; 136 - transition: 137 - max-height 0.35s ease, 138 - visibility 0s linear 0s; 139 - visibility: visible; 140 - } 141 112 142 113 & ul { 143 114 list-style: none; 144 - margin: 0.08rem 0; 115 + margin: 0; 145 116 padding: 0; 146 - transition: 147 - max-height 0.35s ease, 148 - transform 0.35s ease, 149 - opacity 0.2s ease; 150 117 151 118 & li > a { 152 119 color: var(--dark); 153 120 opacity: 0.75; 154 121 pointer-events: all; 122 + 123 + &.active { 124 + opacity: 1; 125 + color: var(--tertiary); 126 + } 155 127 } 156 128 } 157 129 158 - > #explorer-ul { 159 - max-height: none; 130 + .folder-outer { 131 + display: grid; 132 + grid-template-rows: 0fr; 133 + transition: grid-template-rows 0.3s ease-in-out; 160 134 } 161 - } 162 135 163 - svg { 164 - pointer-events: all; 136 + .folder-outer.open { 137 + grid-template-rows: 1fr; 138 + } 165 139 166 - & > polyline { 167 - pointer-events: none; 140 + .folder-outer > ul { 141 + overflow: hidden; 142 + margin-left: 6px; 143 + padding-left: 0.8rem; 144 + border-left: 1px solid var(--lightgray); 168 145 } 169 146 } 170 147 ··· 227 204 color: var(--tertiary); 228 205 } 229 206 230 - .no-background::after { 231 - background: none !important; 232 - } 207 + .explorer { 208 + @media all and ($mobile) { 209 + &.collapsed { 210 + flex: 0 0 34px; 233 211 234 - #explorer-end { 235 - // needs height so IntersectionObserver gets triggered 236 - height: 4px; 237 - // remove default margin from li 238 - margin: 0; 239 - } 212 + & > #explorer-content { 213 + transform: translateX(-100vw); 214 + visibility: hidden; 215 + } 216 + } 217 + 218 + &:not(.collapsed) { 219 + flex: 0 0 34px; 240 220 241 - .explorer { 242 - @media all and ($mobile) { 221 + & > #explorer-content { 222 + transform: translateX(0); 223 + visibility: visible; 224 + } 225 + } 226 + 243 227 #explorer-content { 244 228 box-sizing: border-box; 245 - overscroll-behavior: none; 246 229 z-index: 100; 247 230 position: absolute; 248 231 top: 0; 232 + left: 0; 233 + margin-top: 0; 249 234 background-color: var(--light); 250 - max-width: 100dvw; 251 - left: -100dvw; 235 + max-width: 100vw; 252 236 width: 100%; 253 - transition: transform 300ms ease-in-out; 237 + transform: translateX(-100vw); 238 + transition: 239 + transform 200ms ease, 240 + visibility 200ms ease; 254 241 overflow: hidden; 255 - padding: $topSpacing 2rem 2rem; 242 + padding: 4rem 0 2rem 0; 256 243 height: 100dvh; 257 244 max-height: 100dvh; 258 - margin-top: 0; 259 245 visibility: hidden; 260 - 261 - &:not(.collapsed) { 262 - transform: translateX(100dvw); 263 - visibility: visible; 264 - } 265 - 266 - ul.overflow { 267 - max-height: 100%; 268 - width: 100%; 269 - } 270 - 271 - &.collapsed { 272 - transform: translateX(0); 273 - visibility: visible; 274 - } 275 246 } 276 247 277 248 #mobile-explorer { 278 - margin: 5px; 249 + margin: 0; 250 + padding: 5px; 279 251 z-index: 101; 280 - 281 - &:not(.collapsed) .lucide-menu { 282 - transform: rotate(-90deg); 283 - transition: transform 200ms ease-in-out; 284 - } 285 252 286 253 .lucide-menu { 287 254 stroke: var(--darkgray); 288 - transition: transform 200ms ease; 289 - 290 - &:hover { 291 - stroke: var(--dark); 292 - } 293 255 } 294 256 } 295 257 }
+4 -25
quartz/components/styles/toc.scss
··· 4 4 display: flex; 5 5 flex-direction: column; 6 6 7 - &.desktop-only { 8 - max-height: 40%; 7 + overflow-y: hidden; 8 + flex: 0 1 auto; 9 + &:has(button#toc.collapsed) { 10 + flex: 0 1 1.2rem; 9 11 } 10 12 } 11 13 ··· 44 46 45 47 #toc-content { 46 48 list-style: none; 47 - overflow: hidden; 48 - overflow-y: auto; 49 - max-height: 100%; 50 - transition: 51 - max-height 0.35s ease, 52 - visibility 0s linear 0s; 53 49 position: relative; 54 - visibility: visible; 55 - 56 - &.collapsed { 57 - max-height: 0; 58 - transition: 59 - max-height 0.35s ease, 60 - visibility 0s linear 0.35s; 61 - visibility: hidden; 62 - } 63 - 64 - &.collapsed > .overflow::after { 65 - opacity: 0; 66 - } 67 50 68 51 & ul { 69 52 list-style: none; ··· 79 62 opacity: 0.75; 80 63 } 81 64 } 82 - } 83 - > ul.overflow { 84 - max-height: none; 85 - width: 100%; 86 65 } 87 66 88 67 @for $i from 0 through 6 {
+2
quartz/plugins/emitters/contentIndex.tsx
··· 11 11 12 12 export type ContentIndexMap = Map<FullSlug, ContentDetails> 13 13 export type ContentDetails = { 14 + slug: FullSlug 14 15 title: string 15 16 links: SimpleSlug[] 16 17 tags: string[] ··· 124 125 const date = getDate(ctx.cfg.configuration, file.data) ?? new Date() 125 126 if (opts?.includeEmptyFiles || (file.data.text && file.data.text !== "")) { 126 127 linkIndex.set(slug, { 128 + slug, 127 129 title: file.data.frontmatter?.title!, 128 130 links: file.data.links ?? [], 129 131 tags: file.data.frontmatter?.tags ?? [],
+10 -13
quartz/styles/base.scss
··· 543 543 544 544 div:has(> .overflow) { 545 545 display: flex; 546 - overflow-y: auto; 547 546 max-height: 100%; 548 547 } 549 548 ··· 551 550 ol.overflow { 552 551 max-height: 100%; 553 552 overflow-y: auto; 553 + width: 100%; 554 554 555 555 // clearfix 556 556 content: ""; ··· 559 559 & > li:last-of-type { 560 560 margin-bottom: 30px; 561 561 } 562 - /*&:after { 563 - pointer-events: none; 564 - content: ""; 565 - width: 100%; 566 - height: 50px; 567 - position: absolute; 568 - left: 0; 569 - bottom: 0; 570 - opacity: 1; 571 - transition: opacity 0.3s ease; 572 - background: linear-gradient(transparent 0px, var(--light)); 573 - }*/ 562 + 563 + & > li.overflow-end { 564 + height: 4px; 565 + margin: 0; 566 + } 567 + 568 + &.gradient-active { 569 + mask-image: linear-gradient(to bottom, black calc(100% - 50px), transparent 100%); 570 + } 574 571 } 575 572 576 573 .transclude {
+3
quartz/util/clone.ts
··· 1 + import rfdc from "rfdc" 2 + 3 + export const clone = rfdc()
+190
quartz/util/fileTrie.test.ts
··· 1 + import test, { describe, beforeEach } from "node:test" 2 + import assert from "node:assert" 3 + import { FileTrieNode } from "./fileTrie" 4 + 5 + interface TestData { 6 + title: string 7 + slug: string 8 + } 9 + 10 + describe("FileTrie", () => { 11 + let trie: FileTrieNode<TestData> 12 + 13 + beforeEach(() => { 14 + trie = new FileTrieNode<TestData>("") 15 + }) 16 + 17 + describe("constructor", () => { 18 + test("should create an empty trie", () => { 19 + assert.deepStrictEqual(trie.children, []) 20 + assert.strictEqual(trie.slugSegment, "") 21 + assert.strictEqual(trie.displayName, "") 22 + assert.strictEqual(trie.data, null) 23 + assert.strictEqual(trie.depth, 0) 24 + }) 25 + 26 + test("should set displayName from data title", () => { 27 + const data = { 28 + title: "Test Title", 29 + slug: "test", 30 + } 31 + 32 + trie.add(data) 33 + assert.strictEqual(trie.children[0].displayName, "Test Title") 34 + }) 35 + }) 36 + 37 + describe("add", () => { 38 + test("should add a file at root level", () => { 39 + const data = { 40 + title: "Test", 41 + slug: "test", 42 + } 43 + 44 + trie.add(data) 45 + assert.strictEqual(trie.children.length, 1) 46 + assert.strictEqual(trie.children[0].slugSegment, "test") 47 + assert.strictEqual(trie.children[0].data, data) 48 + }) 49 + 50 + test("should handle index files", () => { 51 + const data = { 52 + title: "Index", 53 + slug: "index", 54 + } 55 + 56 + trie.add(data) 57 + assert.strictEqual(trie.data, data) 58 + assert.strictEqual(trie.children.length, 0) 59 + }) 60 + 61 + test("should add nested files", () => { 62 + const data1 = { 63 + title: "Nested", 64 + slug: "folder/test", 65 + } 66 + 67 + const data2 = { 68 + title: "Really nested index", 69 + slug: "a/b/c/index", 70 + } 71 + 72 + trie.add(data1) 73 + trie.add(data2) 74 + assert.strictEqual(trie.children.length, 2) 75 + assert.strictEqual(trie.children[0].slugSegment, "folder") 76 + assert.strictEqual(trie.children[0].children.length, 1) 77 + assert.strictEqual(trie.children[0].children[0].slugSegment, "test") 78 + assert.strictEqual(trie.children[0].children[0].data, data1) 79 + 80 + assert.strictEqual(trie.children[1].slugSegment, "a") 81 + assert.strictEqual(trie.children[1].children.length, 1) 82 + assert.strictEqual(trie.children[1].data, null) 83 + 84 + assert.strictEqual(trie.children[1].children[0].slugSegment, "b") 85 + assert.strictEqual(trie.children[1].children[0].children.length, 1) 86 + assert.strictEqual(trie.children[1].children[0].data, null) 87 + 88 + assert.strictEqual(trie.children[1].children[0].children[0].slugSegment, "c") 89 + assert.strictEqual(trie.children[1].children[0].children[0].data, data2) 90 + assert.strictEqual(trie.children[1].children[0].children[0].children.length, 0) 91 + }) 92 + }) 93 + 94 + describe("filter", () => { 95 + test("should filter nodes based on condition", () => { 96 + const data1 = { title: "Test1", slug: "test1" } 97 + const data2 = { title: "Test2", slug: "test2" } 98 + 99 + trie.add(data1) 100 + trie.add(data2) 101 + 102 + trie.filter((node) => node.slugSegment !== "test1") 103 + assert.strictEqual(trie.children.length, 1) 104 + assert.strictEqual(trie.children[0].slugSegment, "test2") 105 + }) 106 + }) 107 + 108 + describe("map", () => { 109 + test("should apply function to all nodes", () => { 110 + const data1 = { title: "Test1", slug: "test1" } 111 + const data2 = { title: "Test2", slug: "test2" } 112 + 113 + trie.add(data1) 114 + trie.add(data2) 115 + 116 + trie.map((node) => { 117 + if (node.data) { 118 + node.displayName = "Modified" 119 + } 120 + }) 121 + 122 + assert.strictEqual(trie.children[0].displayName, "Modified") 123 + assert.strictEqual(trie.children[1].displayName, "Modified") 124 + }) 125 + }) 126 + 127 + describe("entries", () => { 128 + test("should return all entries", () => { 129 + const data1 = { title: "Test1", slug: "test1" } 130 + const data2 = { title: "Test2", slug: "a/b/test2" } 131 + 132 + trie.add(data1) 133 + trie.add(data2) 134 + 135 + const entries = trie.entries() 136 + assert.deepStrictEqual( 137 + entries.map(([path, node]) => [path, node.data]), 138 + [ 139 + ["", trie.data], 140 + ["test1", data1], 141 + ["a/index", null], 142 + ["a/b/index", null], 143 + ["a/b/test2", data2], 144 + ], 145 + ) 146 + }) 147 + }) 148 + 149 + describe("getFolderPaths", () => { 150 + test("should return all folder paths", () => { 151 + const data1 = { 152 + title: "Root", 153 + slug: "index", 154 + } 155 + const data2 = { 156 + title: "Test", 157 + slug: "folder/subfolder/test", 158 + } 159 + const data3 = { 160 + title: "Folder Index", 161 + slug: "abc/index", 162 + } 163 + 164 + trie.add(data1) 165 + trie.add(data2) 166 + trie.add(data3) 167 + const paths = trie.getFolderPaths() 168 + 169 + assert.deepStrictEqual(paths, ["folder/index", "folder/subfolder/index", "abc/index"]) 170 + }) 171 + }) 172 + 173 + describe("sort", () => { 174 + test("should sort nodes according to sort function", () => { 175 + const data1 = { title: "A", slug: "a" } 176 + const data2 = { title: "B", slug: "b" } 177 + const data3 = { title: "C", slug: "c" } 178 + 179 + trie.add(data3) 180 + trie.add(data1) 181 + trie.add(data2) 182 + 183 + trie.sort((a, b) => a.slugSegment.localeCompare(b.slugSegment)) 184 + assert.deepStrictEqual( 185 + trie.children.map((n) => n.slugSegment), 186 + ["a", "b", "c"], 187 + ) 188 + }) 189 + }) 190 + })
+128
quartz/util/fileTrie.ts
··· 1 + import { ContentDetails } from "../plugins/emitters/contentIndex" 2 + import { FullSlug, joinSegments } from "./path" 3 + 4 + interface FileTrieData { 5 + slug: string 6 + title: string 7 + } 8 + 9 + export class FileTrieNode<T extends FileTrieData = ContentDetails> { 10 + children: Array<FileTrieNode<T>> 11 + slugSegment: string 12 + displayName: string 13 + data: T | null 14 + depth: number 15 + isFolder: boolean 16 + 17 + constructor(segment: string, data?: T, depth: number = 0) { 18 + this.children = [] 19 + this.slugSegment = segment 20 + this.displayName = data?.title ?? segment 21 + this.data = data ?? null 22 + this.depth = depth 23 + this.isFolder = segment === "index" 24 + } 25 + 26 + private insert(path: string[], file: T) { 27 + if (path.length === 0) return 28 + 29 + const nextSegment = path[0] 30 + 31 + // base case, insert here 32 + if (path.length === 1) { 33 + if (nextSegment === "index") { 34 + // index case (we are the root and we just found index.md) 35 + this.data ??= file 36 + const title = file.title 37 + if (title !== "index") { 38 + this.displayName = title 39 + } 40 + } else { 41 + // direct child 42 + this.children.push(new FileTrieNode(nextSegment, file, this.depth + 1)) 43 + this.isFolder = true 44 + } 45 + 46 + return 47 + } 48 + 49 + // find the right child to insert into, creating it if it doesn't exist 50 + path = path.splice(1) 51 + let child = this.children.find((c) => c.slugSegment === nextSegment) 52 + if (!child) { 53 + child = new FileTrieNode<T>(nextSegment, undefined, this.depth + 1) 54 + this.children.push(child) 55 + child.isFolder = true 56 + } 57 + 58 + child.insert(path, file) 59 + } 60 + 61 + // Add new file to trie 62 + add(file: T) { 63 + this.insert(file.slug.split("/"), file) 64 + } 65 + 66 + /** 67 + * Filter trie nodes. Behaves similar to `Array.prototype.filter()`, but modifies tree in place 68 + */ 69 + filter(filterFn: (node: FileTrieNode<T>) => boolean) { 70 + this.children = this.children.filter(filterFn) 71 + this.children.forEach((child) => child.filter(filterFn)) 72 + } 73 + 74 + /** 75 + * Map over trie nodes. Behaves similar to `Array.prototype.map()`, but modifies tree in place 76 + */ 77 + map(mapFn: (node: FileTrieNode<T>) => void) { 78 + mapFn(this) 79 + this.children.forEach((child) => child.map(mapFn)) 80 + } 81 + 82 + /** 83 + * Sort trie nodes according to sort/compare function 84 + */ 85 + sort(sortFn: (a: FileTrieNode<T>, b: FileTrieNode<T>) => number) { 86 + this.children = this.children.sort(sortFn) 87 + this.children.forEach((e) => e.sort(sortFn)) 88 + } 89 + 90 + static fromEntries<T extends FileTrieData>(entries: [FullSlug, T][]) { 91 + const trie = new FileTrieNode<T>("") 92 + entries.forEach(([, entry]) => trie.add(entry)) 93 + return trie 94 + } 95 + 96 + /** 97 + * Get all entries in the trie 98 + * in the a flat array including the full path and the node 99 + */ 100 + entries(): [FullSlug, FileTrieNode<T>][] { 101 + const traverse = ( 102 + node: FileTrieNode<T>, 103 + currentPath: string, 104 + ): [FullSlug, FileTrieNode<T>][] => { 105 + const segments = [currentPath, node.slugSegment] 106 + const fullPath = joinSegments(...segments) as FullSlug 107 + 108 + const indexQualifiedPath = 109 + node.isFolder && node.depth > 0 ? (joinSegments(fullPath, "index") as FullSlug) : fullPath 110 + 111 + const result: [FullSlug, FileTrieNode<T>][] = [[indexQualifiedPath, node]] 112 + 113 + return result.concat(...node.children.map((child) => traverse(child, fullPath))) 114 + } 115 + 116 + return traverse(this, "") 117 + } 118 + 119 + /** 120 + * Get all folder paths in the trie 121 + * @returns array containing folder state for trie 122 + */ 123 + getFolderPaths() { 124 + return this.entries() 125 + .filter(([_, node]) => node.isFolder) 126 + .map(([path, _]) => path) 127 + } 128 + }
+1 -4
quartz/util/path.ts
··· 1 1 import { slug as slugAnchor } from "github-slugger" 2 2 import type { Element as HastElement } from "hast" 3 - import rfdc from "rfdc" 4 - 5 - export const clone = rfdc() 6 - 3 + import { clone } from "./clone" 7 4 // this file must be isomorphic so it can't use node libs (e.g. path) 8 5 9 6 export const QUARTZ = "quartz"