collapsible toc
This commit is contained in:
parent
95cb3efe33
commit
73dfcab5eb
2
index.d.ts
vendored
2
index.d.ts
vendored
@ -5,7 +5,7 @@ declare module '*.scss' {
|
|||||||
|
|
||||||
// dom custom event
|
// dom custom event
|
||||||
interface CustomEventMap {
|
interface CustomEventMap {
|
||||||
"spa_nav": CustomEvent<{ url: string }>;
|
"nav": CustomEvent<{ url: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -4,9 +4,9 @@ import clipboardStyle from './styles/clipboard.scss'
|
|||||||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
||||||
|
|
||||||
function Body({ children }: QuartzComponentProps) {
|
function Body({ children }: QuartzComponentProps) {
|
||||||
return <article>
|
return <div id="quartz-body">
|
||||||
{children}
|
{children}
|
||||||
</article>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
Body.afterDOMLoaded = clipboardScript
|
Body.afterDOMLoaded = clipboardScript
|
||||||
|
@ -5,7 +5,7 @@ import { toJsxRuntime } from "hast-util-to-jsx-runtime"
|
|||||||
function Content({ tree }: QuartzComponentProps) {
|
function Content({ tree }: QuartzComponentProps) {
|
||||||
// @ts-ignore (preact makes it angry)
|
// @ts-ignore (preact makes it angry)
|
||||||
const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
|
const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
|
||||||
return content
|
return <article>{content}</article>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (() => Content) satisfies QuartzComponentConstructor
|
export default (() => Content) satisfies QuartzComponentConstructor
|
||||||
|
@ -2,6 +2,9 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
|
|||||||
import legacyStyle from "./styles/legacyToc.scss"
|
import legacyStyle from "./styles/legacyToc.scss"
|
||||||
import modernStyle from "./styles/toc.scss"
|
import modernStyle from "./styles/toc.scss"
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import script from "./scripts/toc.inline"
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
layout: 'modern' | 'legacy'
|
layout: 'modern' | 'legacy'
|
||||||
}
|
}
|
||||||
@ -10,15 +13,39 @@ const defaultOptions: Options = {
|
|||||||
layout: 'modern'
|
layout: 'modern'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ((opts?: Partial<Options>) => {
|
|
||||||
const layout = opts?.layout ?? defaultOptions.layout
|
|
||||||
function TableOfContents({ fileData }: QuartzComponentProps) {
|
function TableOfContents({ fileData }: QuartzComponentProps) {
|
||||||
if (!fileData.toc) {
|
if (!fileData.toc) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return <details class="toc" open>
|
return <>
|
||||||
<summary><h3>Table of Contents</h3></summary>
|
<button type="button" id="toc">
|
||||||
|
<h3>Table of Contents</h3>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" 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="toc-content">
|
||||||
|
<ul>
|
||||||
|
{fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||||
|
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>{tocEntry.text}</a>
|
||||||
|
</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
TableOfContents.css = modernStyle
|
||||||
|
TableOfContents.afterDOMLoaded = script
|
||||||
|
|
||||||
|
function LegacyTableOfContents({ fileData }: QuartzComponentProps) {
|
||||||
|
if (!fileData.toc) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <details id="toc" open>
|
||||||
|
<summary>
|
||||||
|
<h3>Table of Contents</h3>
|
||||||
|
</summary>
|
||||||
<ul>
|
<ul>
|
||||||
{fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
{fileData.toc.map(tocEntry => <li key={tocEntry.slug} class={`depth-${tocEntry.depth}`}>
|
||||||
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>{tocEntry.text}</a>
|
<a href={`#${tocEntry.slug}`} data-for={tocEntry.slug}>{tocEntry.text}</a>
|
||||||
@ -26,40 +53,9 @@ export default ((opts?: Partial<Options>) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
|
LegacyTableOfContents.css = legacyStyle
|
||||||
|
|
||||||
TableOfContents.css = layout === "modern" ? modernStyle : legacyStyle
|
export default ((opts?: Partial<Options>) => {
|
||||||
|
const layout = opts?.layout ?? defaultOptions.layout
|
||||||
if (layout === "modern") {
|
return layout === "modern" ? TableOfContents : LegacyTableOfContents
|
||||||
TableOfContents.afterDOMLoaded = `
|
|
||||||
const bufferPx = 150
|
|
||||||
const observer = new IntersectionObserver(entries => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
const slug = entry.target.id
|
|
||||||
const tocEntryElement = document.querySelector(\`a[data-for="$\{slug\}"]\`)
|
|
||||||
const windowHeight = entry.rootBounds?.height
|
|
||||||
if (windowHeight && tocEntryElement) {
|
|
||||||
if (entry.boundingClientRect.y < windowHeight) {
|
|
||||||
tocEntryElement.classList.add("in-view")
|
|
||||||
} else {
|
|
||||||
tocEntryElement.classList.remove("in-view")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")
|
|
||||||
headers.forEach(header => observer.observe(header))
|
|
||||||
}
|
|
||||||
|
|
||||||
init()
|
|
||||||
|
|
||||||
document.addEventListener("spa_nav", (e) => {
|
|
||||||
observer.disconnect()
|
|
||||||
init()
|
|
||||||
})
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
return TableOfContents
|
|
||||||
}) satisfies QuartzComponentConstructor
|
}) satisfies QuartzComponentConstructor
|
||||||
|
@ -3,6 +3,7 @@ const svgCopy =
|
|||||||
const svgCheck =
|
const svgCheck =
|
||||||
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>'
|
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>'
|
||||||
|
|
||||||
|
document.addEventListener("nav", () => {
|
||||||
const els = document.getElementsByTagName("pre")
|
const els = document.getElementsByTagName("pre")
|
||||||
for (let i = 0; i < els.length; i++) {
|
for (let i = 0; i < els.length; i++) {
|
||||||
const codeBlock = els[i].getElementsByTagName("code")[0]
|
const codeBlock = els[i].getElementsByTagName("code")[0]
|
||||||
@ -27,3 +28,4 @@ for (let i = 0; i < els.length; i++) {
|
|||||||
})
|
})
|
||||||
els[i].prepend(button)
|
els[i].prepend(button)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
@ -30,7 +30,7 @@ const getOpts = ({ target }: Event): { url: URL, scroll?: boolean } | undefined
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notifyNav(slug: string) {
|
function notifyNav(slug: string) {
|
||||||
const event = new CustomEvent("spa_nav", { detail: { slug } })
|
const event = new CustomEvent("nav", { detail: { slug } })
|
||||||
document.dispatchEvent(event)
|
document.dispatchEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,6 +96,7 @@ function createRouter() {
|
|||||||
return
|
return
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return new class Router {
|
return new class Router {
|
||||||
go(pathname: string) {
|
go(pathname: string) {
|
||||||
const url = new URL(pathname, window.location.toString())
|
const url = new URL(pathname, window.location.toString())
|
||||||
@ -113,6 +114,7 @@ function createRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createRouter()
|
createRouter()
|
||||||
|
notifyNav(document.body.dataset.slug!)
|
||||||
|
|
||||||
if (!customElements.get('route-announcer')) {
|
if (!customElements.get('route-announcer')) {
|
||||||
const attrs = {
|
const attrs = {
|
||||||
|
35
quartz/components/scripts/toc.inline.ts
Normal file
35
quartz/components/scripts/toc.inline.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
const bufferPx = 150
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const slug = entry.target.id
|
||||||
|
const tocEntryElement = document.querySelector(`a[data-for="${slug}"]`)
|
||||||
|
const windowHeight = entry.rootBounds?.height
|
||||||
|
if (windowHeight && tocEntryElement) {
|
||||||
|
if (entry.boundingClientRect.y < windowHeight) {
|
||||||
|
tocEntryElement.classList.add("in-view")
|
||||||
|
} else {
|
||||||
|
tocEntryElement.classList.remove("in-view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleCollapsible(this: HTMLElement) {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("nav", () => {
|
||||||
|
const toc = document.getElementById("toc")!
|
||||||
|
const content = toc.nextElementSibling as HTMLElement
|
||||||
|
content.style.maxHeight = content.scrollHeight + "px"
|
||||||
|
toc.removeEventListener("click", toggleCollapsible)
|
||||||
|
toc.addEventListener("click", toggleCollapsible)
|
||||||
|
|
||||||
|
// update toc entry highlighting
|
||||||
|
observer.disconnect()
|
||||||
|
const headers = document.querySelectorAll("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]")
|
||||||
|
headers.forEach(header => observer.observe(header))
|
||||||
|
})
|
@ -1,4 +1,4 @@
|
|||||||
details.toc {
|
details#toc {
|
||||||
& summary {
|
& summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
@ -1,22 +1,36 @@
|
|||||||
details.toc {
|
button#toc {
|
||||||
& summary {
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--dark);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
list-style: none;
|
& h3 {
|
||||||
&::marker, &::-webkit-details-marker {
|
font-size: 1rem;
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > h3 {
|
& .fold {
|
||||||
font-size: 1rem;
|
margin-left: 0.5rem;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.collapsed .fold {
|
||||||
|
transform: rotateZ(-90deg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#toc-content {
|
||||||
|
list-style: none;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: none;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
|
||||||
& ul {
|
& ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
@ -37,3 +51,4 @@ details.toc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ export const ContentPage: QuartzEmitterPlugin<Options> = (opts) => {
|
|||||||
return {
|
return {
|
||||||
name: "ContentPage",
|
name: "ContentPage",
|
||||||
getQuartzComponents() {
|
getQuartzComponents() {
|
||||||
return [opts.head, Header, ...opts.header, ...opts.body]
|
return [opts.head, Header, Body, ...opts.header, ...opts.body, ...opts.left, ...opts.right, ...opts.footer]
|
||||||
},
|
},
|
||||||
async emit(_contentDir, cfg, content, resources, emit): Promise<string[]> {
|
async emit(_contentDir, cfg, content, resources, emit): Promise<string[]> {
|
||||||
const fps: string[] = []
|
const fps: string[] = []
|
||||||
|
@ -33,10 +33,6 @@ export function emitComponentResources(cfg: GlobalConfiguration, resources: Stat
|
|||||||
afterDOMLoaded: []
|
afterDOMLoaded: []
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cfg.enableSPA) {
|
|
||||||
componentResources.afterDOMLoaded.push(spaRouterScript)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const component of allComponents) {
|
for (const component of allComponents) {
|
||||||
const { css, beforeDOMLoaded, afterDOMLoaded } = component
|
const { css, beforeDOMLoaded, afterDOMLoaded } = component
|
||||||
if (css) {
|
if (css) {
|
||||||
@ -50,6 +46,15 @@ export function emitComponentResources(cfg: GlobalConfiguration, resources: Stat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cfg.enableSPA) {
|
||||||
|
componentResources.afterDOMLoaded.push(spaRouterScript)
|
||||||
|
} else {
|
||||||
|
componentResources.afterDOMLoaded.push(`
|
||||||
|
const event = new CustomEvent("nav", { detail: { slug: document.body.dataset.slug } })
|
||||||
|
document.dispatchEvent(event)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
emit({
|
emit({
|
||||||
slug: "index",
|
slug: "index",
|
||||||
ext: ".css",
|
ext: ".css",
|
||||||
|
@ -14,7 +14,8 @@ export const Katex: QuartzTransformerPlugin = () => ({
|
|||||||
}]
|
}]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
externalResources: {
|
externalResources() {
|
||||||
|
return {
|
||||||
css: [
|
css: [
|
||||||
// base css
|
// base css
|
||||||
"https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
|
"https://cdn.jsdelivr.net/npm/katex@0.16.0/dist/katex.min.css",
|
||||||
@ -28,4 +29,5 @@ export const Katex: QuartzTransformerPlugin = () => ({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
@ -6,6 +6,7 @@ import { slugify } from "../../path"
|
|||||||
import rehypeRaw from "rehype-raw"
|
import rehypeRaw from "rehype-raw"
|
||||||
import { visit } from "unist-util-visit"
|
import { visit } from "unist-util-visit"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { JSResource } from "../../resources"
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
highlight: boolean
|
highlight: boolean
|
||||||
@ -235,6 +236,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
|||||||
node.children.splice(0, 1, ...blockquoteContent)
|
node.children.splice(0, 1, ...blockquoteContent)
|
||||||
|
|
||||||
// add properties to base blockquote
|
// add properties to base blockquote
|
||||||
|
// TODO: add the js to actually support collapsing callout
|
||||||
node.data = {
|
node.data = {
|
||||||
hProperties: {
|
hProperties: {
|
||||||
...(node.data?.hProperties ?? {}),
|
...(node.data?.hProperties ?? {}),
|
||||||
@ -270,8 +272,8 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>
|
|||||||
htmlPlugins() {
|
htmlPlugins() {
|
||||||
return [rehypeRaw]
|
return [rehypeRaw]
|
||||||
},
|
},
|
||||||
externalResources: {
|
externalResources() {
|
||||||
js: [{
|
const mermaidScript: JSResource = {
|
||||||
script: `
|
script: `
|
||||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
|
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs';
|
||||||
mermaid.initialize({ startOnLoad: true });
|
mermaid.initialize({ startOnLoad: true });
|
||||||
@ -279,7 +281,10 @@ mermaid.initialize({ startOnLoad: true });
|
|||||||
loadTime: 'afterDOMReady',
|
loadTime: 'afterDOMReady',
|
||||||
moduleType: 'module',
|
moduleType: 'module',
|
||||||
contentType: 'inline'
|
contentType: 'inline'
|
||||||
}]
|
}
|
||||||
|
return {
|
||||||
|
js: opts.mermaid ? [mermaidScript] : []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ export type QuartzTransformerPluginInstance = {
|
|||||||
name: string
|
name: string
|
||||||
markdownPlugins(): PluggableList
|
markdownPlugins(): PluggableList
|
||||||
htmlPlugins(): PluggableList
|
htmlPlugins(): PluggableList
|
||||||
externalResources?: Partial<StaticResources>
|
externalResources?(): Partial<StaticResources>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzFilterPluginInstance
|
export type QuartzFilterPlugin<Options extends OptionType = undefined> = (opts?: Options) => QuartzFilterPluginInstance
|
||||||
|
Loading…
Reference in New Issue
Block a user