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