From 91f9ae2d71d5c28ba7d2182eed5a9f77da1fbe8d Mon Sep 17 00:00:00 2001 From: Ben Schlegel <31989404+benschlegel@users.noreply.github.com> Date: Fri, 15 Sep 2023 18:39:16 +0200 Subject: [PATCH] feat: implement file explorer component (closes #201) (#452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- docs/features/explorer.md | 41 ++++ quartz.layout.ts | 8 +- quartz/components/Explorer.tsx | 70 +++++++ quartz/components/ExplorerNode.tsx | 196 +++++++++++++++++++ quartz/components/index.ts | 2 + quartz/components/scripts/explorer.inline.ts | 141 +++++++++++++ quartz/components/styles/explorer.scss | 133 +++++++++++++ quartz/styles/base.scss | 4 +- 8 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 docs/features/explorer.md create mode 100644 quartz/components/Explorer.tsx create mode 100644 quartz/components/ExplorerNode.tsx create mode 100644 quartz/components/scripts/explorer.inline.ts create mode 100644 quartz/components/styles/explorer.scss diff --git a/docs/features/explorer.md b/docs/features/explorer.md new file mode 100644 index 000000000..17647de00 --- /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 482aba6e3..8c1c6c114 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -21,9 +21,13 @@ export const defaultContentPageLayout: PageLayout = { Component.MobileOnly(Component.Spacer()), Component.Search(), Component.Darkmode(), - Component.DesktopOnly(Component.TableOfContents()), + Component.DesktopOnly(Component.Explorer()), + ], + right: [ + Component.Graph(), + Component.DesktopOnly(Component.TableOfContents()), + Component.Backlinks(), ], - right: [Component.Graph(), 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 000000000..ce69491e9 --- /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) => { + 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 ( +
+ +
+
    + +
+
+
+ ) + } + 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 000000000..6718ec9fa --- /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 ( +
+ {node.file ? ( + // Single file node +
  • + + {node.file.frontmatter?.title} + +
  • + ) : ( +
    + {node.name !== "" && ( + // Node with entire folder + // Render svg button + folder name, then children + + )} + {/* Recursively render children of folder */} +
    +
      + {node.children.map((childNode, i) => ( + + ))} +
    +
    +
    + )} +
    + ) +} diff --git a/quartz/components/index.ts b/quartz/components/index.ts index 10a43acb5..d7b6a1c5e 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -9,6 +9,7 @@ import PageTitle from "./PageTitle" 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 @@ export { 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 000000000..807397998 --- /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