forked from GitHub/quartz
Compare commits
2 Commits
v4
...
feat/siden
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea6424fed0 | ||
|
|
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 Breadcrumbs from "./Breadcrumbs"
|
||||
import Comments from "./Comments"
|
||||
import Sidenotes from "./Sidenotes"
|
||||
|
||||
export {
|
||||
ArticleTitle,
|
||||
@ -44,4 +45,5 @@ export {
|
||||
NotFound,
|
||||
Breadcrumbs,
|
||||
Comments,
|
||||
Sidenotes,
|
||||
}
|
||||
|
||||
144
quartz/components/scripts/sidenotes.inline.ts
Normal file
144
quartz/components/scripts/sidenotes.inline.ts
Normal file
@ -0,0 +1,144 @@
|
||||
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-']"
|
||||
|
||||
function isInViewport(element: HTMLElement, buffer: number = 100) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= -buffer &&
|
||||
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) + buffer
|
||||
)
|
||||
}
|
||||
|
||||
function computeOffsetForAlignment(elemToAlign: HTMLElement, targetAlignment: HTMLElement) {
|
||||
const elemRect = elemToAlign.getBoundingClientRect()
|
||||
const targetRect = targetAlignment.getBoundingClientRect()
|
||||
const parentRect = elemToAlign.parentElement?.getBoundingClientRect() || elemRect
|
||||
return targetRect.top - parentRect.top
|
||||
}
|
||||
|
||||
// Get bounds for the sidenote positioning
|
||||
function getBounds(parent: HTMLElement, child: HTMLElement): { min: number; max: number } {
|
||||
const containerRect = parent.getBoundingClientRect()
|
||||
const sidenoteRect = child.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
min: 0,
|
||||
max: containerRect.height - sidenoteRect.height,
|
||||
}
|
||||
}
|
||||
|
||||
function updatePosition(ref: HTMLElement, child: HTMLElement, parent: HTMLElement) {
|
||||
// Calculate ideal position
|
||||
let referencePosition = computeOffsetForAlignment(child, ref)
|
||||
|
||||
// Get bounds for this sidenote
|
||||
const bounds = getBounds(parent, child)
|
||||
|
||||
// Clamp the position within bounds
|
||||
referencePosition = Math.max(referencePosition, Math.min(bounds.min, bounds.max))
|
||||
|
||||
// Apply position
|
||||
child.style.top = `${referencePosition}px`
|
||||
}
|
||||
|
||||
function updateSidenotes() {
|
||||
const articleContent = document.querySelector(ARTICLE_CONTENT_SELECTOR) as HTMLElement
|
||||
const sideContainer = document.querySelector(".sidenotes") as HTMLElement
|
||||
if (!articleContent || !sideContainer) return
|
||||
|
||||
const sidenotes = sideContainer.querySelectorAll(".sidenote-element") as NodeListOf<HTMLElement>
|
||||
for (const sidenote of sidenotes) {
|
||||
const sideId = sidenote.id.replace("sidebar-", "")
|
||||
const intextLink = articleContent.querySelector(`a[href="#${sideId}"]`) as HTMLElement
|
||||
if (!intextLink) return
|
||||
|
||||
if (isInViewport(intextLink)) {
|
||||
sidenote.classList.add("in-view")
|
||||
intextLink.classList.add("active")
|
||||
updatePosition(intextLink, sidenote, sideContainer)
|
||||
} 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 footnoteSections = Array.from(document.querySelectorAll(FOOTNOTE_SECTION_SELECTOR))
|
||||
if (footnoteSections.length == 0 || !articleContent) return
|
||||
|
||||
const lastIdx = footnoteSections.length - 1
|
||||
const footnoteSection = footnoteSections[lastIdx] as HTMLElement
|
||||
|
||||
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>
|
||||
|
||||
for (const footnote of footnotes) {
|
||||
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"
|
||||
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
sidenote.style.maxWidth = `${sideContainer.offsetWidth - rootFontSize}px`
|
||||
sidenote.id = `sidebar-${footnoteId}`
|
||||
const cloned = footnote.cloneNode(true) as HTMLElement
|
||||
const backref = cloned.querySelector("a[data-footnote-backref]")
|
||||
backref?.remove()
|
||||
sidenote.append(...cloned.children)
|
||||
// create inner child container
|
||||
let innerContainer = sidenote.querySelector(".sidenote-inner")
|
||||
if (!innerContainer) {
|
||||
innerContainer = document.createElement("div") as HTMLDivElement
|
||||
innerContainer.className = "sidenote-inner"
|
||||
while (sidenote.firstChild) {
|
||||
innerContainer.appendChild(sidenote.firstChild)
|
||||
}
|
||||
sidenote.appendChild(innerContainer)
|
||||
}
|
||||
|
||||
ol.appendChild(sidenote)
|
||||
}
|
||||
|
||||
updateSidenotes()
|
||||
|
||||
// Update on scroll with debouncing
|
||||
const debouncedUpdate = debounce(updateSidenotes, 2)
|
||||
|
||||
document.addEventListener("scroll", debouncedUpdate, { passive: true })
|
||||
window.addEventListener("resize", debouncedUpdate, { passive: true })
|
||||
|
||||
// Cleanup
|
||||
window.addCleanup(() => {
|
||||
document.removeEventListener("scroll", debouncedUpdate)
|
||||
window.removeEventListener("resize", debouncedUpdate)
|
||||
})
|
||||
})
|
||||
41
quartz/components/styles/sidenotes.scss
Normal file
41
quartz/components/styles/sidenotes.scss
Normal file
@ -0,0 +1,41 @@
|
||||
.sidenotes {
|
||||
& > ol {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
& .sidenote-element {
|
||||
position: absolute;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
opacity: 0;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--gray);
|
||||
counter-increment: sidenote-counter;
|
||||
background-color: var(--light);
|
||||
|
||||
&::before {
|
||||
content: counter(sidenote-counter);
|
||||
background-color: var(--light);
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
margin-right: 0.5rem;
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 12px;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid var(--tertiary);
|
||||
}
|
||||
|
||||
&.in-view {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& .sidenote-inner {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.2rem 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user