Ben Schlegel
2023-09-15 91f9ae2d71d5c28ba7d2182eed5a9f77da1fbe8d
feat: implement file explorer component (closes #201) (#452)

* feat: add basic explorer structure„

* feat: integrate new component/plugin

* feat: add basic explorer structure

* feat: add sort to FileNodes

* style: improve style for explorer

* refactor: remove unused explorer plugin

* refactor: clean explorer structure, fix base (toc)

* refactor: clean css, respect displayClass

* style: add styling to chevron

* refactor: clean up debug statements

* refactor: remove unused import

* fix: clicking folder icon sometimes turns invisible

* refactor: clean css

* feat(explorer): add config for title

* feat: add config for folder click behavior

* fix: `no-pointer` not being set for all elements

new approach, have one `no-pointer` class, that removes pointer events and one `clickable` class on the svg and button (everything that can normally be clicked). then, find all children with `clickable` and toggle `no-pointer`

* fix: bug where nested folders got incorrect height

this fixes the bug where nested folders weren't calculating their total height correctly. done by adding class to main container of all children and calculating total

* feat: introduce `folderDefaultState` config

* feat: store depth for explorer nodes

* feat: implement option for collapsed state + bug fixes

folderBehavior: "link" still has bad styling, but major bugs with pointers fixed (not clean yet, but working)

* fix: default folder icon rotation

* fix: hitbox problem with folder links, fix style

* fix: redirect url for nested folders

* fix: inconsistent behavior with 'collapseFolders' opt

* chore: add comments to `ExplorerNode`

* feat: save explorer state to local storage (not clean)

* feat: rework `getFolders()`, fix localstorage read + write

* feat: set folder state from localStorage

needs serious refactoring but functional (except folder icon orientation)

* fix: folder icon orientation after local storage

* feat: add config for `useSavedState`

* refactor: clean `explorer.inline.ts`

remove unused functions, comments, unused code, add types to EventHandler

* refactor: clean explorer

merge `isSvg` paths, remove console logs

* refactor: add documentation, remove unused funcs

* feat: rework folder collapse logic

use grids instead of jank scuffed solution with calculating total heights

* refactor: remove depth arg from insert

* feat: restore collapse functionality to clicks

allow folder icon + folder label to collapse folders again

* refactor: remove `pointer-event` jank

* feat: improve svg viewbox + remove unused props

* feat: use css selector to toggle icon

rework folder icon to work purely with css instead of JS manipulation

* refactor: remove unused cfg

* feat: move TOC to right sidebar

* refactor: clean css

* style: fix overflow + overflow margin

* fix: use `resolveRelative` to resolve file paths

* fix: `defaultFolderState` config option

* refactor: rename import, rename `folderLi` + ul

* fix: use `QuartzPluginData` type

* docs: add explorer documentation
5 files added
3 files modified
595 ■■■■■ changed files
docs/features/explorer.md 41 ●●●●● patch | view | raw | blame | history
quartz.layout.ts 8 ●●●● patch | view | raw | blame | history
quartz/components/Explorer.tsx 70 ●●●●● patch | view | raw | blame | history
quartz/components/ExplorerNode.tsx 196 ●●●●● patch | view | raw | blame | history
quartz/components/index.ts 2 ●●●●● patch | view | raw | blame | history
quartz/components/scripts/explorer.inline.ts 141 ●●●●● patch | view | raw | blame | history
quartz/components/styles/explorer.scss 133 ●●●●● patch | view | raw | blame | history
quartz/styles/base.scss 4 ●●●● patch | view | raw | blame | history
docs/features/explorer.md
New file
@@ -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`
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)
quartz/components/Explorer.tsx
New file
@@ -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
quartz/components/ExplorerNode.tsx
New file
@@ -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>
  )
}
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,
quartz/components/scripts/explorer.inline.ts
New file
@@ -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
  }
}
quartz/components/styles/explorer.scss
New file
@@ -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);
}
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 {