From be4d907efb4e2fa658baa7c9b276ed282eb920db Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 14 Nov 2023 19:07:14 +0100 Subject: refactor(components, hooks): rewrite ToC and useHeadingsTree * replace TableOfContents component with TocWidget to keep the name of widget components coherent * replace `wrapper` prop with `tree` prop (the component no longer uses the hook, it is up to the consumer to provide the headings tree) * let consumer handle the widget title * add options to useHeadingsTree hook to retrieve only the wanted headings (and do not assume that h1 is unwanted) * expect an ref object instead of an element in useHeadingsTree hook * rename most of the types involved --- .../hooks/use-headings-tree/use-headings-tree.ts | 148 +++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/utils/hooks/use-headings-tree/use-headings-tree.ts (limited to 'src/utils/hooks/use-headings-tree/use-headings-tree.ts') diff --git a/src/utils/hooks/use-headings-tree/use-headings-tree.ts b/src/utils/hooks/use-headings-tree/use-headings-tree.ts new file mode 100644 index 0000000..6a081e7 --- /dev/null +++ b/src/utils/hooks/use-headings-tree/use-headings-tree.ts @@ -0,0 +1,148 @@ +import { useEffect, useState, type RefObject } from 'react'; +import type { HeadingLevel } from '../../../components'; + +export type HeadingsTreeNode = { + /** + * The heading children. + */ + children: HeadingsTreeNode[]; + /** + * The heading depth. + */ + depth: number; + /** + * The heading id. + */ + id: string; + /** + * The heading label. + */ + label: string; +}; + +const headingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const; + +type HeadingTagNames = (typeof headingTags)[number]; + +export type UseHeadingsTreeOptions = { + /** + * Look for headings starting from this level (1 = `h1`, ...). + * + * @default undefined + */ + fromLevel?: HeadingLevel; + /** + * Look for headings ending with this level (1 = `h1`, ...). + * + * @default undefined + */ + toLevel?: HeadingLevel; +}; + +/** + * Retrieve a list of heading tags. + * + * @param {UseHeadingsTreeOptions} options - An options object. + * @returns {HeadingTagNames[]} The heading tags list. + */ +const getHeadingTagsList = ( + options?: UseHeadingsTreeOptions +): HeadingTagNames[] => { + const tagsList = headingTags.slice(0); + + if (options?.toLevel) tagsList.length = options.toLevel; + if (options?.fromLevel) tagsList.splice(0, options.fromLevel - 1); + + return tagsList; +}; + +type HeadingsTreeNodeWithParentIndex = HeadingsTreeNode & { + parentIndex: number; +}; + +/** + * Convert a node list of heading elements to an array of indexed nodes. + * + * @param {NodeListOf} nodes - The heading elements list. + * @returns {HeadingsTreeNodeWithParentIndex[]} The headings nodes. + */ +const getHeadingNodesFrom = ( + nodes: NodeListOf +): HeadingsTreeNodeWithParentIndex[] => { + const depthLastIndexes = Array.from({ length: headingTags.length }, () => -1); + + return Array.from(nodes).map( + (node, index): HeadingsTreeNodeWithParentIndex => { + const depth = headingTags.findIndex((tag) => tag === node.localName); + const parentDepthIndexes = depthLastIndexes.slice(0, depth); + + depthLastIndexes[depth] = index; + + return { + children: [], + depth, + id: node.id, + label: node.textContent ?? '', + parentIndex: Math.max(...parentDepthIndexes), + }; + } + ); +}; + +/** + * Build an headings tree from a list of heading elements. + * + * @param {NodeListOf} nodes - The heading nodes. + * @returns {HeadingsTreeNode[]} The headings tree. + */ +const buildHeadingsTreeFrom = ( + nodes: NodeListOf +): HeadingsTreeNode[] => { + const headings = getHeadingNodesFrom(nodes); + const treeNodes: HeadingsTreeNode[] = []; + + for (const heading of headings) { + const { parentIndex, ...node } = heading; + + if (parentIndex >= 0) headings[parentIndex].children.push(node); + else treeNodes.push(node); + } + + return treeNodes; +}; + +/** + * React hook to retrieve the headings tree in a document or in a given wrapper. + * + * @param {RefObject} ref - A ref to the element where to look for headings. + * @param {UseHeadingsTreeOptions} options - The headings tree config. + * @returns {HeadingsTreeNode[]} The headings tree. + */ +export const useHeadingsTree = ( + ref: RefObject, + options?: UseHeadingsTreeOptions +): HeadingsTreeNode[] => { + if ( + options?.fromLevel && + options.toLevel && + options.fromLevel > options.toLevel + ) + throw new Error( + 'Invalid options: `fromLevel` must be lower or equal to `toLevel`.' + ); + + const [tree, setTree] = useState([]); + const requestedHeadingTags = getHeadingTagsList(options); + const query = requestedHeadingTags.join(', '); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const headingNodes = + ref.current?.querySelectorAll(query); + + if (headingNodes) setTree(buildHeadingsTreeFrom(headingNodes)); + }, [query, ref]); + + return tree; +}; -- cgit v1.2.3