Last active
June 11, 2024 07:02
-
-
Save sirupsen/4b61223c353c101c4e97567597b14744 to your computer and use it in GitHub Desktop.
Sidenotes and table of contents on MDX with React.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function isColliding(a: DOMRect, b: DOMRect) { | |
return !( | |
((a.y + a.height) < (b.y)) | |
|| (a.y > (b.y + b.height)) | |
|| ((a.x + a.width) < b.x) | |
|| (a.x > (b.x + b.width)) | |
); | |
} | |
// Move the footnotes from the bottom to become sidenotes if the viewport is | |
// large enough. | |
// TODO: Should probably use Portals instead. | |
// https://reactjs.org/docs/portals.html | |
const sidenotes = useCallback(() => { | |
const footnoteLinks = Array.from(document.getElementsByClassName('footnote-ref')); | |
const width = [document.documentElement.clientWidth, window.innerWidth]; | |
const isSmallViewport = Math.max(...width) <= 1100 || isPrintLayout.current; | |
let lastFootnote: HTMLElement | null = null; | |
// for (let i = 0; i < footnoteLinks.length; i += 1) { | |
footnoteLinks.forEach((footnoteLink) => { | |
// The return from getElementsByClassName() isn't an iterable.. 😳 | |
// const footnoteLink = footnoteLinks[i]; | |
const linkCoords = footnoteLink.getClientRects()[0]; | |
const footnoteId = footnoteLink.getAttribute('href')?.slice(1); | |
const footnote = footnoteId && document.getElementById(footnoteId); | |
const container = document.getElementById('container')?.getClientRects()[0]; | |
if (footnote && linkCoords && container) { | |
if (isSmallViewport) { | |
footnote.style.position = 'static'; | |
footnote.style.left = '0'; | |
footnote.style.top = '0'; | |
footnote.style.width = '100%'; | |
} else { | |
// Don't put it _right_ at the footnote. scrollY is because we need to | |
// adjust if we're rendering with the viewport at the bottom. sigh | |
let yAdjustment = 10 - window.scrollY; | |
footnote.style.position = 'absolute'; | |
// The left adjustment needs to be based on the container's x, which | |
// moves as the browser resizes. | |
footnote.style.left = `${container.x + container.width + 25}px`; | |
footnote.style.top = `${linkCoords.y - yAdjustment}px`; | |
footnote.style.width = '200px'; | |
// If there's a footnote close to another one, we negotiate a difference | |
// rather than overlaying them on top of each other. Probably we could do | |
// this statitically, but frankly this is easier to understand.. | |
while ( | |
lastFootnote | |
&& isColliding(footnote.getClientRects()[0], lastFootnote.getClientRects()[0]) | |
) { | |
yAdjustment -= 15; | |
footnote.style.top = `${linkCoords.y - yAdjustment}px`; | |
// slice() is to remove `px` at the end | |
const newLastfootnote = Number(lastFootnote.style.top.slice(0, -2)) - 15; | |
lastFootnote.style.top = `${newLastfootnote}px`; | |
} | |
} | |
lastFootnote = footnote; | |
} | |
}); | |
}, []); | |
function tableOfContents() { | |
const toc = document.getElementsByClassName('toc')[0] as HTMLElement; | |
if (toc) { | |
const width = [document.documentElement.clientWidth, window.innerWidth]; | |
const largeEnough = Math.max(...width) >= 1200 && !isPrintLayout.current; | |
if (largeEnough) { | |
// const headers = document.querySelectorAll('article h1, h2, h3, h4, h5, h6'); | |
const container = document.getElementById('container')?.getClientRects()[0]; | |
if (container && toc) { | |
toc.style.fontSize = '80%'; | |
toc.style.position = 'absolute'; | |
toc.style.left = `${container.x - 250}px`; | |
toc.style.top = '90px'; | |
toc.style.width = '250px'; | |
// un-hide | |
toc.style.display = 'block'; | |
if (!toc.innerText.startsWith('Table of Contents') && toc.innerText.length > 0) { | |
toc.innerHTML = `<b>Table of Contents</b>${toc.innerHTML}`; | |
} | |
} | |
} else { | |
toc.style.display = 'none'; | |
} | |
} | |
} | |
// useLayoutEffect blocks rendering to avoid flickering. | |
useEffect(() => { | |
const mdxChanges = (event: any) => { | |
// Unfortunately the resize event is sent after `beforeprint`, so we need to | |
// set a global. | |
if (event?.type === 'beforeprint') isPrintLayout.current = true; | |
if (event?.type === 'afterprint') isPrintLayout.current = false; | |
sidenotes(); | |
tableOfContents(); | |
}; | |
mdxChanges({}); | |
// When we resize the window, we need to move the absolute position | |
// otherwise it just freezes in the viewport. This also handles the | |
// transition to the small view port where we show them at the bottom. | |
window.addEventListener('resize', mdxChanges); | |
window.addEventListener('resize', mdxChanges); | |
// For printing, we need to remove the mdxChanges. | |
window.addEventListener('beforeprint', mdxChanges); | |
window.addEventListener('afterprint', mdxChanges); | |
return () => { | |
window.removeEventListener('resize', mdxChanges); | |
window.removeEventListener('beforeprint', mdxChanges); | |
window.removeEventListener('afterprint', mdxChanges); | |
}; | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment