mirror of
https://github.com/jackyzha0/quartz.git
synced 2025-05-18 14:34:23 +02:00
feat(components): sidenotes
Signed-off-by: Aaron Pham <contact@aarnphm.xyz>
This commit is contained in:
parent
3aa11357aa
commit
3529b10af3
16
quartz/components/Sidenotes.tsx
Normal file
16
quartz/components/Sidenotes.tsx
Normal 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
|
@ -20,6 +20,7 @@ import MobileOnly from "./MobileOnly"
|
|||||||
import RecentNotes from "./RecentNotes"
|
import RecentNotes from "./RecentNotes"
|
||||||
import Breadcrumbs from "./Breadcrumbs"
|
import Breadcrumbs from "./Breadcrumbs"
|
||||||
import Comments from "./Comments"
|
import Comments from "./Comments"
|
||||||
|
import Sidenotes from "./Sidenotes"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ArticleTitle,
|
ArticleTitle,
|
||||||
@ -44,4 +45,5 @@ export {
|
|||||||
NotFound,
|
NotFound,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Comments,
|
Comments,
|
||||||
|
Sidenotes,
|
||||||
}
|
}
|
||||||
|
141
quartz/components/scripts/sidenotes.inline.ts
Normal file
141
quartz/components/scripts/sidenotes.inline.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
15
quartz/components/styles/sidenotes.scss
Normal file
15
quartz/components/styles/sidenotes.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user