diff --git a/quartz/components/Sidenotes.tsx b/quartz/components/Sidenotes.tsx new file mode 100644 index 000000000..e8a927a9a --- /dev/null +++ b/quartz/components/Sidenotes.tsx @@ -0,0 +1,16 @@ +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" +// @ts-ignore +import script from "./scripts/sidenotes.inline" +import style from "./styles/sidenotes.scss" +import { classNames } from "../util/lang" + +export default (() => { + const Sidenotes: QuartzComponent = ({ displayClass }: QuartzComponentProps) => ( +
+ ) + + Sidenotes.css = style + Sidenotes.afterDOMLoaded = script + + return Sidenotes +}) satisfies QuartzComponentConstructor diff --git a/quartz/components/index.ts b/quartz/components/index.ts index 5b197941c..e03e12609 100644 --- a/quartz/components/index.ts +++ b/quartz/components/index.ts @@ -20,6 +20,7 @@ import MobileOnly from "./MobileOnly" import RecentNotes from "./RecentNotes" import Breadcrumbs from "./Breadcrumbs" import Comments from "./Comments" +import Sidenotes from "./Sidenotes" export { ArticleTitle, @@ -44,4 +45,5 @@ export { NotFound, Breadcrumbs, Comments, + Sidenotes, } diff --git a/quartz/components/scripts/sidenotes.inline.ts b/quartz/components/scripts/sidenotes.inline.ts new file mode 100644 index 000000000..b52c943fd --- /dev/null +++ b/quartz/components/scripts/sidenotes.inline.ts @@ -0,0 +1,141 @@ +import { removeAllChildren } from "./util" + +const ARTICLE_CONTENT_SELECTOR = ".center" +const FOOTNOTE_SECTION_SELECTOR = "section[data-footnotes] > ol" +const INDIVIDUAL_FOOTNOTE_SELECTOR = "li[id^='user-content-fn-']" + +// Computes an offset such that setting `top` on elemToAlign will put it +// in vertical alignment with targetAlignment. +function computeOffsetForAlignment(elemToAlign: HTMLElement, targetAlignment: HTMLElement) { + const offsetParentTop = elemToAlign.offsetParent!.getBoundingClientRect().top + return targetAlignment.getBoundingClientRect().top - offsetParentTop +} + +// Clamp value between min and max +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(value, max)) +} + +function isInViewport(element: HTMLElement, buffer: number = 100) { + const rect = element.getBoundingClientRect() + return ( + rect.top >= -buffer && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) + buffer + ) +} + +// Get bounds for the sidenote positioning +function getSidenoteBounds( + sideContainer: HTMLElement, + sidenote: HTMLElement, +): { min: number; max: number } { + const containerRect = sideContainer.getBoundingClientRect() + const sidenoteRect = sidenote.getBoundingClientRect() + + return { + min: 0, + max: containerRect.height - sidenoteRect.height, + } +} + +function updateSidenotes( + articleContent: HTMLElement, + sideContainer: HTMLElement, + footnoteElements: NodeListOf, +) { + footnoteElements.forEach((sidenote) => { + const sideId = sidenote.id.replace("sidebar-", "") + const intextLink = articleContent.querySelector(`a[href="#${sideId}"]`) as HTMLElement + if (!intextLink) return + + // Calculate ideal position + let referencePosition = computeOffsetForAlignment(sidenote, intextLink) + + // Get bounds for this sidenote + const bounds = getSidenoteBounds(sideContainer, sidenote) + + // Clamp the position within bounds + referencePosition = clamp(referencePosition, bounds.min, bounds.max) + + // Apply position + sidenote.style.top = `${referencePosition}px` + + // Update visibility state + if (isInViewport(intextLink)) { + sidenote.classList.add("in-view") + intextLink.classList.add("active") + } else { + sidenote.classList.remove("in-view") + intextLink.classList.remove("active") + } + }) +} + +function debounce(fn: Function, delay: number) { + let timeoutId: ReturnType + return (...args: any[]) => { + clearTimeout(timeoutId) + timeoutId = setTimeout(() => fn(...args), delay) + } +} + +document.addEventListener("nav", () => { + const articleContent = document.querySelector(ARTICLE_CONTENT_SELECTOR) as HTMLElement + const footnoteSection = document.querySelector(FOOTNOTE_SECTION_SELECTOR) + if (!footnoteSection || !articleContent) return + + const sideContainer = document.querySelector(".sidenotes") as HTMLElement + if (!sideContainer) return + + removeAllChildren(sideContainer) + + // Set container height to match article content + const articleRect = articleContent.getBoundingClientRect() + sideContainer.style.height = `${articleRect.height}px` + sideContainer.style.top = `0px` + + const ol = document.createElement("ol") + sideContainer.appendChild(ol) + + const footnotes = footnoteSection.querySelectorAll( + INDIVIDUAL_FOOTNOTE_SELECTOR, + ) as NodeListOf + + footnotes.forEach((footnote) => { + const footnoteId = footnote.id + const intextLink = articleContent.querySelector(`a[href="#${footnoteId}"]`) as HTMLElement + if (!intextLink) return + + const sidenote = document.createElement("li") + sidenote.classList.add("sidenote-element") + sidenote.style.position = "absolute" + sidenote.id = `sidebar-${footnoteId}` + const cloned = footnote.cloneNode(true) as HTMLElement + sidenote.append(...cloned.children) + ol.appendChild(sidenote) + }) + + // Get all sidenotes for updates + const sidenotes = sideContainer.querySelectorAll(".sidenote-element") as NodeListOf + + // Initial position update + updateSidenotes(articleContent, sideContainer, sidenotes) + + // Update on scroll with debouncing + const debouncedUpdate = debounce( + () => updateSidenotes(articleContent, sideContainer, sidenotes), + 16, // ~60fps + ) + + // Add scroll listener + document.addEventListener("scroll", debouncedUpdate, { passive: true }) + + // Add resize listener + window.addEventListener("resize", debouncedUpdate, { passive: true }) + + // Cleanup + window.addCleanup(() => { + document.removeEventListener("scroll", debouncedUpdate) + window.removeEventListener("resize", debouncedUpdate) + }) +}) diff --git a/quartz/components/styles/sidenotes.scss b/quartz/components/styles/sidenotes.scss new file mode 100644 index 000000000..0998d03ec --- /dev/null +++ b/quartz/components/styles/sidenotes.scss @@ -0,0 +1,15 @@ +.sidenotes { + & > ol { + padding-inline-start: 0; + } + + & .sidenote-element { + transition: opacity 0.2s ease; + opacity: 0; + margin-bottom: 1rem; + + &.in-view { + opacity: 1; + } + } +}