feat(components): sidenotes

Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
This commit is contained in:
Aaron Pham 2024-10-30 06:16:00 -04:00
parent 3aa11357aa
commit 3529b10af3
No known key found for this signature in database
GPG Key ID: 18974753009D2BFA
4 changed files with 174 additions and 0 deletions

View File

@ -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) => (
<div class={classNames(displayClass, "sidenotes")}></div>
)
Sidenotes.css = style
Sidenotes.afterDOMLoaded = script
return Sidenotes
}) satisfies QuartzComponentConstructor

View File

@ -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,
}

View File

@ -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<HTMLElement>,
) {
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<typeof setTimeout>
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<HTMLLIElement>
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<HTMLElement>
// 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)
})
})

View File

@ -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;
}
}
}