1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
|
import { useState, useCallback, type RefCallback } 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;
};
export type UseHeadingsTreeReturn<T extends HTMLElement> = {
/**
* A callback function to set a ref.
*/
ref: RefCallback<T>;
/**
* The headings tree.
*/
tree: HeadingsTreeNode[];
};
/**
* React hook to retrieve the headings tree in a document or in a given wrapper.
*
* @param {UseHeadingsTreeOptions} options - The headings tree config.
* @returns {UseHeadingsTreeReturn<T>} The headings tree and a ref callback.
*/
export const useHeadingsTree = <T extends HTMLElement = HTMLElement>(
options?: UseHeadingsTreeOptions
): UseHeadingsTreeReturn<T> => {
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(', ');
const ref: RefCallback<T> = useCallback(
(el) => {
const headingNodes = el?.querySelectorAll<HTMLHeadingElement>(query);
if (headingNodes) setTree(buildHeadingsTreeFrom(headingNodes));
},
[query]
);
return { ref, tree };
};
|