Skip to content

Instantly share code, notes, and snippets.

@jirfag jirfag/component.tsx

Last active Jun 15, 2020
Embed
What would you like to do?
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/components/toc/index.ts
export { default } from "./component"
// 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}
`
// src/components/toc/types.ts
export interface ITocListProps {
active: boolean
depth: number
}
export interface ITocToggleProps {
open: boolean
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.