Skip to content

Instantly share code, notes, and snippets.

@sirupsen
Last active January 5, 2022 11:47
Show Gist options
  • Save sirupsen/4b61223c353c101c4e97567597b14744 to your computer and use it in GitHub Desktop.
Save sirupsen/4b61223c353c101c4e97567597b14744 to your computer and use it in GitHub Desktop.
Sidenotes and table of contents on MDX with React.
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