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