diff --git a/quartz.layout.ts b/quartz.layout.ts index 4a78256aa..ab4c0c84c 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -27,7 +27,7 @@ export const defaultContentPageLayout: PageLayout = { Component.MobileOnly(Component.Spacer()), Component.Search(), Component.Darkmode(), - Component.DesktopOnly(Component.Explorer()), + Component.Explorer(), ], right: [ Component.Graph(), diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx index ec7c48ef7..ea43815e7 100644 --- a/quartz/components/Explorer.tsx +++ b/quartz/components/Explorer.tsx @@ -5,7 +5,6 @@ import explorerStyle from "./styles/explorer.scss" 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" // Options interface defined in `ExplorerNode` to avoid circular dependency @@ -13,6 +12,7 @@ const defaultOptions = { folderClickBehavior: "collapse", folderDefaultState: "collapsed", useSavedState: true, + usePagePath: false, mapFn: (node) => { return node }, @@ -46,7 +46,7 @@ export default ((userOpts?: Partial) => { let jsonTree: string let lastBuildId: string = "" - function constructFileTree(allFiles: QuartzPluginData[]) { + function constructFileTree(allFiles: QuartzPluginData[], currentFilePath: string) { // Construct tree from allFiles fileTree = new FileNode("") allFiles.forEach((file) => fileTree.add(file)) @@ -68,7 +68,10 @@ export default ((userOpts?: Partial) => { // 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") + const folders = fileTree.getFolderPaths( + opts.folderDefaultState === "collapsed", + currentFilePath, + ) jsonTree = JSON.stringify(folders) } @@ -81,42 +84,78 @@ export default ((userOpts?: Partial) => { }: QuartzComponentProps) => { if (ctx.buildId !== lastBuildId) { lastBuildId = ctx.buildId - constructFileTree(allFiles) + constructFileTree(allFiles, (fileData.filePath ?? "").replaceAll(" ", "-")) } - return ( -
- -
-
    - -
  • -
+ + + + + + + +
+
+ +
+
    + +
  • +
+
) diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx index e57d67715..88246e049 100644 --- a/quartz/components/ExplorerNode.tsx +++ b/quartz/components/ExplorerNode.tsx @@ -16,6 +16,7 @@ export interface Options { folderDefaultState: "collapsed" | "open" folderClickBehavior: "collapse" | "link" useSavedState: boolean + usePagePath: boolean sortFn: (a: FileNode, b: FileNode) => number filterFn: (node: FileNode) => boolean mapFn: (node: FileNode) => void @@ -124,9 +125,10 @@ export class FileNode { * 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) + * @param currentFile current file * @returns array containing folder state for tree */ - getFolderPaths(collapsed: boolean): FolderState[] { + getFolderPaths(collapsed: boolean, currentFile: string): FolderState[] { const folderPaths: FolderState[] = [] const traverse = (node: FileNode, currentPath: string) => { diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts index 33d328a6c..ffe9ecbe3 100644 --- a/quartz/components/scripts/explorer.inline.ts +++ b/quartz/components/scripts/explorer.inline.ts @@ -1,7 +1,13 @@ import { FolderState } from "../ExplorerNode" +// Current state of folders type MaybeHTMLElement = HTMLElement | undefined let currentExplorerState: FolderState[] + +function escapeCharacters(str: string) { + return str.replace(/'/g, "\\'").replace(/"/g, '\\"') +} + 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") @@ -15,24 +21,17 @@ const observer = new IntersectionObserver((entries) => { } }) -function toggleExplorer(this: HTMLElement) { - this.classList.toggle("collapsed") - this.setAttribute( - "aria-expanded", - this.getAttribute("aria-expanded") === "true" ? "false" : "true", - ) - const content = this.nextElementSibling as MaybeHTMLElement - if (!content) return - - content.classList.toggle("collapsed") -} - function toggleFolder(evt: MouseEvent) { evt.stopPropagation() + + // Element that was clicked const target = evt.target as MaybeHTMLElement if (!target) return + // Check if target was svg icon or button const isSvg = target.nodeName === "svg" + + // corresponding
    element relative to clicked button/folder const childFolderContainer = ( isSvg ? target.parentElement?.nextSibling @@ -42,68 +41,150 @@ function toggleFolder(evt: MouseEvent) { isSvg ? target.nextElementSibling : target.parentElement ) as MaybeHTMLElement if (!(childFolderContainer && currentFolderParent)) return - + //
  • element of folder (stores folder-path dataset) childFolderContainer.classList.toggle("open") + + // Collapse folder container 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 stringifiedFileTree = JSON.stringify(currentExplorerState) localStorage.setItem("fileTree", stringifiedFileTree) } -function setupExplorer() { - const explorer = document.getElementById("explorer") - if (!explorer) return +function toggleExplorer(this: HTMLElement) { + // Toggle collapsed state of entire explorer + this.classList.toggle("collapsed") + const content = this.nextElementSibling as MaybeHTMLElement + if (!content) return + content.classList.toggle("collapsed") + //content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px" + content.style.maxHeight = content.style.maxHeight === "0px" ? "calc(100vh - 8rem)" : "0px" - if (explorer.dataset.behavior === "collapse") { + //prevent scroll under + if (this.dataset.mobile === "true" && document.querySelector("#mobile-explorer")) { + const article = document.querySelectorAll( + ".popover-hint, footer, .backlinks, .graph, .toc, #progress", + ) + const header = document.querySelector(".page .page-header") + if (article) + article.forEach((element) => { + element.classList.toggle("no-scroll") + }) + if (header) header.classList.toggle("fixed") + } +} + +function setupExplorer() { + // Set click handler for collapsing entire explorer + const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf + + for (const explorer of allExplorers) { + // 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-button", + "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])) + //console.log(explorer.dataset.tree) + //console.log(explorer.dataset.tree ? JSON.parse(explorer.dataset.tree) : []) + 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) + } + }) } +} - explorer.addEventListener("click", toggleExplorer) - window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer)) +function toggleExplorerFolders() { + const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace( + /\/index$/g, + "", + ) + const listToReplace = document.querySelectorAll(".folder-outer:has(> ul[data-folderul]") - // 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 storageTree = localStorage.getItem("fileTree") - const useSavedFolderState = explorer?.dataset.savestate === "true" - 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}']`, - ) as MaybeHTMLElement - const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement - if (folderUl) { - setFolderState(folderUl, folderState.collapsed) + listToReplace.forEach((element) => { + if (element.children.length > 0) { + if (currentFile.includes(element.firstElementChild?.getAttribute("data-folderul") ?? "")) { + if (!element.classList.contains("open")) { + element.classList.add("open") + } + } } }) } window.addEventListener("resize", setupExplorer) + +document.addEventListener("DOMContentLoaded", () => { + const explorer = document.querySelector("#mobile-explorer") + if (explorer) { + explorer.classList.add("collapsed") + const content = explorer.nextElementSibling as HTMLElement + content.classList.add("collapsed") + content.style.maxHeight = "0px" + } + toggleExplorerFolders() +}) + document.addEventListener("nav", () => { + const explorer = document.querySelector("#mobile-explorer") + if (explorer) { + explorer.classList.add("collapsed") + const content = explorer.nextElementSibling as HTMLElement + content.classList.add("collapsed") + content.style.maxHeight = "0px" + } setupExplorer() + //add collapsed class to all folders + observer.disconnect() // select pseudo element at end of list @@ -111,6 +192,8 @@ document.addEventListener("nav", () => { if (lastItem) { observer.observe(lastItem) } + + toggleExplorerFolders() }) /** diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss index 397fd0241..01e526a0a 100644 --- a/quartz/components/styles/explorer.scss +++ b/quartz/components/styles/explorer.scss @@ -1,15 +1,31 @@ @use "../../styles/variables.scss" as *; -.explorer { - display: flex; - flex-direction: column; - overflow-y: hidden; - &.desktop-only { - @media all and not ($mobile) { +.explorer-container { + .mobile-explorer { + display: none; + } + .desktop-explorer { + display: flex; + } + @media all and ($mobile) { + .mobile-explorer { display: flex; } + .desktop-explorer { + display: none; + } } - /*&:after { + + .mobile-explorer, + .desktop-explorer { + flex-direction: column; + overflow-y: hidden; + &.desktop-only { + @media all and not ($mobile) { + display: flex; + } + } + /*&:after { pointer-events: none; content: ""; width: 100%; @@ -21,161 +37,234 @@ transition: opacity 0.3s ease; background: linear-gradient(transparent 0px, var(--light)); }*/ -} - -button#explorer { - background-color: transparent; - border: none; - text-align: left; - cursor: pointer; - padding: 0; - color: var(--dark); - display: flex; - align-items: center; - - & h2 { - font-size: 1rem; - display: inline-block; - margin: 0; } - & .fold { - margin-left: 0.5rem; - transition: transform 0.3s ease; - opacity: 0.8; - } - - &.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: 100%; - transition: - max-height 0.35s ease, - visibility 0s linear 0s; - margin-top: 0.5rem; - visibility: visible; - - &.collapsed { - max-height: 0; - transition: - max-height 0.35s ease, - visibility 0s linear 0.35s; - visibility: hidden; - } - - & ul { - list-style: none; - margin: 0.08rem 0; - padding: 0; - transition: - max-height 0.35s ease, - transform 0.35s ease, - opacity 0.2s ease; - & li > a { - color: var(--dark); - opacity: 0.75; - pointer-events: all; - } - } - > #explorer-ul { - max-height: none; - } -} - -svg { - pointer-events: all; - - & > polyline { - pointer-events: none; - } -} - -.folder-container { - flex-direction: row; - display: flex; - align-items: center; - user-select: none; - - & div > a { - color: var(--secondary); - font-family: var(--headerFont); - font-size: 0.95rem; - font-weight: $semiBoldWeight; - line-height: 1.5rem; - display: inline-block; - } - - & div > a:hover { - color: var(--tertiary); - } - - & div > button { - color: var(--dark); + button#mobile-explorer, + button#desktop-explorer { background-color: transparent; border: none; text-align: left; cursor: pointer; - padding-left: 0; - padding-right: 0; + padding: 0; + color: var(--dark); display: flex; align-items: center; - font-family: var(--headerFont); - & span { - font-size: 0.95rem; + & h2 { + font-size: 1rem; display: inline-block; - color: var(--secondary); - font-weight: $semiBoldWeight; margin: 0; - line-height: 1.5rem; + } + + & .fold { + margin-left: 0.5rem; + transition: transform 0.3s ease; + opacity: 0.8; + } + + &.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: 100%; + transition: + max-height 0.35s ease, + visibility 0s linear 0s; + margin-top: 0.5rem; + visibility: visible; + + &.collapsed { + max-height: 0; + transition: + max-height 0.35s ease, + visibility 0s linear 0.35s; + visibility: hidden; + } + + & ul { + list-style: none; + margin: 0.08rem 0; + padding: 0; + transition: + max-height 0.35s ease, + transform 0.35s ease, + opacity 0.2s ease; + & li > a { + color: var(--dark); + opacity: 0.75; + pointer-events: all; + } + } + > #explorer-ul { + max-height: none; + } + } + + svg { + pointer-events: all; + + & > polyline { pointer-events: none; } } -} -.folder-icon { - margin-right: 5px; - color: var(--secondary); - cursor: pointer; - transition: transform 0.3s ease; - backface-visibility: visible; -} + .folder-container { + flex-direction: row; + display: flex; + align-items: center; + user-select: none; -li:has(> .folder-outer:not(.open)) > .folder-container > svg { - transform: rotate(-90deg); -} + & div > a { + color: var(--secondary); + font-family: var(--headerFont); + font-size: 0.95rem; + font-weight: $semiBoldWeight; + line-height: 1.5rem; + display: inline-block; + } -.folder-icon:hover { - color: var(--tertiary); -} + & div > a:hover { + color: var(--tertiary); + } -.no-background::after { - background: none !important; -} + & div > button { + color: var(--dark); + background-color: transparent; + border: none; + text-align: left; + cursor: pointer; + padding-left: 0; + padding-right: 0; + display: flex; + align-items: center; + font-family: var(--headerFont); -#explorer-end { - // needs height so IntersectionObserver gets triggered - height: 4px; - // remove default margin from li - margin: 0; + & span { + font-size: 0.95rem; + display: inline-block; + color: var(--secondary); + font-weight: $semiBoldWeight; + margin: 0; + line-height: 1.5rem; + pointer-events: none; + } + } + } + + .folder-icon { + margin-right: 5px; + color: var(--secondary); + cursor: pointer; + transition: transform 0.3s ease; + backface-visibility: visible; + } + + li:has(> .folder-outer:not(.open)) > .folder-container > svg { + transform: rotate(-90deg); + } + + .folder-icon:hover { + 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; + } + + .mobile-explorer, + .desktop-explorer { + #explorer-content { + //left: 0; + z-index: 100; + position: absolute; + //max-height: max-content !important; + background-color: var(--light); + //max-width: calc(100% - 36px); + max-width: 100%; + width: calc(100% - 36px + 4px); + //margin-right: 36px; + transition: all 300ms ease-in-out; + overflow: hidden; + padding: 0 16px; + height: calc(100dvh - 8rem); + //border-top-left-radius: 23px; + //border-top-right-radius: 23px; + border-radius: 23px; + margin-top: 36px; + + &:not(.collapsed) { + height: calc(100dvh - 8rem); + //top: 10dvh; + } + + ul.overflow { + max-height: calc(100dvh - 8rem); + width: 100%; + } + + &.collapsed { + height: 0; + } + } + + #mobile-explorer, + #desktop-explorer { + &:not(.collapsed) .lucide-menu { + transform: rotate(90deg); + transition: transform 200ms ease-in-out; + } + .lucide-menu { + stroke: var(--darkgray); + transition: transform 200ms ease; + &:hover { + stroke: var(--dark); + } + } + } + } + + .no-scroll { + opacity: 0; + overflow: hidden; + } + + html:has(.no-scroll) { + overflow: hidden; + } + + @media all and not ($mobile) { + .no-scroll { + opacity: 1 !important; + overflow: auto !important; + } + html:has(.no-scroll) { + overflow: auto !important; + } + } }