Compare commits

...

3 Commits

Author SHA1 Message Date
Anton Bulakh
30a26c41f3
Merge 0bafa4c94a0b63b30b456a6fd02c6bdd701cdaee into 91189dfd2f4cb32e205117b327e0ae7a0c2dd716 2025-02-03 09:26:03 -05:00
Emile Bangma
91189dfd2f
feat(explorer): collapsible mobile explorer (#1471)
Some checks failed
Build and Test / build-and-test (macos-latest) (push) Has been cancelled
Build and Test / build-and-test (ubuntu-latest) (push) Has been cancelled
Build and Test / build-and-test (windows-latest) (push) Has been cancelled
Build and Test / publish-tag (push) Has been cancelled
Docker build & push image / build (push) Has been cancelled
Co-authored-by: Aaron Pham <Aaronpham0103@gmail.com>
2025-02-03 09:25:42 -05:00
Anton Bulakh
0bafa4c94a
fix(folders): Use real folder names instead of splicing slugs
In breadcrumbs and folder pages (both folder page titles and page
lists) the name of the folder was derived from the slug unless
overriden, which is.. wonky.

This is much more noticeable if you change the slugify function to make
all slugs lowercase - which I did, and which may be a followup PR.

The patch was pretty straightforward though, we just use the real
folder names from the relativePath.
2025-01-24 03:38:58 +02:00
7 changed files with 369 additions and 105 deletions

View File

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

View File

@ -40,11 +40,8 @@ const defaultOptions: BreadcrumbOptions = {
showCurrentPage: true,
}
function formatCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
return {
displayName: displayName.replaceAll("-", " "),
path: resolveRelative(baseSlug, currentSlug),
}
function newCrumb(displayName: string, baseSlug: FullSlug, currentSlug: SimpleSlug): CrumbData {
return { displayName, path: resolveRelative(baseSlug, currentSlug) }
}
export default ((opts?: Partial<BreadcrumbOptions>) => {
@ -65,7 +62,7 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
}
// Format entry for root element
const firstEntry = formatCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug)
const firstEntry = newCrumb(options.rootName, fileData.slug!, "/" as SimpleSlug)
const crumbs: CrumbData[] = [firstEntry]
if (!folderIndex && options.resolveFrontmatterTitle) {
@ -81,6 +78,7 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
// Split slug into hierarchy/parts
const slugParts = fileData.slug?.split("/")
const pathParts = fileData.relativePath?.split("/")
if (slugParts) {
// is tag breadcrumb?
const isTagPath = slugParts[0] === "tags"
@ -89,7 +87,7 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
let currentPath = ""
for (let i = 0; i < slugParts.length - 1; i++) {
let curPathSegment = slugParts[i]
let curPathSegment = pathParts?.[i] ?? slugParts[i]
// Try to resolve frontmatter folder title
const currentFile = folderIndex?.get(slugParts.slice(0, i + 1).join("/"))
@ -105,7 +103,7 @@ export default ((opts?: Partial<BreadcrumbOptions>) => {
const includeTrailingSlash = !isTagPath || i < 1
// Format and add current crumb
const crumb = formatCrumb(
const crumb = newCrumb(
curPathSegment,
fileData.slug!,
(currentPath + (includeTrailingSlash ? "/" : "")) as SimpleSlug,

View File

@ -1,5 +1,5 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import explorerStyle from "./styles/explorer.scss"
import style from "./styles/explorer.scss"
// @ts-ignore
import script from "./scripts/explorer.inline"
@ -83,18 +83,46 @@ export default ((userOpts?: Partial<Options>) => {
lastBuildId = ctx.buildId
constructFileTree(allFiles)
}
return (
<div class={classNames(displayClass, "explorer")}>
<button
type="button"
id="explorer"
id="mobile-explorer"
class="collapsed hide-until-loaded"
data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-tree={jsonTree}
data-mobile={true}
aria-controls="explorer-content"
aria-expanded={opts.folderDefaultState === "open"}
aria-expanded={false}
>
<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>
<button
type="button"
id="desktop-explorer"
class="title-button"
data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-tree={jsonTree}
data-mobile={false}
aria-controls="explorer-content"
aria-expanded={true}
>
<h2>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h2>
<svg
@ -122,7 +150,7 @@ export default ((userOpts?: Partial<Options>) => {
)
}
Explorer.css = explorerStyle
Explorer.css = style
Explorer.afterDOMLoaded = script
return Explorer
}) satisfies QuartzComponentConstructor

View File

@ -23,6 +23,11 @@ const defaultOptions: FolderContentOptions = {
showSubfolders: true,
}
type Subfolder = {
name: string
contents: QuartzPluginData[]
}
export default ((opts?: Partial<FolderContentOptions>) => {
const options: FolderContentOptions = { ...defaultOptions, ...opts }
@ -31,51 +36,56 @@ export default ((opts?: Partial<FolderContentOptions>) => {
const folderSlug = stripSlashes(simplifySlug(fileData.slug!))
const folderParts = folderSlug.split(path.posix.sep)
const allPagesInFolder: QuartzPluginData[] = []
const allPagesInSubfolders: Map<FullSlug, QuartzPluginData[]> = new Map()
const shownPages: QuartzPluginData[] = []
const subfolders: Map<FullSlug, Subfolder> = new Map()
allFiles.forEach((file) => {
for (const file of allFiles) {
const fileSlug = stripSlashes(simplifySlug(file.slug!))
const prefixed = fileSlug.startsWith(folderSlug) && fileSlug !== folderSlug
const fileParts = fileSlug.split(path.posix.sep)
const isDirectChild = fileParts.length === folderParts.length + 1
if (!prefixed) {
return
// check only files in our folder or nested folders
if (!fileSlug.startsWith(folderSlug) || fileSlug === folderSlug) {
continue
}
if (isDirectChild) {
allPagesInFolder.push(file)
} else if (options.showSubfolders) {
const fileParts = fileSlug.split(path.posix.sep)
// If the file is directly in the folder we just show it
if (fileParts.length === folderParts.length + 1) {
shownPages.push(file)
continue
}
if (options.showSubfolders) {
const subfolderSlug = joinSegments(
...fileParts.slice(0, folderParts.length + 1),
) as FullSlug
const pagesInFolder = allPagesInSubfolders.get(subfolderSlug) || []
allPagesInSubfolders.set(subfolderSlug, [...pagesInFolder, file])
}
})
allPagesInSubfolders.forEach((files, subfolderSlug) => {
const hasIndex = allPagesInFolder.some(
(file) => subfolderSlug === stripSlashes(simplifySlug(file.slug!)),
)
let subfolder = subfolders.get(subfolderSlug)
if (!subfolder) {
const subfolderName = file.relativePath!.split(path.posix.sep).at(folderParts.length)!
subfolders.set(subfolderSlug, (subfolder = { name: subfolderName, contents: [] }))
}
subfolder.contents.push(file)
}
}
for (const [slug, subfolder] of subfolders.entries()) {
const hasIndex = shownPages.some((file) => slug === stripSlashes(simplifySlug(file.slug!)))
if (!hasIndex) {
const subfolderDates = files.sort(byDateAndAlphabetical(cfg))[0].dates
const subfolderTitle = subfolderSlug.split(path.posix.sep).at(-1)!
allPagesInFolder.push({
slug: subfolderSlug,
const subfolderDates = subfolder.contents.sort(byDateAndAlphabetical(cfg))[0].dates
shownPages.push({
slug: slug,
dates: subfolderDates,
frontmatter: { title: subfolderTitle, tags: ["folder"] },
frontmatter: { title: subfolder.name, tags: ["folder"] },
})
}
})
}
const cssClasses: string[] = fileData.frontmatter?.cssclasses ?? []
const classes = cssClasses.join(" ")
const listProps = {
...props,
sort: options.sort,
allFiles: allPagesInFolder,
allFiles: shownPages,
}
const content =
@ -90,7 +100,7 @@ export default ((opts?: Partial<FolderContentOptions>) => {
{options.showFolderCount && (
<p>
{i18n(cfg.locale).pages.folderContent.itemsUnderFolder({
count: allPagesInFolder.length,
count: shownPages.length,
})}
</p>
)}

View File

@ -1,7 +1,9 @@
import { FolderState } from "../ExplorerNode"
// Current state of folders
type MaybeHTMLElement = HTMLElement | undefined
let currentExplorerState: FolderState[]
const observer = new IntersectionObserver((entries) => {
// If last element is observed, remove gradient of "overflow" class so element is visible
const explorerUl = document.getElementById("explorer-ul")
@ -16,23 +18,43 @@ const observer = new IntersectionObserver((entries) => {
})
function toggleExplorer(this: HTMLElement) {
// Toggle collapsed state of entire explorer
this.classList.toggle("collapsed")
// Toggle collapsed aria state of entire explorer
this.setAttribute(
"aria-expanded",
this.getAttribute("aria-expanded") === "true" ? "false" : "true",
)
const content = this.nextElementSibling as MaybeHTMLElement
if (!content) return
const content = (
this.nextElementSibling?.nextElementSibling
? this.nextElementSibling.nextElementSibling
: this.nextElementSibling
) as MaybeHTMLElement
if (!content) return
content.classList.toggle("collapsed")
content.classList.toggle("explorer-viewmode")
// Prevent scroll under
if (document.querySelector("#mobile-explorer")) {
// Disable scrolling on the page when the explorer is opened on mobile
const bodySelector = document.querySelector("#quartz-body")
if (bodySelector) bodySelector.classList.toggle("lock-scroll")
}
}
function toggleFolder(evt: MouseEvent) {
evt.stopPropagation()
// Element that was clicked
const target = evt.target as MaybeHTMLElement
if (!target) return
// Check if target was svg icon or button
const isSvg = target.nodeName === "svg"
// corresponding <ul> element relative to clicked button/folder
const childFolderContainer = (
isSvg
? target.parentElement?.nextSibling
@ -42,10 +64,14 @@ function toggleFolder(evt: MouseEvent) {
isSvg ? target.nextElementSibling : target.parentElement
) as MaybeHTMLElement
if (!(childFolderContainer && currentFolderParent)) return
// <li> element of folder (stores folder-path dataset)
childFolderContainer.classList.toggle("open")
// Collapse folder container
const isCollapsed = childFolderContainer.classList.contains("open")
setFolderState(childFolderContainer, !isCollapsed)
// Save folder state to localStorage
const fullFolderPath = currentFolderParent.dataset.folderpath as string
toggleCollapsedByPath(currentExplorerState, fullFolderPath)
const stringifiedFileTree = JSON.stringify(currentExplorerState)
@ -53,20 +79,34 @@ function toggleFolder(evt: MouseEvent) {
}
function setupExplorer() {
const explorer = document.getElementById("explorer")
if (!explorer) return
// Set click handler for collapsing entire explorer
const allExplorers = document.querySelectorAll(".explorer > button") as NodeListOf<HTMLElement>
if (explorer.dataset.behavior === "collapse") {
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(
"folder-button",
) as HTMLCollectionOf<HTMLElement>) {
window.addCleanup(() => explorer.removeEventListener("click", toggleExplorer))
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))
explorer.addEventListener("click", toggleExplorer)
}
// Set up click handlers for each folder (click handler on folder "icon")
for (const item of document.getElementsByClassName(
@ -77,8 +117,6 @@ function setupExplorer() {
}
// Get folder state from local storage
const storageTree = localStorage.getItem("fileTree")
const useSavedFolderState = explorer?.dataset.savestate === "true"
const oldExplorerState: FolderState[] =
storageTree && useSavedFolderState ? JSON.parse(storageTree) : []
const oldIndex = new Map(oldExplorerState.map((entry) => [entry.path, entry.collapsed]))
@ -86,13 +124,17 @@ function setupExplorer() {
? JSON.parse(explorer.dataset.tree)
: []
currentExplorerState = []
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) => {
const folderLi = document.querySelector(
`[data-folderpath='${folderState.path}']`,
`[data-folderpath='${folderState.path.replace("'", "-")}']`,
) as MaybeHTMLElement
const folderUl = folderLi?.parentElement?.nextElementSibling as MaybeHTMLElement
if (folderUl) {
@ -100,10 +142,43 @@ function setupExplorer() {
}
})
}
}
function toggleExplorerFolders() {
const currentFile = (document.querySelector("body")?.getAttribute("data-slug") ?? "").replace(
/\/index$/g,
"",
)
const allFolders = document.querySelectorAll(".folder-outer")
allFolders.forEach((element) => {
const folderUl = Array.from(element.children).find((child) =>
child.matches("ul[data-folderul]"),
)
if (folderUl) {
if (currentFile.includes(folderUl.getAttribute("data-folderul") ?? "")) {
if (!element.classList.contains("open")) {
element.classList.add("open")
}
}
}
})
}
window.addEventListener("resize", setupExplorer)
document.addEventListener("nav", () => {
const explorer = document.querySelector("#mobile-explorer")
if (explorer) {
explorer.classList.add("collapsed")
const content = explorer.nextElementSibling?.nextElementSibling as HTMLElement
if (content) {
content.classList.add("collapsed")
content.classList.toggle("explorer-viewmode")
}
}
setupExplorer()
observer.disconnect()
// select pseudo element at end of list
@ -111,6 +186,12 @@ document.addEventListener("nav", () => {
if (lastItem) {
observer.observe(lastItem)
}
// Hide explorer on mobile until it is requested
const hiddenUntilDoneLoading = document.querySelector("#mobile-explorer")
hiddenUntilDoneLoading?.classList.remove("hide-until-loaded")
toggleExplorerFolders()
})
/**

View File

@ -1,14 +1,70 @@
@use "../../styles/variables.scss" as *;
@media all and ($mobile) {
.page > #quartz-body {
// Shift page position when toggling Explorer on mobile.
& > :not(.sidebar.left:has(.explorer)) {
transform: translateX(0);
transition: transform 300ms ease-in-out;
}
&.lock-scroll > :not(.sidebar.left:has(.explorer)) {
transform: translateX(100dvw);
transition: transform 300ms ease-in-out;
}
// Sticky top bar (stays in place when scrolling down on mobile).
.sidebar.left:has(.explorer) {
box-sizing: border-box;
position: sticky;
background-color: var(--light);
}
// Hide Explorer on mobile until done loading.
// Prevents ugly animation on page load.
.hide-until-loaded ~ #explorer-content {
display: none;
}
}
}
.explorer {
display: flex;
height: 100%;
flex-direction: column;
overflow-y: hidden;
@media all and ($mobile) {
order: -1;
height: initial;
overflow: hidden;
flex-shrink: 0;
align-self: flex-start;
}
button#mobile-explorer {
display: none;
}
button#desktop-explorer {
display: flex;
}
@media all and ($mobile) {
button#mobile-explorer {
display: flex;
}
button#desktop-explorer {
display: none;
}
}
&.desktop-only {
@media all and not ($mobile) {
display: flex;
}
}
/*&:after {
pointer-events: none;
content: "";
@ -23,7 +79,8 @@
}*/
}
button#explorer {
button#mobile-explorer,
button#desktop-explorer {
background-color: transparent;
border: none;
text-align: left;
@ -68,19 +125,19 @@ button#explorer {
list-style: none;
overflow: hidden;
overflow-y: auto;
max-height: 0px;
transition:
max-height 0.35s ease,
visibility 0s linear 0.35s;
margin-top: 0.5rem;
visibility: hidden;
&.collapsed {
max-height: 100%;
transition:
max-height 0.35s ease,
visibility 0s linear 0s;
margin-top: 0.5rem;
visibility: visible;
&.collapsed {
max-height: 0;
transition:
max-height 0.35s ease,
visibility 0s linear 0.35s;
visibility: hidden;
}
& ul {
@ -91,12 +148,14 @@ button#explorer {
max-height 0.35s ease,
transform 0.35s ease,
opacity 0.2s ease;
& li > a {
color: var(--dark);
opacity: 0.75;
pointer-events: all;
}
}
> #explorer-ul {
max-height: none;
}
@ -179,3 +238,80 @@ li:has(> .folder-outer:not(.open)) > .folder-container > svg {
// remove default margin from li
margin: 0;
}
.explorer {
@media all and ($mobile) {
#explorer-content {
box-sizing: border-box;
overscroll-behavior: none;
z-index: 100;
position: absolute;
top: 0;
background-color: var(--light);
max-width: 100dvw;
left: -100dvw;
width: 100%;
transition: transform 300ms ease-in-out;
overflow: hidden;
padding: $topSpacing 2rem 2rem;
height: 100dvh;
max-height: 100dvh;
margin-top: 0;
visibility: hidden;
&:not(.collapsed) {
transform: translateX(100dvw);
visibility: visible;
}
ul.overflow {
max-height: 100%;
width: 100%;
}
&.collapsed {
transform: translateX(0);
visibility: visible;
}
}
#mobile-explorer {
margin: 5px;
z-index: 101;
&: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;
}
}

View File

@ -74,13 +74,27 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
const allFiles = content.map((c) => c[1].data)
const cfg = ctx.cfg.configuration
const folderNames: Record<SimpleSlug, string> = {}
const folders: Set<SimpleSlug> = new Set(
allFiles.flatMap((data) => {
return data.slug
? _getFolders(data.slug).filter(
(folderName) => folderName !== "." && folderName !== "tags",
)
: []
if (!data.slug || !data.relativePath) {
return []
}
let folderSlug = path.dirname(data.slug) as SimpleSlug
let folderFs = path.dirname(data.relativePath) as SimpleSlug
folderNames[folderSlug] = folderFs
const folders = [folderSlug]
while (folderSlug !== ".") {
folderSlug = path.dirname(folderSlug) as SimpleSlug
folders.push(folderSlug)
folderFs = path.dirname(folderFs) as SimpleSlug
folderNames[folderSlug] = folderFs
}
return folders.filter((f) => f !== "." && f !== "tags")
}),
)
@ -89,8 +103,9 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
folder,
defaultProcessedContent({
slug: joinSegments(folder, "index") as FullSlug,
relativePath: joinSegments(folderNames[folder], "index.html") as FilePath, // this is used by breadcrumbs
frontmatter: {
title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folder}`,
title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folderNames[folder]}`,
tags: [],
},
}),
@ -100,9 +115,16 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
for (const [tree, file] of content) {
const slug = stripSlashes(simplifySlug(file.data.slug!)) as SimpleSlug
if (folders.has(slug)) {
if (file.data.frontmatter?.title === "index") {
// sadly we need to avoid changing the original file title for things like explorer to work
const clonedFile = structuredClone(file)
clonedFile.data.frontmatter!.title = `${i18n(cfg.locale).pages.folderContent.folder}: ${folderNames[slug]}`
folderDescriptions[slug] = [tree, clonedFile]
} else {
folderDescriptions[slug] = [tree, file]
}
}
}
for (const folder of folders) {
const slug = joinSegments(folder, "index") as FullSlug
@ -132,14 +154,3 @@ export const FolderPage: QuartzEmitterPlugin<Partial<FolderPageOptions>> = (user
},
}
}
function _getFolders(slug: FullSlug): SimpleSlug[] {
var folderName = path.dirname(slug ?? "") as SimpleSlug
const parentFolderNames = [folderName]
while (folderName !== ".") {
folderName = path.dirname(folderName ?? "") as SimpleSlug
parentFolderNames.push(folderName)
}
return parentFolderNames
}