aboutsummaryrefslogtreecommitdiffstats
path: root/src/utils/hooks/use-headings-tree/use-headings-tree.ts
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-11-14 19:07:14 +0100
committerArmand Philippot <git@armandphilippot.com>2023-11-14 19:07:14 +0100
commitbe4d907efb4e2fa658baa7c9b276ed282eb920db (patch)
tree0a7bd2d955ce9f9d5e252684ae6735bff7e9bd77 /src/utils/hooks/use-headings-tree/use-headings-tree.ts
parenta3a4c50f26b8750ae1c87f1f1103b84b7d2e6315 (diff)
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
Diffstat (limited to 'src/utils/hooks/use-headings-tree/use-headings-tree.ts')
-rw-r--r--src/utils/hooks/use-headings-tree/use-headings-tree.ts148
1 files changed, 148 insertions, 0 deletions
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<HTMLHeadingElement>} nodes - The heading elements list.
+ * @returns {HeadingsTreeNodeWithParentIndex[]} The headings nodes.
+ */
+const getHeadingNodesFrom = (
+ nodes: NodeListOf<HTMLHeadingElement>
+): 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<HTMLHeadingElement>} nodes - The heading nodes.
+ * @returns {HeadingsTreeNode[]} The headings tree.
+ */
+const buildHeadingsTreeFrom = (
+ nodes: NodeListOf<HTMLHeadingElement>
+): 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<T>} 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 = <T extends HTMLElement = HTMLElement>(
+ ref: RefObject<T>,
+ 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<HeadingsTreeNode[]>([]);
+ const requestedHeadingTags = getHeadingTagsList(options);
+ const query = requestedHeadingTags.join(', ');
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+
+ const headingNodes =
+ ref.current?.querySelectorAll<HTMLHeadingElement>(query);
+
+ if (headingNodes) setTree(buildHeadingsTreeFrom(headingNodes));
+ }, [query, ref]);
+
+ return tree;
+};