React/Gatsby Table of Contents
import React, { createRef } from "react" | |
import throttle from "lodash/throttle" | |
// Import styled components (see the Styles section below). | |
import { | |
TocDiv, | |
TocLink, | |
TocIcon, | |
TocTitle, | |
TocToggleOpener, | |
TocToggleCloser, | |
TocListItem, | |
TocListBullet, | |
} from "./styles" | |
import { HeadingTree, TraverseResult, IHeadingData } from "./heading-tree" | |
import HeadingNode from "./heading-node" | |
interface IProps { | |
containerSelector: string // Selector for a content container | |
levels?: number[] // Needed heading levels, by default [2, 3, 4] | |
prebuiltHeadings?: IHeadingData[] // Already extracted page headings to speed up | |
title?: string // Title, default is "Contents" | |
throttleTimeMs?: number // Scroll handler throttle time, default is 300 | |
// Note: offsetToBecomeActive must not be zero because at least in my chrome browser | |
// element.scrollTo() sets window.scrollY = element.offsetTop - 1 | |
// and some routers use this function to scroll to window.location.hash. | |
// The default value is 30 (px). | |
offsetToBecomeActive?: number | |
} | |
interface IActiveHeadings { | |
[key: number]: boolean | |
} | |
interface IState { | |
open: boolean | |
headingTree?: HeadingTree | |
activeParents: IActiveHeadings | |
activeNode?: HeadingNode | |
container?: HTMLElement | |
} | |
export default class Toc extends React.Component<IProps, IState> { | |
private wrapperRef = createRef<HTMLDivElement>() | |
private clickEventListenerWasAdded = false | |
private handleScrollThrottled: () => void | |
private domObserver: MutationObserver | |
constructor(props: IProps) { | |
super(props) | |
this.state = { | |
open: false, | |
headingTree: null, | |
activeParents: {}, | |
activeNode: null, | |
container: null, | |
} | |
this.handleClickOutside = this.handleClickOutside.bind(this) | |
this.handleResize = this.handleResize.bind(this) | |
} | |
componentWillUnmount() { | |
this.handleClose(false) | |
window.removeEventListener(`scroll`, this.handleScrollThrottled) | |
window.removeEventListener(`resize`, this.handleResize) | |
} | |
componentDidMount() { | |
const container = this.parseHeadings() | |
this.setupEventListeners(container) | |
} | |
private setupEventListeners(container: HTMLElement) { | |
const startedAt = performance.now() | |
let handleScroll: any | |
if (typeof window === "undefined" || !window.MutationObserver) { | |
console.info(`No window or mutationobserver, falling back to recalculating offsets on scroll`) | |
handleScroll = () => { | |
this.recalcOffsets() | |
this.handleScrollImpl() | |
} | |
this.domObserver = null | |
} else { | |
handleScroll = this.handleScrollImpl.bind(this) | |
this.domObserver = new MutationObserver(mutations => { | |
console.info( | |
`Toc: content container "${this.props.containerSelector}" mutation detected, recalculating offsets`, | |
mutations | |
) | |
this.recalcOffsets() | |
}) | |
this.domObserver.observe(container, { | |
attributes: true, | |
childList: true, | |
subtree: true, | |
characterData: true, | |
}) | |
} | |
this.handleScrollThrottled = throttle(handleScroll, this.props.throttleTimeMs || 300) | |
window.addEventListener(`scroll`, this.handleScrollThrottled) | |
window.addEventListener(`resize`, this.handleResize) | |
console.info(`Set up toc event listeners in ${performance.now() - startedAt}ms`) | |
} | |
private buildActiveParents(activeNode: HeadingNode): IActiveHeadings { | |
let curNode = activeNode | |
const activeParents = {} | |
if (this.state.headingTree) { | |
activeParents[this.state.headingTree.getRoot().key] = true | |
} | |
while (curNode !== null) { | |
activeParents[curNode.key] = true | |
curNode = curNode.parent | |
} | |
return activeParents | |
} | |
private handleResize() { | |
console.info(`Handling resize event`) | |
this.recalcOffsets() | |
} | |
private recalcOffsets() { | |
if (this.state.headingTree) { | |
this.state.headingTree.markOffsetCacheStale() | |
} | |
} | |
private handleScrollImpl() { | |
const startedAt = performance.now() | |
const activeNode = this.findActiveNode() | |
const elapsedMs = performance.now() - startedAt | |
if (elapsedMs >= 5) { | |
console.info(`Scroll handler: looking for active heading took ${elapsedMs}ms`) | |
} | |
if (activeNode !== this.state.activeNode) { | |
const activeParents = this.buildActiveParents(activeNode) | |
this.setState({ activeNode, activeParents }) | |
} | |
} | |
private handleClickOutside(event: MouseEvent) { | |
if (this.wrapperRef && this.wrapperRef.current && !this.wrapperRef.current.contains(event.target as Node)) { | |
this.setState({ open: false }) | |
} | |
} | |
private handleOpen() { | |
if (!this.clickEventListenerWasAdded) { | |
document.addEventListener("mousedown", this.handleClickOutside) | |
this.clickEventListenerWasAdded = true | |
} | |
this.setState({ open: true }) | |
} | |
private handleClose(canSetState: boolean) { | |
if (this.clickEventListenerWasAdded) { | |
document.removeEventListener("mousedown", this.handleClickOutside) | |
this.clickEventListenerWasAdded = false | |
} | |
if (canSetState) { | |
this.setState({ open: false }) | |
} | |
} | |
private handleHeadingClick(ev: any, h: HeadingNode) { | |
event.preventDefault() | |
const elemTopOffset = h.cachedOffsetTop | |
window.history.replaceState({}, "", `#${h.id}`) | |
window.scrollTo(0, elemTopOffset) | |
this.handleClose(true) | |
this.setState({ activeNode: h, activeParents: this.buildActiveParents(h) }) | |
} | |
private parseHeadings() { | |
const startedAt = performance.now() | |
const container = document.querySelector(this.props.containerSelector) as HTMLElement | |
if (!container) { | |
throw Error(`failed to find container by selector "${this.props.containerSelector}"`) | |
} | |
let headings = this.props.prebuiltHeadings | |
if (headings) { | |
const isSSR = typeof window === "undefined" | |
if (isSSR) { | |
// Just to validate, in client-side code it will be lazy. | |
headings.forEach(h => { | |
h.htmlNode = h.htmlNode || document.getElementById(h.id) | |
if (!h.htmlNode) { | |
throw Error(`no heading with id "${h.id}"`) | |
} | |
}) | |
} | |
} else { | |
const levels = this.props.levels || [2, 3, 4] | |
const headingSelector = levels.map(level => `h${level}`).join(`, `) | |
const htmlNodes: HTMLElement[] = Array.from(container.querySelectorAll(headingSelector)) | |
headings = htmlNodes.map((node, i) => ({ | |
value: node.innerText, | |
depth: Number(node.nodeName[1]), | |
id: node.id, | |
htmlNode: node, | |
})) | |
} | |
const tree = new HeadingTree(headings) | |
console.info( | |
`Built headings tree in ${performance.now() - startedAt}ms from ${ | |
this.props.prebuiltHeadings ? "prebuilt headings" : "DOM" | |
}` | |
) | |
this.setState({ headingTree: tree, container }) | |
return container | |
} | |
private findActiveNode(): HeadingNode | null { | |
if (!this.state.headingTree) { | |
return null | |
} | |
const offsetToBecomeActive = this.props.offsetToBecomeActive || 30 | |
const curScrollPos = window.scrollY + offsetToBecomeActive | |
let activeNode = null | |
let lastNode = null | |
this.state.headingTree.traverseInPreorder((h: HeadingNode) => { | |
if (curScrollPos > h.cachedOffsetTop) { | |
lastNode = h | |
return TraverseResult.Continue | |
} | |
activeNode = lastNode | |
return TraverseResult.Stop | |
}) | |
if (activeNode === null && lastNode !== null && this.state.container) { | |
// Mark last heading active only if we didn't scroll after the end of the container. | |
if (window.scrollY <= this.state.container.offsetTop + this.state.container.offsetHeight) { | |
return lastNode | |
} | |
} | |
return activeNode | |
} | |
private renderHeadings() { | |
if (!this.state.headingTree) { | |
return | |
} | |
const items = [] | |
this.state.headingTree.traverseInPreorder(h => { | |
const isActive = this.state.activeNode && this.state.activeNode.key === h.key | |
items.push( | |
<TocListItem depth={h.depth} active={isActive} key={h.key}> | |
<TocListBullet depth={h.depth} active={isActive} /> | |
<TocLink href={`#${h.id}`} active={isActive} depth={h.depth} onClick={ev => this.handleHeadingClick(ev, h)}> | |
{h.title} | |
</TocLink> | |
</TocListItem> | |
) | |
return this.state.activeParents[h.key] ? TraverseResult.Continue : TraverseResult.NoChildren | |
}) | |
return items | |
} | |
render() { | |
return ( | |
<> | |
<TocToggleOpener open={this.state.open} onClick={this.handleOpen.bind(this)} /> | |
<TocDiv ref={this.wrapperRef} open={this.state.open}> | |
<TocTitle> | |
<TocIcon /> | |
{this.props.title || `Contents`} | |
<TocToggleCloser onClick={() => this.handleClose(true)} /> | |
</TocTitle> | |
<nav> | |
<ul>{this.renderHeadings()}</ul> | |
</nav> | |
</TocDiv> | |
</> | |
) | |
} | |
} |
// src/components/toc/heading-node.ts | |
export default class HeadingNode { | |
title: string | |
children: HeadingNode[] | |
parent?: HeadingNode | |
private offsetCacheVersion: number | null | |
cachedOffsetTop: number | null | |
private htmlNode: HTMLElement | null | |
depth: number // relative depth, starts from 0 | |
id: string | |
key: number // faster comparison than by id | |
constructor( | |
htmlNode: HTMLElement | null, | |
value: string, | |
depth: number, | |
id: string, | |
key: number, | |
offsetCacheVersion: number | |
) { | |
this.htmlNode = htmlNode | |
this.title = value | |
this.parent = null | |
this.children = [] | |
this.depth = depth | |
this.id = id | |
this.key = key | |
this.offsetCacheVersion = offsetCacheVersion - 1 | |
// Don't call this.refetchOffsetIfNeeded(offsetCacheVersion) to save initial expensive fetch: | |
// there are a more reflows during intial page loading, we need to delay offset fetching. | |
} | |
lazyLoad(curCacheVersion: number) { | |
if (curCacheVersion === this.offsetCacheVersion) { | |
return | |
} | |
if (!this.htmlNode) { | |
this.htmlNode = document.getElementById(this.id) | |
if (!this.htmlNode) { | |
throw Error(`no heading with id "${this.id}"`) | |
} | |
} | |
this.cachedOffsetTop = this.htmlNode.offsetTop | |
this.offsetCacheVersion = curCacheVersion | |
} | |
} |
// src/components/toc/heading-tree.ts | |
import HeadingNode from "./heading-node" | |
export enum TraverseResult { | |
Continue = 1, | |
NoChildren = 2, | |
Stop = 3, | |
} | |
export interface IHeadingData { | |
depth: number // h2 => 2, h3 => 3, etc | |
value: string | |
id: string | |
htmlNode?: HTMLElement | |
} | |
export class HeadingTree { | |
private root?: HeadingNode | |
private offsetCacheVersion: number | |
constructor(headings: IHeadingData[]) { | |
// Make depths of nodes relative | |
const minDepth = Math.min(...headings.map(h => h.depth)) | |
const offsetCacheVersion = 0 | |
const headingNodes: HeadingNode[] = headings.map((h, i) => { | |
return new HeadingNode(h.htmlNode, h.value, h.depth - minDepth, h.id, i, offsetCacheVersion) | |
}) | |
const nodeStack: HeadingNode[] = [new HeadingNode(null, "", -1, "", -1, offsetCacheVersion)] // init with root node | |
headingNodes.forEach(node => { | |
while (nodeStack.length && nodeStack[nodeStack.length - 1].depth >= node.depth) { | |
nodeStack.pop() | |
} | |
nodeStack[nodeStack.length - 1].children.push(node) | |
node.parent = nodeStack[nodeStack.length - 1] | |
nodeStack.push(node) | |
}) | |
this.root = nodeStack[0] | |
this.offsetCacheVersion = offsetCacheVersion | |
} | |
getRoot() { | |
return this.root | |
} | |
markOffsetCacheStale() { | |
this.offsetCacheVersion++ | |
} | |
traverseInPreorder(f: (h: HeadingNode) => TraverseResult) { | |
const visitChildren = (h: HeadingNode): TraverseResult => { | |
for (const child of h.children) { | |
if (visit(child) === TraverseResult.Stop) { | |
return TraverseResult.Stop | |
} | |
} | |
return TraverseResult.Continue | |
} | |
const visit = (h: HeadingNode): TraverseResult => { | |
h.lazyLoad(this.offsetCacheVersion) | |
const res = f(h) | |
if (res !== TraverseResult.Continue) { | |
return res | |
} | |
return visitChildren(h) | |
} | |
visitChildren(this.root) | |
} | |
} |
// src/utils/mediaQuery.js | |
import startCase from "lodash/startCase" | |
const min = width => `only screen and (min-width: ${width}px)` | |
const max = width => `only screen and (max-width: ${width - 1}px)` | |
const mediaQuery = { | |
screens: { | |
// values are in pixels | |
small: 576, | |
medium: 768, | |
large: 992, | |
}, | |
} | |
for (const key of Object.keys(mediaQuery.screens)) { | |
const Key = startCase(key) | |
for (const [func, name] of [ | |
[min, `min`], | |
[max, `max`], | |
]) { | |
// css query | |
const query = func(mediaQuery.screens[key]) | |
mediaQuery[name + Key] = `@media ` + query | |
// js query (see window.matchMedia) | |
mediaQuery[name + Key + `Js`] = query | |
} | |
} | |
export default mediaQuery |
// src/components/toc/styles.ts | |
import styled, { IProps } from "utils/styled" | |
import { css } from "@emotion/core" | |
import { FaList as BookContentIcon } from "react-icons/fa" | |
import { FaTimes as CrossIcon } from "react-icons/fa" | |
import mediaQuery from "utils/mediaQuery" | |
import { ITocListProps, ITocToggleProps } from "./types" | |
const openTocDiv = (props: IProps) => css` | |
background: ${props.theme.colors.background}; | |
color: ${props.theme.colors.text.primary}; | |
padding: ${props.theme.space[3]} ${props.theme.space[5]}; | |
border-radius: ${props.theme.space[2]}; | |
box-shadow: 0 0 1em rgba(0, 0, 0, 0.5); | |
border: 1px solid ${props.theme.colors.ui.border}; | |
` | |
export const TocDiv = styled.div` | |
height: max-content; | |
z-index: 3; | |
line-height: 2em; | |
right: ${props => props.theme.space[4]}; | |
margin-top: ${props => props.theme.space[3]}; | |
${mediaQuery.maxMedium} { | |
overscroll-behavior: none; | |
nav { | |
max-height: 60vh; | |
overflow-y: scroll; | |
} | |
position: fixed; | |
bottom: 1em; | |
left: 1em; | |
visibility: ${(props: any) => (props.open ? `visible` : `hidden`)}; | |
opacity: ${(props: any) => (props.open ? 1 : 0)}; | |
transition: 0.3s; | |
background: white; | |
max-width: 20em; | |
${(props: any) => (props.open ? openTocDiv : `height: 0;`)} | |
} | |
${mediaQuery.minMedium} { | |
font-size: ${props => props.theme.fontSizes[1]}; | |
position: sticky; | |
top: ${props => props.theme.space[7]}; | |
padding-left: ${props => props.theme.space[4]}; | |
max-width: 100%; | |
} | |
` | |
export const TocTitle = styled.p` | |
margin: 0; | |
padding-bottom: ${props => props.theme.space[2]}; | |
display: grid; | |
grid-auto-flow: column; | |
grid-template-columns: auto auto 1fr; | |
align-items: center; | |
font-size: ${props => props.theme.fontSizes[3]}; | |
font-weight: ${props => props.theme.fontWeights[`bold`]}; | |
font-family: ${props => props.theme.fonts[`heading`]}; | |
line-height: ${props => props.theme.lineHeights[`dense`]}; | |
` | |
export const TocLink = styled.a` | |
font-weight: ${(props: ITocListProps) => props.active && `bold`}; | |
display: block; | |
box-shadow: none; | |
` | |
const listItemShiftWidthEm = 1.5 | |
const bulletRadiusPx = 4 | |
export const TocListBullet = styled.span` | |
position: absolute; | |
border-color: #f0f0f2; | |
border-width: 1px; | |
border-style: solid; | |
border-radius: ${bulletRadiusPx}px; | |
background-color: ${(props: ITocListProps) => (props.active ? `rgba(34, 162, 201, 1);` : `#ffffff`)}; | |
width: ${bulletRadiusPx * 2}px; | |
height: ${bulletRadiusPx * 2}px; | |
content: ""; | |
position: absolute; | |
display: block; | |
z-index: 999; | |
top: calc(50% - ${bulletRadiusPx}px); | |
left: calc(${(props: ITocListProps) => `${(props.depth + 1 - 1) * listItemShiftWidthEm}em`} - ${bulletRadiusPx}px); | |
` | |
export const TocListItem = styled.li` | |
margin: 0; | |
list-style: none; | |
/* You need to turn on relative positioning so the line is placed relative to the item rather than absolutely on the page */ | |
position: relative; | |
/* Use padding to space things out rather than margins as the line would get broken up otherwise */ | |
padding-left: ${(props: ITocListProps) => `${(props.depth + 1) * listItemShiftWidthEm}em`}; | |
&::before { | |
background-color: ${props => props.theme.colors.palette.grey[20]}; | |
width: 1px; | |
content: ""; | |
position: absolute; | |
top: 0px; | |
bottom: 0px; | |
left: ${(props: ITocListProps) => `${(props.depth + 1 - 1) * listItemShiftWidthEm}em`}; | |
} | |
&:hover { | |
background-color: ${props => props.theme.colors.primaryShades[20]}; | |
} | |
` | |
export const TocIcon = styled(BookContentIcon)` | |
width: 1em; | |
margin-right: ${props => props.theme.space[1]}; | |
` | |
const openerToggleCss = (props: ITocToggleProps & IProps) => css` | |
position: fixed; | |
bottom: calc(1vh + 4em); | |
left: 0; | |
margin-left: ${props.theme.space[2]}; | |
transform: translate(${props.open ? `-100%` : 0}); | |
padding: ${props.theme.space[1]}; | |
border-radius: 0 50% 50% 0; | |
background: ${props.theme.colors.primary}; | |
color: ${props.theme.colors.background}; | |
` | |
const closerToggleCss = () => css` | |
margin-left: 1em; | |
padding: 2px; | |
border-radius: 0 50% 50% 0; | |
` | |
const toggleCss = () => css` | |
width: 1.6em; | |
height: auto; | |
z-index: 2; | |
transition: 0.3s; | |
justify-self: end; | |
&:hover { | |
transform: scale(1.1); | |
} | |
${mediaQuery.minMedium} { | |
display: none; | |
} | |
` | |
export const TocToggleOpener = styled(BookContentIcon)` | |
${toggleCss} | |
${openerToggleCss} | |
` | |
export const TocToggleCloser = styled(CrossIcon)` | |
${toggleCss} | |
${closerToggleCss} | |
` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This comment has been minimized.
Awesome!! you are a live saver.