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
This commit is contained in:
Ben Schlegel 2023-09-15 18:39:16 +02:00 committed by GitHub
parent 4b177ed03e
commit 515ac8d9a1
8 changed files with 591 additions and 4 deletions

41
docs/features/explorer.md Normal file
View 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`

View File

@ -21,9 +21,13 @@ export const defaultContentPageLayout: PageLayout = {
Component.MobileOnly(Component.Spacer()), Component.MobileOnly(Component.Spacer()),
Component.Search(), Component.Search(),
Component.Darkmode(), 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) // components for pages that display lists of pages (e.g. tags or folders)

View 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

View 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>
)
}

View File

@ -9,6 +9,7 @@ import PageTitle from "./PageTitle"
import ContentMeta from "./ContentMeta" import ContentMeta from "./ContentMeta"
import Spacer from "./Spacer" import Spacer from "./Spacer"
import TableOfContents from "./TableOfContents" import TableOfContents from "./TableOfContents"
import Explorer from "./Explorer"
import TagList from "./TagList" import TagList from "./TagList"
import Graph from "./Graph" import Graph from "./Graph"
import Backlinks from "./Backlinks" import Backlinks from "./Backlinks"
@ -29,6 +30,7 @@ export {
ContentMeta, ContentMeta,
Spacer, Spacer,
TableOfContents, TableOfContents,
Explorer,
TagList, TagList,
Graph, Graph,
Backlinks, Backlinks,

View 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
}
}

View 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);
}

View File

@ -446,7 +446,7 @@ video {
ul.overflow, ul.overflow,
ol.overflow { ol.overflow {
height: 300px; max-height: 300;
overflow-y: auto; overflow-y: auto;
// clearfix // clearfix
@ -454,7 +454,7 @@ ol.overflow {
clear: both; clear: both;
& > li:last-of-type { & > li:last-of-type {
margin-bottom: 50px; margin-bottom: 30px;
} }
&:after { &:after {