Rewrite mobile explorer

This commit is contained in:
saberzero1 2024-10-01 22:12:17 +02:00
parent b0c079f24a
commit a190cba737
No known key found for this signature in database
GPG Key ID: 41AEE99107640F10
5 changed files with 445 additions and 232 deletions

View File

@ -27,7 +27,7 @@ export const defaultContentPageLayout: PageLayout = {
Component.MobileOnly(Component.Spacer()), Component.MobileOnly(Component.Spacer()),
Component.Search(), Component.Search(),
Component.Darkmode(), Component.Darkmode(),
Component.DesktopOnly(Component.Explorer()), Component.Explorer(),
], ],
right: [ right: [
Component.Graph(), Component.Graph(),

View File

@ -5,7 +5,6 @@ import explorerStyle from "./styles/explorer.scss"
import script from "./scripts/explorer.inline" import script from "./scripts/explorer.inline"
import { ExplorerNode, FileNode, Options } from "./ExplorerNode" import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
import { QuartzPluginData } from "../plugins/vfile" import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang"
import { i18n } from "../i18n" import { i18n } from "../i18n"
// Options interface defined in `ExplorerNode` to avoid circular dependency // Options interface defined in `ExplorerNode` to avoid circular dependency
@ -13,6 +12,7 @@ const defaultOptions = {
folderClickBehavior: "collapse", folderClickBehavior: "collapse",
folderDefaultState: "collapsed", folderDefaultState: "collapsed",
useSavedState: true, useSavedState: true,
usePagePath: false,
mapFn: (node) => { mapFn: (node) => {
return node return node
}, },
@ -46,7 +46,7 @@ export default ((userOpts?: Partial<Options>) => {
let jsonTree: string let jsonTree: string
let lastBuildId: string = "" let lastBuildId: string = ""
function constructFileTree(allFiles: QuartzPluginData[]) { function constructFileTree(allFiles: QuartzPluginData[], currentFilePath: string) {
// Construct tree from allFiles // Construct tree from allFiles
fileTree = new FileNode("") fileTree = new FileNode("")
allFiles.forEach((file) => fileTree.add(file)) allFiles.forEach((file) => fileTree.add(file))
@ -68,7 +68,10 @@ export default ((userOpts?: Partial<Options>) => {
// Get all folders of tree. Initialize with collapsed state // Get all folders of tree. Initialize with collapsed state
// Stringify to pass json tree as data attribute ([data-tree]) // Stringify to pass json tree as data attribute ([data-tree])
const folders = fileTree.getFolderPaths(opts.folderDefaultState === "collapsed") const folders = fileTree.getFolderPaths(
opts.folderDefaultState === "collapsed",
currentFilePath,
)
jsonTree = JSON.stringify(folders) jsonTree = JSON.stringify(folders)
} }
@ -81,20 +84,55 @@ export default ((userOpts?: Partial<Options>) => {
}: QuartzComponentProps) => { }: QuartzComponentProps) => {
if (ctx.buildId !== lastBuildId) { if (ctx.buildId !== lastBuildId) {
lastBuildId = ctx.buildId lastBuildId = ctx.buildId
constructFileTree(allFiles) constructFileTree(allFiles, (fileData.filePath ?? "").replaceAll(" ", "-"))
} }
return ( return (
<div class={classNames(displayClass, "explorer")}> <div class="explorer-container">
<div class={`mobile-explorer explorer ${displayClass ?? ""}`}>
<button <button
type="button" type="button"
id="explorer" id="mobile-explorer"
class="collapsed"
data-behavior={opts.folderClickBehavior} data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState} data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState} data-savestate={opts.useSavedState}
data-pagepathstate={opts.usePagePath}
data-tree={jsonTree} data-tree={jsonTree}
aria-controls="explorer-content" data-mobile={true}
aria-expanded={opts.folderDefaultState === "open"} >
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-menu"
>
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>
</button>
<div id="explorer-content" class="collapsed">
<ul class="overflow" id="explorer-ul">
<ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
<li id="explorer-end" />
</ul>
</div>
</div>
<div class={`desktop-explorer explorer ${displayClass ?? ""}`}>
<button
type="button"
id="desktop-explorer"
class="title-button"
data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-pagepathstate={opts.usePagePath}
data-tree={jsonTree}
data-mobile={false}
> >
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2> <h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
<svg <svg
@ -112,13 +150,14 @@ export default ((userOpts?: Partial<Options>) => {
<polyline points="6 9 12 15 18 9"></polyline> <polyline points="6 9 12 15 18 9"></polyline>
</svg> </svg>
</button> </button>
<div id="explorer-content"> <div id="explorer-content" class="">
<ul class="overflow" id="explorer-ul"> <ul class="overflow" id="explorer-ul">
<ExplorerNode node={fileTree} opts={opts} fileData={fileData} /> <ExplorerNode node={fileTree} opts={opts} fileData={fileData} />
<li id="explorer-end" /> <li id="explorer-end" />
</ul> </ul>
</div> </div>
</div> </div>
</div>
) )
} }

View File

@ -16,6 +16,7 @@ export interface Options {
folderDefaultState: "collapsed" | "open" folderDefaultState: "collapsed" | "open"
folderClickBehavior: "collapse" | "link" folderClickBehavior: "collapse" | "link"
useSavedState: boolean useSavedState: boolean
usePagePath: boolean
sortFn: (a: FileNode, b: FileNode) => number sortFn: (a: FileNode, b: FileNode) => number
filterFn: (node: FileNode) => boolean filterFn: (node: FileNode) => boolean
mapFn: (node: FileNode) => void mapFn: (node: FileNode) => void
@ -124,9 +125,10 @@ export class FileNode {
* Get folder representation with state of tree. * Get folder representation with state of tree.
* Intended to only be called on root node before changes to the tree are made * 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) * @param collapsed default state of folders (collapsed by default or not)
* @param currentFile current file
* @returns array containing folder state for tree * @returns array containing folder state for tree
*/ */
getFolderPaths(collapsed: boolean): FolderState[] { getFolderPaths(collapsed: boolean, currentFile: string): FolderState[] {
const folderPaths: FolderState[] = [] const folderPaths: FolderState[] = []
const traverse = (node: FileNode, currentPath: string) => { const traverse = (node: FileNode, currentPath: string) => {

View File

@ -1,7 +1,13 @@
import { FolderState } from "../ExplorerNode" import { FolderState } from "../ExplorerNode"
// Current state of folders
type MaybeHTMLElement = HTMLElement | undefined type MaybeHTMLElement = HTMLElement | undefined
let currentExplorerState: FolderState[] let currentExplorerState: FolderState[]
function escapeCharacters(str: string) {
return str.replace(/'/g, "\\'").replace(/"/g, '\\"')
}
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
// If last element is observed, remove gradient of "overflow" class so element is visible // If last element is observed, remove gradient of "overflow" class so element is visible
const explorerUl = document.getElementById("explorer-ul") const explorerUl = document.getElementById("explorer-ul")
@ -15,24 +21,17 @@ const observer = new IntersectionObserver((entries) => {
} }
}) })
function toggleExplorer(this: HTMLElement) {
this.classList.toggle("collapsed")
this.setAttribute(
"aria-expanded",
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
)
const content = this.nextElementSibling as MaybeHTMLElement
if (!content) return
content.classList.toggle("collapsed")
}
function toggleFolder(evt: MouseEvent) { function toggleFolder(evt: MouseEvent) {
evt.stopPropagation() evt.stopPropagation()
// Element that was clicked
const target = evt.target as MaybeHTMLElement const target = evt.target as MaybeHTMLElement
if (!target) return if (!target) return
// Check if target was svg icon or button
const isSvg = target.nodeName === "svg" const isSvg = target.nodeName === "svg"
// corresponding <ul> element relative to clicked button/folder
const childFolderContainer = ( const childFolderContainer = (
isSvg isSvg
? target.parentElement?.nextSibling ? target.parentElement?.nextSibling
@ -42,31 +41,72 @@ function toggleFolder(evt: MouseEvent) {
isSvg ? target.nextElementSibling : target.parentElement isSvg ? target.nextElementSibling : target.parentElement
) as MaybeHTMLElement ) as MaybeHTMLElement
if (!(childFolderContainer && currentFolderParent)) return if (!(childFolderContainer && currentFolderParent)) return
// <li> element of folder (stores folder-path dataset)
childFolderContainer.classList.toggle("open") childFolderContainer.classList.toggle("open")
// Collapse folder container
const isCollapsed = childFolderContainer.classList.contains("open") const isCollapsed = childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, !isCollapsed) setFolderState(childFolderContainer, !isCollapsed)
// Save folder state to localStorage
const fullFolderPath = currentFolderParent.dataset.folderpath as string const fullFolderPath = currentFolderParent.dataset.folderpath as string
toggleCollapsedByPath(currentExplorerState, fullFolderPath) toggleCollapsedByPath(currentExplorerState, fullFolderPath)
const stringifiedFileTree = JSON.stringify(currentExplorerState) const stringifiedFileTree = JSON.stringify(currentExplorerState)
localStorage.setItem("fileTree", stringifiedFileTree) localStorage.setItem("fileTree", stringifiedFileTree)
} }
function setupExplorer() { function toggleExplorer(this: HTMLElement) {
const explorer = document.getElementById("explorer") // Toggle collapsed state of entire explorer
if (!explorer) return this.classList.toggle("collapsed")
const content = this.nextElementSibling as MaybeHTMLElement
if (!content) return
content.classList.toggle("collapsed")
//content.style.maxHeight = content.style.maxHeight === "0px" ? content.scrollHeight + "px" : "0px"
content.style.maxHeight = content.style.maxHeight === "0px" ? "calc(100vh - 8rem)" : "0px"
if (explorer.dataset.behavior === "collapse") { //prevent scroll under
if (this.dataset.mobile === "true" && document.querySelector("#mobile-explorer")) {
const article = document.querySelectorAll(
".popover-hint, footer, .backlinks, .graph, .toc, #progress",
)
const header = document.querySelector(".page .page-header")
if (article)
article.forEach((element) => {
element.classList.toggle("no-scroll")
})
if (header) header.classList.toggle("fixed")
}
}
function setupExplorer() {
// Set click handler for collapsing entire explorer
const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
for (const explorer of allExplorers) {
// 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") {
for (const item of document.getElementsByClassName( for (const item of document.getElementsByClassName(
"folder-button", "folder-button",
) as HTMLCollectionOf<HTMLElement>) { ) as HTMLCollectionOf<HTMLElement>) {
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
item.addEventListener("click", toggleFolder) item.addEventListener("click", toggleFolder)
window.addCleanup(() => item.removeEventListener("click", toggleFolder))
} }
} }
explorer.addEventListener("click", toggleExplorer) // Add click handler to main explorer
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer)) window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
explorer.addEventListener("click", toggleExplorer)
}
// Set up click handlers for each folder (click handler on folder "icon") // Set up click handlers for each folder (click handler on folder "icon")
for (const item of document.getElementsByClassName( for (const item of document.getElementsByClassName(
@ -77,33 +117,74 @@ function setupExplorer() {
} }
// Get folder state from local storage // Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
const useSavedFolderState = explorer?.dataset.savestate === "true"
const oldExplorerState: FolderState[] = const oldExplorerState: FolderState[] =
storageTree && useSavedFolderState ? JSON.parse(storageTree) : [] storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed])) const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
//console.log(explorer.dataset.tree)
//console.log(explorer.dataset.tree ? JSON.parse(explorer.dataset.tree) : [])
const newExplorerState: FolderState[] = explorer.dataset.tree const newExplorerState: FolderState[] = explorer.dataset.tree
? JSON.parse(explorer.dataset.tree) ? JSON.parse(explorer.dataset.tree)
: [] : []
currentExplorerState = [] currentExplorerState = []
for (const { path, collapsed } of newExplorerState) { for (const { path, collapsed } of newExplorerState) {
currentExplorerState.push({ path, collapsed: oldIndex.get(path) ?? collapsed }) currentExplorerState.push({ path, collapsed: /*oldIndex.get(path) ?? */ collapsed })
} }
currentExplorerState.map((folderState) => { currentExplorerState.map((folderState) => {
const folderLi = document.querySelector( const folderLi = document.querySelector(
`[data-folderpath='${folderState.path}']`, `[data-folderpath='${folderState.path.replace("'", "-")}']`,
) as MaybeHTMLElement ) as MaybeHTMLElement
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
if (folderUl) { if (folderUl) {
setFolderState(folderUl, folderState.collapsed) setFolderState(folderUl, folderState.collapsed)
} }
}) })
}
}
function toggleExplorerFolders() {
const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
/\/index$/g,
"",
)
const listToReplace = document.querySelectorAll(".folder-outer:has(> ul[data-folderul]")
listToReplace.forEach((element) => {
if (element.children.length > 0) {
if (currentFile.includes(element.firstElementChild?.getAttribute("data-folderul") ?? "")) {
if (!element.classList.contains("open")) {
element.classList.add("open")
}
}
}
})
} }
window.addEventListener("resize", setupExplorer) window.addEventListener("resize", setupExplorer)
document.addEventListener("DOMContentLoaded", () => {
const explorer = document.querySelector("#mobile-explorer")
if (explorer) {
explorer.classList.add("collapsed")
const content = explorer.nextElementSibling as HTMLElement
content.classList.add("collapsed")
content.style.maxHeight = "0px"
}
toggleExplorerFolders()
})
document.addEventListener("nav", () => { document.addEventListener("nav", () => {
const explorer = document.querySelector("#mobile-explorer")
if (explorer) {
explorer.classList.add("collapsed")
const content = explorer.nextElementSibling as HTMLElement
content.classList.add("collapsed")
content.style.maxHeight = "0px"
}
setupExplorer() setupExplorer()
//add collapsed class to all folders
observer.disconnect() observer.disconnect()
// select pseudo element at end of list // select pseudo element at end of list
@ -111,6 +192,8 @@ document.addEventListener("nav", () => {
if (lastItem) { if (lastItem) {
observer.observe(lastItem) observer.observe(lastItem)
} }
toggleExplorerFolders()
}) })
/** /**

View File

@ -1,7 +1,23 @@
@use "../../styles/variables.scss" as *; @use "../../styles/variables.scss" as *;
.explorer { .explorer-container {
.mobile-explorer {
display: none;
}
.desktop-explorer {
display: flex; display: flex;
}
@media all and ($mobile) {
.mobile-explorer {
display: flex;
}
.desktop-explorer {
display: none;
}
}
.mobile-explorer,
.desktop-explorer {
flex-direction: column; flex-direction: column;
overflow-y: hidden; overflow-y: hidden;
&.desktop-only { &.desktop-only {
@ -21,9 +37,10 @@
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
background: linear-gradient(transparent 0px, var(--light)); background: linear-gradient(transparent 0px, var(--light));
}*/ }*/
} }
button#explorer { button#mobile-explorer,
button#desktop-explorer {
background-color: transparent; background-color: transparent;
border: none; border: none;
text-align: left; text-align: left;
@ -48,23 +65,23 @@ button#explorer {
&.collapsed .fold { &.collapsed .fold {
transform: rotateZ(-90deg); transform: rotateZ(-90deg);
} }
} }
.folder-outer { .folder-outer {
display: grid; display: grid;
grid-template-rows: 0fr; grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out; transition: grid-template-rows 0.3s ease-in-out;
} }
.folder-outer.open { .folder-outer.open {
grid-template-rows: 1fr; grid-template-rows: 1fr;
} }
.folder-outer > ul { .folder-outer > ul {
overflow: hidden; overflow: hidden;
} }
#explorer-content { #explorer-content {
list-style: none; list-style: none;
overflow: hidden; overflow: hidden;
overflow-y: auto; overflow-y: auto;
@ -100,17 +117,17 @@ button#explorer {
> #explorer-ul { > #explorer-ul {
max-height: none; max-height: none;
} }
} }
svg { svg {
pointer-events: all; pointer-events: all;
& > polyline { & > polyline {
pointer-events: none; pointer-events: none;
} }
} }
.folder-container { .folder-container {
flex-direction: row; flex-direction: row;
display: flex; display: flex;
align-items: center; align-items: center;
@ -151,31 +168,103 @@ svg {
pointer-events: none; pointer-events: none;
} }
} }
} }
.folder-icon { .folder-icon {
margin-right: 5px; margin-right: 5px;
color: var(--secondary); color: var(--secondary);
cursor: pointer; cursor: pointer;
transition: transform 0.3s ease; transition: transform 0.3s ease;
backface-visibility: visible; backface-visibility: visible;
} }
li:has(> .folder-outer:not(.open)) > .folder-container > svg { li:has(> .folder-outer:not(.open)) > .folder-container > svg {
transform: rotate(-90deg); transform: rotate(-90deg);
} }
.folder-icon:hover { .folder-icon:hover {
color: var(--tertiary); color: var(--tertiary);
} }
.no-background::after { .no-background::after {
background: none !important; background: none !important;
} }
#explorer-end { #explorer-end {
// needs height so IntersectionObserver gets triggered // needs height so IntersectionObserver gets triggered
height: 4px; height: 4px;
// remove default margin from li // remove default margin from li
margin: 0; margin: 0;
}
.mobile-explorer,
.desktop-explorer {
#explorer-content {
//left: 0;
z-index: 100;
position: absolute;
//max-height: max-content !important;
background-color: var(--light);
//max-width: calc(100% - 36px);
max-width: 100%;
width: calc(100% - 36px + 4px);
//margin-right: 36px;
transition: all 300ms ease-in-out;
overflow: hidden;
padding: 0 16px;
height: calc(100dvh - 8rem);
//border-top-left-radius: 23px;
//border-top-right-radius: 23px;
border-radius: 23px;
margin-top: 36px;
&:not(.collapsed) {
height: calc(100dvh - 8rem);
//top: 10dvh;
}
ul.overflow {
max-height: calc(100dvh - 8rem);
width: 100%;
}
&.collapsed {
height: 0;
}
}
#mobile-explorer,
#desktop-explorer {
&:not(.collapsed) .lucide-menu {
transform: rotate(90deg);
transition: transform 200ms ease-in-out;
}
.lucide-menu {
stroke: var(--darkgray);
transition: transform 200ms ease;
&:hover {
stroke: var(--dark);
}
}
}
}
.no-scroll {
opacity: 0;
overflow: hidden;
}
html:has(.no-scroll) {
overflow: hidden;
}
@media all and not ($mobile) {
.no-scroll {
opacity: 1 !important;
overflow: auto !important;
}
html:has(.no-scroll) {
overflow: auto !important;
}
}
} }