From cbfb898c51ed2b02cf6fee5c632ad9adec23df76 Mon Sep 17 00:00:00 2001 From: Jafeth Garro <45522320+IAmJafeth@users.noreply.github.com> Date: Wed, 18 Sep 2024 08:15:53 -0600 Subject: [PATCH] Implemented OverlayExplorer for mobile view --- quartz.layout.ts | 7 + quartz/extra/OverlayExplorer.tsx | 218 ++++++++++++++++++ quartz/extra/index.ts | 5 + .../extra/scripts/overlayexplorer.inline.ts | 108 +++++++++ quartz/extra/styles/overlayexplorer.scss | 164 +++++++++++++ 5 files changed, 502 insertions(+) create mode 100644 quartz/extra/OverlayExplorer.tsx create mode 100644 quartz/extra/index.ts create mode 100644 quartz/extra/scripts/overlayexplorer.inline.ts create mode 100644 quartz/extra/styles/overlayexplorer.scss diff --git a/quartz.layout.ts b/quartz.layout.ts index 8912937..51c2d05 100644 --- a/quartz.layout.ts +++ b/quartz.layout.ts @@ -1,5 +1,6 @@ import { PageLayout, SharedLayout } from "./quartz/cfg" import * as Component from "./quartz/components" +import * as ExtraComponent from "./quartz/extra" // components shared across all pages export const sharedPageComponents: SharedLayout = { @@ -26,6 +27,9 @@ export const defaultContentPageLayout: PageLayout = { left: [ Component.PageTitle(), Component.MobileOnly(Component.Spacer()), + Component.MobileOnly(ExtraComponent.OverlayExplorer( + {filterFn: (node) => (node.name !== "tags" && node.name !== "License")}, + )), Component.Search(), Component.Darkmode(), Component.DesktopOnly(Component.Explorer()), @@ -43,6 +47,9 @@ export const defaultListPageLayout: PageLayout = { left: [ Component.PageTitle(), Component.MobileOnly(Component.Spacer()), + Component.MobileOnly(ExtraComponent.OverlayExplorer( + {filterFn: (node) => (node.name !== "tags" && node.name !== "License")}, + )), Component.Search(), Component.Darkmode(), Component.DesktopOnly(Component.Explorer()), diff --git a/quartz/extra/OverlayExplorer.tsx b/quartz/extra/OverlayExplorer.tsx new file mode 100644 index 0000000..420cb38 --- /dev/null +++ b/quartz/extra/OverlayExplorer.tsx @@ -0,0 +1,218 @@ +// Nothing yet +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "../components/types" +import overlayexplorerStyle from "./styles/overlayexplorer.scss" + +// @ts-ignore +import script from "./scripts/overlayexplorer.inline" +import { FileNode, Options } from "../components/ExplorerNode" +import { QuartzPluginData } from "../plugins/vfile" +import { classNames } from "../util/lang" +import { i18n } from "../i18n" +import { joinSegments, resolveRelative } from "../util/path" + +interface OlOptions extends Omit { + folderClickBehavior: "collapse" | "link" | "mixed" +} + +const defaultOptions = { + folderClickBehavior: "mixed", + folderDefaultState: "collapsed", + useSavedState: true, + mapFn: (node) => { + return node + }, + sortFn: (a, b) => { + // Sort order: folders first, then files. Sort folders and files alphabetically + if ((!a.file && !b.file) || (a.file && b.file)) { + // numeric: true: Whether numeric collation should be used, such that "1" < "2" < "10" + // sensitivity: "base": Only strings that differ in base letters compare as unequal. Examples: a ≠ b, a = á, a = A + return a.displayName.localeCompare(b.displayName, undefined, { + numeric: true, + sensitivity: "base", + }) + } + + if (a.file && !b.file) { + return 1 + } else { + return -1 + } + }, + filterFn: (node) => node.name !== "tags", + order: ["filter", "map", "sort"], +} satisfies OlOptions + +type OlExplorerNodeProps = { + node: FileNode + opts: OlOptions + fileData: QuartzPluginData + fullPath?: string +} + +function OverlayExplorerNode({node, opts, fullPath, fileData}: OlExplorerNodeProps) { + + // Calculate current folderPath + const folderPath = node.name !== "" ? joinSegments(fullPath ?? "", node.name) : "" + const href = resolveRelative(fileData.slug!, folderPath as SimpleSlug) + "/" + + return ( + <> + {node.file ? ( +
  • + + {node.displayName} + +
  • + ) : ( +
  • + {node.name !== "" && ( +
    + + + + {opts.folderClickBehavior === "link" ? ( + + {node.displayName} + + ) : ( + <> + + {opts.folderClickBehavior === "mixed" && ( + + + + + + + + )} + + )} +
    + )} +
    +
      + {node.children.map((childNode, i) => ( + + ))} +
    +
    +
  • + )} + + ) +} + +export default ((userOpts?: Partial) => { + // Parse config + const opts: OlOptions = { ...defaultOptions, ...userOpts } + + // memoized + let fileTree: FileNode + let lastBuildId: string = "" + + function constructFileTree(allFiles: QuartzPluginData[]) { + // Construct tree from allFiles + fileTree = new FileNode("") + allFiles.forEach((file) => fileTree.add(file)) + + // Execute all functions (sort, filter, map) that were provided (if none were provided, only default "sort" is applied) + if (opts.order) { + // Order is important, use loop with index instead of order.map() + for (let i = 0; i < opts.order.length; i++) { + const functionName = opts.order[i] + if (functionName === "map") { + fileTree.map(opts.mapFn) + } else if (functionName === "sort") { + fileTree.sort(opts.sortFn) + } else if (functionName === "filter") { + fileTree.filter(opts.filterFn) + } + } + } + } + + const OverlayExplorer: QuartzComponent = ({ + ctx, + cfg, + allFiles, + displayClass, + fileData, + }: QuartzComponentProps) => { + if (ctx.buildId !== lastBuildId) { + lastBuildId = ctx.buildId + constructFileTree(allFiles) + } + + return ( +
    + +
    +
    +
    +
      + +
    +
    +
    +
    +
    + ) + } + + OverlayExplorer.css = overlayexplorerStyle + OverlayExplorer.afterDOMLoaded = script + return OverlayExplorer +}) satisfies QuartzComponentConstructor diff --git a/quartz/extra/index.ts b/quartz/extra/index.ts new file mode 100644 index 0000000..321786e --- /dev/null +++ b/quartz/extra/index.ts @@ -0,0 +1,5 @@ +import OverlayExplorer from "./OverlayExplorer" + +export { + OverlayExplorer, +} diff --git a/quartz/extra/scripts/overlayexplorer.inline.ts b/quartz/extra/scripts/overlayexplorer.inline.ts new file mode 100644 index 0000000..b3206ba --- /dev/null +++ b/quartz/extra/scripts/overlayexplorer.inline.ts @@ -0,0 +1,108 @@ +// Nothing yet + +import { registerEscapeHandler } from "../../components/scripts/util" + +type MaybeHTMLElement = HTMLElement | undefined + +function setFolder(folderPath: string, open: boolean) { + const childrenList = document.querySelector( + `[data-ol-children-for='${folderPath}']` + ) as MaybeHTMLElement + if (!childrenList) return + + const folderEntry = document.querySelector( + `[data-ol-selector-for='${folderPath}']` + ) as MaybeHTMLElement + if (!folderEntry) return + + const collapseIcon = folderEntry.getElementsByTagName( + "svg" + )[0] as MaybeHTMLElement + if (!collapseIcon) return + + if (open) { + childrenList.classList.add("open") + collapseIcon.classList.add("open") + } else { + childrenList.classList.remove("open") + collapseIcon.classList.remove("open") + } +} + +function setupOverlayExplorer() { + const openButton = document.getElementById("overlay-explorer-button") + const container = document.getElementById("overlay-explorer-container") + + const useSaveState = openButton?.dataset.olsavestate === "true" + + let folderOpenMap: Map + + if (useSaveState) { + const fromStorage = localStorage.getItem("olFileTree") + folderOpenMap = new Map(fromStorage ? JSON.parse(fromStorage) : []) + + for (let [key, value] of folderOpenMap) { + setFolder(key, value) + } + } + + function showExplorer() { + container?.classList.add("active") + } + + function hideExplorer() { + container?.classList.remove("active") + } + + function toggleFolder(evt: MouseEvent) { + evt.stopPropagation() + const target = evt.target as MaybeHTMLElement + if (!target) return + + const folderPath = target.parentNode.getAttribute("data-ol-selector-for") + const childrenList = document.querySelector( + `[data-ol-children-for='${folderPath}']` + ) as MaybeHTMLElement + if (!childrenList) return + + const collapseIcon = target.parentNode.getElementsByTagName( + "svg" + )[0] as MaybeHTMLElement + if (!collapseIcon) return + + childrenList.classList.toggle("open") + collapseIcon.classList.toggle("open") + + if (useSaveState) { + folderOpenMap.set(folderPath, collapseIcon.classList.contains("open")) + localStorage.setItem( + "olFileTree", + JSON.stringify(Array.from(folderOpenMap.entries())) + ) + } + } + + openButton.addEventListener("click", showExplorer) + window.addCleanup(() => openButton.removeEventListener("click", showExplorer)) + + // Set up click handlers for each folder (click handler on folder "icon") + for (const item of document.getElementsByClassName( + "ol-folder-icon", + ) as HTMLCollectionOf) { + item.addEventListener("click", toggleFolder) + window.addCleanup(() => item.removeEventListener("click", toggleFolder)) + } + + for (const item of document.getElementsByClassName( + "ol-folder-button", + ) as HTMLCollectionOf) { + item.addEventListener("click", toggleFolder) + window.addCleanup(() => item.removeEventListener("click", toggleFolder)) + } + + registerEscapeHandler(container, hideExplorer) +} + +document.addEventListener("nav", () => { + setupOverlayExplorer() +}) diff --git a/quartz/extra/styles/overlayexplorer.scss b/quartz/extra/styles/overlayexplorer.scss new file mode 100644 index 0000000..7862d22 --- /dev/null +++ b/quartz/extra/styles/overlayexplorer.scss @@ -0,0 +1,164 @@ +// Nothing yet +@use "../../styles/variables.scss" as *; + +.ol-folder-icon { + margin-right: 5px; + color: var(--secondary); + cursor: pointer; + transition: transform 0.3s ease; + backface-visibility: visible; + transform: rotate(-90deg); +} + +.ol-folder-icon.open { + transform: rotate(0deg); +} + +.ol-folder-outer { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease-in-out; +} + +.ol-folder-outer.open { + grid-template-rows: 1fr; +} + +.ol-folder-outer > ul { + overflow: hidden; +} + +.ol-folder-entry { + flex-direction: row; + display: flex; + align-items: center; + user-select: none; +} + +.ol-folder-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; + font-family: var(--headerFont); + + margin-right: 5px; + + & span { + font-size: 0.95rem; + display: inline-block; + color: var(--secondary); + font-weight: $semiBoldWeight; + margin: 0; + line-height: 1.5rem; + pointer-events: none; + } +} + +svg { + pointer-events: all; + + & > polyline { + pointer-events: none; + } +} + +.overlay-explorer { + padding: 0 0.5rem; + border: none; + + & > #overlay-explorer-button { + background-color: var(--lightgray); + border: none; + border-radius: 4px; + font-family: inherit; + font-size: inherit; + height: 2rem; + padding: 0; + display: flex; + align-items: center; + text-align: inherit; + cursor: pointer; + white-space: nowrap; + width: 100%; + justify-content: space-between; + + & > p { + display: inline; + padding: 0 1rem; + } + + & svg { + width: 18px; + height: 18px; + min-width: 18px; + margin: 0 0.5rem; + } + } + + & >#overlay-explorer-container { + position: fixed; + contain: layout; + z-index: 999; + left: 0; + top: 0; + width: 100vw; + height: 100vh; + overflow-y: auto; + display: none; + backdrop-filter: blur(4px); + + &.active { + display: inline-block; + } + + & > #overlay-explorer-space { + width: 65%; + margin-top: 12vh; + margin-left: auto; + margin-right: auto; + + @media all and (max-width: $fullPageWidth) { + max-width: 500px; + width: 90%; + } + + & > * { + width: 100%; + border-radius: 7px; + background: var(--light); + box-shadow: + 0 14px 50px rgba(27, 33, 48, 0.12), + 0 10px 30px rgba(27, 33, 48, 0.16); + margin-bottom: 2em; + } + + & > #overlay-explorer-content { + box-sizing: border-box; + padding: 0.5em 1em; + border: 1px solid var(--lightgray); + } + + & ul { + list-style: none; + margin: 0.08rem 0; + padding: 0; + transition: + max-height 0.35s ease, + transform 0.35s ease, + opacity 0.2s ease; + & li > a { + color: var(--dark); + opacity: 0.75; + pointer-events: all; + } + } + } + + } +}