collapsible toc
This commit is contained in:
		
							parent
							
								
									917d5791ac
								
							
						
					
					
						commit
						6d5491fdcb
					
				
					 14 changed files with 176 additions and 114 deletions
				
			
		| 
						 | 
				
			
			@ -4,9 +4,9 @@ import clipboardStyle from './styles/clipboard.scss'
 | 
			
		|||
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		||||
 | 
			
		||||
function Body({ children }: QuartzComponentProps) {
 | 
			
		||||
  return <article>
 | 
			
		||||
  return <div id="quartz-body">
 | 
			
		||||
    {children}
 | 
			
		||||
  </article>
 | 
			
		||||
  </div>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Body.afterDOMLoaded = clipboardScript
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import { toJsxRuntime } from "hast-util-to-jsx-runtime"
 | 
			
		|||
function Content({ tree }: QuartzComponentProps) {
 | 
			
		||||
  // @ts-ignore (preact makes it angry)
 | 
			
		||||
  const content = toJsxRuntime(tree, { Fragment, jsx, jsxs, elementAttributeNameCase: 'html' })
 | 
			
		||||
  return content
 | 
			
		||||
  return <article>{content}</article>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default (() => Content) satisfies QuartzComponentConstructor
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,9 @@ import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
 | 
			
		|||
import legacyStyle from "./styles/legacyToc.scss"
 | 
			
		||||
import modernStyle from "./styles/toc.scss"
 | 
			
		||||
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
import script from "./scripts/toc.inline"
 | 
			
		||||
 | 
			
		||||
interface Options {
 | 
			
		||||
  layout: 'modern' | 'legacy'
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -10,56 +13,49 @@ const defaultOptions: Options = {
 | 
			
		|||
  layout: 'modern'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ((opts?: Partial<Options>) => {
 | 
			
		||||
  const layout = opts?.layout ?? defaultOptions.layout
 | 
			
		||||
  function TableOfContents({ fileData }: QuartzComponentProps) {
 | 
			
		||||
    if (!fileData.toc) {
 | 
			
		||||
      return null
 | 
			
		||||
    }
 | 
			
		||||
function TableOfContents({ fileData }: QuartzComponentProps) {
 | 
			
		||||
  if (!fileData.toc) {
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    return <details class="toc" open>
 | 
			
		||||
      <summary><h3>Table of Contents</h3></summary>
 | 
			
		||||
  return <>
 | 
			
		||||
    <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>
 | 
			
		||||
    </details>
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  TableOfContents.css = layout === "modern" ? modernStyle : legacyStyle
 | 
			
		||||
 | 
			
		||||
  if (layout === "modern") {
 | 
			
		||||
    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))
 | 
			
		||||
    </div>
 | 
			
		||||
  </>
 | 
			
		||||
}
 | 
			
		||||
TableOfContents.css = modernStyle
 | 
			
		||||
TableOfContents.afterDOMLoaded = script
 | 
			
		||||
 | 
			
		||||
init()
 | 
			
		||||
 | 
			
		||||
document.addEventListener("spa_nav", (e) => {
 | 
			
		||||
  observer.disconnect()
 | 
			
		||||
  init()
 | 
			
		||||
})
 | 
			
		||||
`
 | 
			
		||||
function LegacyTableOfContents({ fileData }: QuartzComponentProps) {
 | 
			
		||||
  if (!fileData.toc) {
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return TableOfContents
 | 
			
		||||
  return <details id="toc" open>
 | 
			
		||||
    <summary>
 | 
			
		||||
      <h3>Table of Contents</h3>
 | 
			
		||||
    </summary>
 | 
			
		||||
    <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>
 | 
			
		||||
  </details>
 | 
			
		||||
}
 | 
			
		||||
LegacyTableOfContents.css = legacyStyle
 | 
			
		||||
 | 
			
		||||
export default ((opts?: Partial<Options>) => {
 | 
			
		||||
  const layout = opts?.layout ?? defaultOptions.layout
 | 
			
		||||
  return layout === "modern" ? TableOfContents : LegacyTableOfContents
 | 
			
		||||
}) satisfies QuartzComponentConstructor
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,27 +3,29 @@ const svgCopy =
 | 
			
		|||
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>'
 | 
			
		||||
 | 
			
		||||
const els = document.getElementsByTagName("pre")
 | 
			
		||||
for (let i = 0; i < els.length; i++) {
 | 
			
		||||
  const codeBlock = els[i].getElementsByTagName("code")[0]
 | 
			
		||||
  const source = codeBlock.innerText.replace(/\n\n/g, "\n")
 | 
			
		||||
  const button = document.createElement("button")
 | 
			
		||||
  button.className = "clipboard-button"
 | 
			
		||||
  button.type = "button"
 | 
			
		||||
  button.innerHTML = svgCopy
 | 
			
		||||
  button.ariaLabel = "Copy source"
 | 
			
		||||
  button.addEventListener("click", () => {
 | 
			
		||||
    navigator.clipboard.writeText(source).then(
 | 
			
		||||
      () => {
 | 
			
		||||
        button.blur()
 | 
			
		||||
        button.innerHTML = svgCheck
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          button.innerHTML = svgCopy
 | 
			
		||||
          button.style.borderColor = ""
 | 
			
		||||
        }, 2000)
 | 
			
		||||
      },
 | 
			
		||||
      (error) => console.error(error),
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
  els[i].prepend(button)
 | 
			
		||||
}
 | 
			
		||||
document.addEventListener("nav", () => {
 | 
			
		||||
  const els = document.getElementsByTagName("pre")
 | 
			
		||||
  for (let i = 0; i < els.length; i++) {
 | 
			
		||||
    const codeBlock = els[i].getElementsByTagName("code")[0]
 | 
			
		||||
    const source = codeBlock.innerText.replace(/\n\n/g, "\n")
 | 
			
		||||
    const button = document.createElement("button")
 | 
			
		||||
    button.className = "clipboard-button"
 | 
			
		||||
    button.type = "button"
 | 
			
		||||
    button.innerHTML = svgCopy
 | 
			
		||||
    button.ariaLabel = "Copy source"
 | 
			
		||||
    button.addEventListener("click", () => {
 | 
			
		||||
      navigator.clipboard.writeText(source).then(
 | 
			
		||||
        () => {
 | 
			
		||||
          button.blur()
 | 
			
		||||
          button.innerHTML = svgCheck
 | 
			
		||||
          setTimeout(() => {
 | 
			
		||||
            button.innerHTML = svgCopy
 | 
			
		||||
            button.style.borderColor = ""
 | 
			
		||||
          }, 2000)
 | 
			
		||||
        },
 | 
			
		||||
        (error) => console.error(error),
 | 
			
		||||
      )
 | 
			
		||||
    })
 | 
			
		||||
    els[i].prepend(button)
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,7 +30,7 @@ const getOpts = ({ target }: Event): { url: URL, scroll?: boolean } | undefined
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function notifyNav(slug: string) {
 | 
			
		||||
  const event = new CustomEvent("spa_nav", { detail: { slug } })
 | 
			
		||||
  const event = new CustomEvent("nav", { detail: { slug } })
 | 
			
		||||
  document.dispatchEvent(event)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -96,6 +96,7 @@ function createRouter() {
 | 
			
		|||
      return
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return new class Router {
 | 
			
		||||
    go(pathname: string) {
 | 
			
		||||
      const url = new URL(pathname, window.location.toString())
 | 
			
		||||
| 
						 | 
				
			
			@ -113,6 +114,7 @@ function createRouter() {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
createRouter()
 | 
			
		||||
notifyNav(document.body.dataset.slug!)
 | 
			
		||||
 | 
			
		||||
if (!customElements.get('route-announcer')) {
 | 
			
		||||
  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 {
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,22 +1,36 @@
 | 
			
		|||
details.toc {
 | 
			
		||||
  & summary {
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
button#toc {
 | 
			
		||||
  background-color: transparent;
 | 
			
		||||
  border: none;
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  color: var(--dark);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
 | 
			
		||||
    list-style: none;
 | 
			
		||||
    &::marker, &::-webkit-details-marker {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    & > * {
 | 
			
		||||
      display: inline-block;
 | 
			
		||||
      margin: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    & > h3 {
 | 
			
		||||
      font-size: 1rem;
 | 
			
		||||
    }
 | 
			
		||||
  & 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)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
  
 | 
			
		||||
#toc-content {
 | 
			
		||||
  list-style: none;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  max-height: none;
 | 
			
		||||
  transition: max-height 0.3s ease;
 | 
			
		||||
 | 
			
		||||
  & ul {
 | 
			
		||||
    list-style: none;
 | 
			
		||||
    margin: 0.5rem 0;
 | 
			
		||||
| 
						 | 
				
			
			@ -37,3 +51,4 @@ details.toc {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
  
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue