From 91f9ae2d71d5c28ba7d2182eed5a9f77da1fbe8d Mon Sep 17 00:00:00 2001
From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com>
Date: Fri, 15 Sep 2023 16:39:16 +0000
Subject: [PATCH] feat: implement file explorer component (closes #201) (#452)
---
quartz/components/ExplorerNode.tsx | 196 +++++++++++++++++++
quartz/styles/base.scss | 4
quartz/components/Explorer.tsx | 70 +++++++
quartz/components/index.ts | 2
quartz/components/styles/explorer.scss | 133 +++++++++++++
docs/features/explorer.md | 41 ++++
quartz.layout.ts | 8
quartz/components/scripts/explorer.inline.ts | 141 ++++++++++++++
8 files changed, 591 insertions(+), 4 deletions(-)
diff --git a/docs/features/explorer.md b/docs/features/explorer.md
new file mode 100644
index 0000000..17647de
--- /dev/null
+++ b/docs/features/explorer.md
@@ -0,0 +1,41 @@
+---
+title: "Explorer"
+tags:
+ - component
+---
+
+Quartz features an explorer that allows you to navigate all files and folders on your site. It supports nested folders and has options for customization.
+
+By default, it will show all folders and files on your page. To display the explorer in a different spot, you can edit the [[layout]].
+
+> [!info]
+> The explorer uses local storage by default to save the state of your explorer. This is done to ensure a smooth experience when navigating to different pages.
+>
+> To clear/delete the explorer state from local storage, delete the `fileTree` entry (guide on how to delete a key from local storage in chromium based browsers can be found [here](https://docs.devolutions.net/kb/general-knowledge-base/clear-browser-local-storage/clear-chrome-local-storage/)). You can disable this by passing `useSavedState: false` as an argument.
+
+## Customization
+
+Most configuration can be done by passing in options to `Component.Explorer()`.
+
+For example, here's what the default configuration looks like:
+
+```typescript title="quartz.layout.ts"
+Component.Explorer({
+ title: "Explorer", // title of the explorer component
+ folderClickBehavior: "collapse", // what happens when you click a folder ("link" to navigate to folder page on click or "collapse" to collapse folder on click)
+ folderDefaultState: "collapsed", // default state of folders ("collapsed" or "open")
+ useSavedState: true, // wether to use local storage to save "state" (which folders are opened) of explorer
+})
+```
+
+When passing in your own options, you can omit any or all of these fields if you'd like to keep the default value for that field.
+
+Want to customize it even more?
+
+- Removing table of contents: remove `Component.Explorer()` from `quartz.layout.ts`
+ - (optional): After removing the explorer component, you can move the [[table of contents]] component back to the `left` part of the layout
+- Component:
+ - Wrapper (Outer component, generates file tree, etc): `quartz/components/Explorer.tsx`
+ - Explorer node (recursive, either a folder or a file): `quartz/components/ExplorerNode.tsx`
+- Style: `quartz/components/styles/explorer.scss`
+- Script: `quartz/components/scripts/explorer.inline.ts`
diff --git a/quartz.layout.ts b/quartz.layout.ts
index 482aba6..8c1c6c1 100644
--- a/quartz.layout.ts
+++ b/quartz.layout.ts
@@ -21,9 +21,13 @@
Component.MobileOnly(Component.Spacer()),
Component.Search(),
Component.Darkmode(),
- Component.DesktopOnly(Component.TableOfContents()),
+ Component.DesktopOnly(Component.Explorer()),
],
- right: [Component.Graph(), Component.Backlinks()],
+ right: [
+ Component.Graph(),
+ Component.DesktopOnly(Component.TableOfContents()),
+ Component.Backlinks(),
+ ],
}
// components for pages that display lists of pages (e.g. tags or folders)
diff --git a/quartz/components/Explorer.tsx b/quartz/components/Explorer.tsx
new file mode 100644
index 0000000..ce69491
--- /dev/null
+++ b/quartz/components/Explorer.tsx
@@ -0,0 +1,70 @@
+import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
+import explorerStyle from "./styles/explorer.scss"
+
+// @ts-ignore
+import script from "./scripts/explorer.inline"
+import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
+
+// Options interface defined in `ExplorerNode` to avoid circular dependency
+const defaultOptions = (): Options => ({
+ title: "Explorer",
+ folderClickBehavior: "collapse",
+ folderDefaultState: "collapsed",
+ useSavedState: true,
+})
+export default ((userOpts?: Partial<Options>) => {
+ function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
+ // Parse config
+ const opts: Options = { ...defaultOptions(), ...userOpts }
+
+ // Construct tree from allFiles
+ const fileTree = new FileNode("")
+ allFiles.forEach((file) => fileTree.add(file, 1))
+
+ // Sort tree (folders first, then files (alphabetic))
+ fileTree.sort()
+
+ // Get all folders of tree. Initialize with collapsed state
+ const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed")
+
+ // Stringify to pass json tree as data attribute ([data-tree])
+ const jsonTree = JSON.stringify(folders)
+
+ return (
+ <div class={`explorer ${displayClass}`}>
+ <button
+ type="button"
+ id="explorer"
+ data-behavior={opts.folderClickBehavior}
+ data-collapsed={opts.folderDefaultState}
+ data-savestate={opts.useSavedState}
+ data-tree={jsonTree}
+ >
+ <h3>{opts.title}</h3>
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="14"
+ height="14"
+ viewBox="5 8 14 8"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="fold"
+ >
+ <polyline points="6 9 12 15 18 9"></polyline>
+ </svg>
+ </button>
+ <div id="explorer-content">
+ <ul class="overflow">
+ <ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
+ </ul>
+ </div>
+ </div>
+ )
+ }
+ Explorer.css = explorerStyle
+ Explorer.afterDOMLoaded = script
+ return Explorer
+}) satisfies QuartzComponentConstructor
diff --git a/quartz/components/ExplorerNode.tsx b/quartz/components/ExplorerNode.tsx
new file mode 100644
index 0000000..6718ec9
--- /dev/null
+++ b/quartz/components/ExplorerNode.tsx
@@ -0,0 +1,196 @@
+// @ts-ignore
+import { QuartzPluginData } from "vfile"
+import { resolveRelative } from "../util/path"
+
+export interface Options {
+ title: string
+ folderDefaultState: "collapsed" | "open"
+ folderClickBehavior: "collapse" | "link"
+ useSavedState: boolean
+}
+
+type DataWrapper = {
+ file: QuartzPluginData
+ path: string[]
+}
+
+export type FolderState = {
+ path: string
+ collapsed: boolean
+}
+
+// Structure to add all files into a tree
+export class FileNode {
+ children: FileNode[]
+ name: string
+ file: QuartzPluginData | null
+ depth: number
+
+ constructor(name: string, file?: QuartzPluginData, depth?: number) {
+ this.children = []
+ this.name = name
+ this.file = file ?? null
+ this.depth = depth ?? 0
+ }
+
+ private insert(file: DataWrapper) {
+ if (file.path.length === 1) {
+ this.children.push(new FileNode(file.file.frontmatter!.title, file.file, this.depth + 1))
+ } else {
+ const next = file.path[0]
+ file.path = file.path.splice(1)
+ for (const child of this.children) {
+ if (child.name === next) {
+ child.insert(file)
+ return
+ }
+ }
+
+ const newChild = new FileNode(next, undefined, this.depth + 1)
+ newChild.insert(file)
+ this.children.push(newChild)
+ }
+ }
+
+ // Add new file to tree
+ add(file: QuartzPluginData, splice: number = 0) {
+ this.insert({ file, path: file.filePath!.split("/").splice(splice) })
+ }
+
+ // Print tree structure (for debugging)
+ print(depth: number = 0) {
+ let folderChar = ""
+ if (!this.file) folderChar = "|"
+ console.log("-".repeat(depth), folderChar, this.name, this.depth)
+ this.children.forEach((e) => e.print(depth + 1))
+ }
+
+ /**
+ * Get folder representation with state of tree.
+ * Intended to only be called on root node before changes to the tree are made
+ * @param collapsed default state of folders (collapsed by default or not)
+ * @returns array containing folder state for tree
+ */
+ getFolderPaths(collapsed: boolean): FolderState[] {
+ const folderPaths: FolderState[] = []
+
+ const traverse = (node: FileNode, currentPath: string) => {
+ if (!node.file) {
+ const folderPath = currentPath + (currentPath ? "/" : "") + node.name
+ if (folderPath !== "") {
+ folderPaths.push({ path: folderPath, collapsed })
+ }
+ node.children.forEach((child) => traverse(child, folderPath))
+ }
+ }
+
+ traverse(this, "")
+
+ return folderPaths
+ }
+
+ // Sort order: folders first, then files. Sort folders and files alphabetically
+ sort() {
+ this.children = this.children.sort((a, b) => {
+ if ((!a.file && !b.file) || (a.file && b.file)) {
+ return a.name.localeCompare(b.name)
+ }
+ if (a.file && !b.file) {
+ return 1
+ } else {
+ return -1
+ }
+ })
+
+ this.children.forEach((e) => e.sort())
+ }
+}
+
+type ExplorerNodeProps = {
+ node: FileNode
+ opts: Options
+ fileData: QuartzPluginData
+ fullPath?: string
+}
+
+export function ExplorerNode({ node, opts, fullPath, fileData }: ExplorerNodeProps) {
+ // Get options
+ const folderBehavior = opts.folderClickBehavior
+ const isDefaultOpen = opts.folderDefaultState === "open"
+
+ // Calculate current folderPath
+ let pathOld = fullPath ? fullPath : ""
+ let folderPath = ""
+ if (node.name !== "") {
+ folderPath = `${pathOld}/${node.name}`
+ }
+
+ return (
+ <div>
+ {node.file ? (
+ // Single file node
+ <li key={node.file.slug}>
+ <a href={resolveRelative(fileData.slug!, node.file.slug!)} data-for={node.file.slug}>
+ {node.file.frontmatter?.title}
+ </a>
+ </li>
+ ) : (
+ <div>
+ {node.name !== "" && (
+ // Node with entire folder
+ // Render svg button + folder name, then children
+ <div class="folder-container">
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="12"
+ height="12"
+ viewBox="5 8 14 8"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="folder-icon"
+ >
+ <polyline points="6 9 12 15 18 9"></polyline>
+ </svg>
+ {/* render <a> tag if folderBehavior is "link", otherwise render <button> with collapse click event */}
+ <li key={node.name} data-folderpath={folderPath}>
+ {folderBehavior === "link" ? (
+ <a href={`${folderPath}`} data-for={node.name} class="folder-title">
+ {node.name}
+ </a>
+ ) : (
+ <button class="folder-button">
+ <h3 class="folder-title">{node.name}</h3>
+ </button>
+ )}
+ </li>
+ </div>
+ )}
+ {/* Recursively render children of folder */}
+ <div class={`folder-outer ${node.depth === 0 || isDefaultOpen ? "open" : ""}`}>
+ <ul
+ // Inline style for left folder paddings
+ style={{
+ paddingLeft: node.name !== "" ? "1.4rem" : "0",
+ }}
+ class="content"
+ data-folderul={folderPath}
+ >
+ {node.children.map((childNode, i) => (
+ <ExplorerNode
+ node={childNode}
+ key={i}
+ opts={opts}
+ fullPath={folderPath}
+ fileData={fileData}
+ />
+ ))}
+ </ul>
+ </div>
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/quartz/components/index.ts b/quartz/components/index.ts
index 10a43ac..d7b6a1c 100644
--- a/quartz/components/index.ts
+++ b/quartz/components/index.ts
@@ -9,6 +9,7 @@
import ContentMeta from "./ContentMeta"
import Spacer from "./Spacer"
import TableOfContents from "./TableOfContents"
+import Explorer from "./Explorer"
import TagList from "./TagList"
import Graph from "./Graph"
import Backlinks from "./Backlinks"
@@ -29,6 +30,7 @@
ContentMeta,
Spacer,
TableOfContents,
+ Explorer,
TagList,
Graph,
Backlinks,
diff --git a/quartz/components/scripts/explorer.inline.ts b/quartz/components/scripts/explorer.inline.ts
new file mode 100644
index 0000000..8073979
--- /dev/null
+++ b/quartz/components/scripts/explorer.inline.ts
@@ -0,0 +1,141 @@
+import { FolderState } from "../ExplorerNode"
+
+// Current state of folders
+let explorerState: FolderState[]
+
+function toggleExplorer(this: HTMLElement) {
+ // Toggle collapsed state of entire explorer
+ this.classList.toggle("collapsed")
+ const content = this.nextElementSibling as HTMLElement
+ content.classList.toggle("collapsed")
+ content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
+}
+
+function toggleFolder(evt: MouseEvent) {
+ evt.stopPropagation()
+
+ // Element that was clicked
+ const target = evt.target as HTMLElement
+
+ // Check if target was svg icon or button
+ const isSvg = target.nodeName === "svg"
+
+ // corresponding <ul> element relative to clicked button/folder
+ let childFolderContainer: HTMLElement
+
+ // <li> element of folder (stores folder-path dataset)
+ let currentFolderParent: HTMLElement
+
+ // Get correct relative container and toggle collapsed class
+ if (isSvg) {
+ childFolderContainer = target.parentElement?.nextSibling as HTMLElement
+ currentFolderParent = target.nextElementSibling as HTMLElement
+
+ childFolderContainer.classList.toggle("open")
+ } else {
+ childFolderContainer = target.parentElement?.parentElement?.nextElementSibling as HTMLElement
+ currentFolderParent = target.parentElement as HTMLElement
+
+ childFolderContainer.classList.toggle("open")
+ }
+ if (!childFolderContainer) return
+
+ // Collapse folder container
+ const isCollapsed = childFolderContainer.classList.contains("open")
+ setFolderState(childFolderContainer, !isCollapsed)
+
+ // Save folder state to localStorage
+ const clickFolderPath = currentFolderParent.dataset.folderpath as string
+
+ // Remove leading "/"
+ const fullFolderPath = clickFolderPath.substring(1)
+ toggleCollapsedByPath(explorerState, fullFolderPath)
+
+ const stringifiedFileTree = JSON.stringify(explorerState)
+ localStorage.setItem("fileTree", stringifiedFileTree)
+}
+
+function setupExplorer() {
+ // Set click handler for collapsing entire explorer
+ const explorer = document.getElementById("explorer")
+
+ // 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") {
+ Array.prototype.forEach.call(
+ document.getElementsByClassName("folder-button"),
+ function (item) {
+ item.removeEventListener("click", toggleFolder)
+ item.addEventListener("click", toggleFolder)
+ },
+ )
+ }
+
+ // Add click handler to main explorer
+ explorer.removeEventListener("click", toggleExplorer)
+ explorer.addEventListener("click", toggleExplorer)
+ }
+
+ // Set up click handlers for each folder (click handler on folder "icon")
+ Array.prototype.forEach.call(document.getElementsByClassName("folder-icon"), function (item) {
+ item.removeEventListener("click", toggleFolder)
+ item.addEventListener("click", toggleFolder)
+ })
+
+ if (storageTree && useSavedFolderState) {
+ // Get state from localStorage and set folder state
+ explorerState = JSON.parse(storageTree)
+ explorerState.map((folderUl) => {
+ // grab <li> element for matching folder path
+ const folderLi = document.querySelector(
+ `[data-folderpath='/${folderUl.path}']`,
+ ) as HTMLElement
+
+ // Get corresponding content <ul> tag and set state
+ const folderUL = folderLi.parentElement?.nextElementSibling as HTMLElement
+ setFolderState(folderUL, folderUl.collapsed)
+ })
+ } else {
+ // If tree is not in localStorage or config is disabled, use tree passed from Explorer as dataset
+ explorerState = JSON.parse(explorer?.dataset.tree as string)
+ }
+}
+
+window.addEventListener("resize", setupExplorer)
+document.addEventListener("nav", () => {
+ setupExplorer()
+})
+
+/**
+ * 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
+ */
+function setFolderState(folderElement: HTMLElement, collapsed: boolean) {
+ if (collapsed) {
+ folderElement?.classList.remove("open")
+ } else {
+ folderElement?.classList.add("open")
+ }
+}
+
+/**
+ * Toggles visibility of a folder
+ * @param array array of FolderState (`fileTree`, either get from local storage or data attribute)
+ * @param path path to folder (e.g. 'advanced/more/more2')
+ */
+function toggleCollapsedByPath(array: FolderState[], path: string) {
+ const entry = array.find((item) => item.path === path)
+ if (entry) {
+ entry.collapsed = !entry.collapsed
+ }
+}
diff --git a/quartz/components/styles/explorer.scss b/quartz/components/styles/explorer.scss
new file mode 100644
index 0000000..4b25a55
--- /dev/null
+++ b/quartz/components/styles/explorer.scss
@@ -0,0 +1,133 @@
+button#explorer {
+ background-color: transparent;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ padding: 0;
+ color: var(--dark);
+ display: flex;
+ align-items: center;
+
+ & h3 {
+ 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;
+ max-height: none;
+ transition: max-height 0.35s ease;
+ margin-top: 0.5rem;
+
+ &.collapsed > .overflow::after {
+ opacity: 0;
+ }
+
+ & ul {
+ list-style: none;
+ margin: 0.08rem 0;
+ padding: 0;
+ transition:
+ max-height 0.35s ease,
+ transform 0.35s ease,
+ opacity 0.2s ease;
+ & div > li > a {
+ color: var(--dark);
+ opacity: 0.75;
+ pointer-events: all;
+ }
+ }
+}
+
+svg {
+ pointer-events: all;
+
+ & > polyline {
+ pointer-events: none;
+ }
+}
+
+.folder-container {
+ flex-direction: row;
+ display: flex;
+ align-items: center;
+ user-select: none;
+
+ & li > a {
+ // other selector is more specific, needs important
+ color: var(--secondary) !important;
+ opacity: 1 !important;
+ font-size: 1.05rem !important;
+ }
+
+ & li > a:hover {
+ // other selector is more specific, needs important
+ color: var(--tertiary) !important;
+ }
+
+ & li > 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;
+
+ & h3 {
+ font-size: 0.95rem;
+ display: inline-block;
+ color: var(--secondary);
+ font-weight: 600;
+ margin: 0;
+ line-height: 1.5rem;
+ font-weight: bold;
+ pointer-events: none;
+ }
+ }
+}
+
+.folder-icon {
+ margin-right: 5px;
+ color: var(--secondary);
+ cursor: pointer;
+ transition: transform 0.3s ease;
+ backface-visibility: visible;
+}
+
+div:has(> .folder-outer:not(.open)) > .folder-container > svg {
+ transform: rotate(-90deg);
+}
+
+.folder-icon:hover {
+ color: var(--tertiary);
+}
diff --git a/quartz/styles/base.scss b/quartz/styles/base.scss
index 92c0f84..c6925fb 100644
--- a/quartz/styles/base.scss
+++ b/quartz/styles/base.scss
@@ -446,7 +446,7 @@
ul.overflow,
ol.overflow {
- height: 300px;
+ max-height: 300;
overflow-y: auto;
// clearfix
@@ -454,7 +454,7 @@
clear: both;
& > li:last-of-type {
- margin-bottom: 50px;
+ margin-bottom: 30px;
}
&:after {
--
Gitblit v1.10.0