aboutsummaryrefslogtreecommitdiffstats
path: root/src/utils/hooks/use-headings-tree.tsx
blob: 0dc077eeb7a9d863131b76ad8e12afd9173d7c42 (plain)
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
157
158
159
160
161
162
163
164
import { useCallback, useEffect, useMemo, useState } from 'react';
import { slugify } from '../helpers/strings';
import { useMutationObserver } from './use-mutation-observer';

export type Heading = {
  /**
   * The heading depth.
   */
  depth: number;
  /**
   * The heading id.
   */
  id: string;
  /**
   * The heading children.
   */
  children: Heading[];
  /**
   * The heading title.
   */
  title: string;
};

/**
 * Get the headings tree of the given HTML element.
 *
 * @param {HTMLElement} wrapper - An HTML element that contains the headings.
 * @returns {Heading[]} The headings tree.
 */
const useHeadingsTree = (wrapper: HTMLElement): Heading[] => {
  const depths = useMemo(() => ['h2', 'h3', 'h4', 'h5', 'h6'], []);
  const [allHeadings, setAllHeadings] =
    useState<NodeListOf<HTMLHeadingElement>>();
  const [headingsTree, setHeadingsTree] = useState<Heading[]>([]);

  const getHeadingsInWrapper = useCallback(() => {
    const query = depths.join(', ');
    const result: NodeListOf<HTMLHeadingElement> =
      wrapper.querySelectorAll(query);
    setAllHeadings(result);
  }, [depths, wrapper]);

  useEffect(() => {
    getHeadingsInWrapper();
  }, [getHeadingsInWrapper]);

  useMutationObserver({
    callback: getHeadingsInWrapper,
    options: { childList: true },
    nodeOrSelector: wrapper,
  });

  const getDepth = useCallback(
    /**
     * Retrieve the heading element depth.
     *
     * @param {HTMLHeadingElement} el - An heading element.
     * @returns {number} The heading depth.
     */
    (el: HTMLHeadingElement): number => {
      return depths.findIndex((depth) => depth === el.localName);
    },
    [depths]
  );

  const formatHeadings = useCallback(
    /**
     * Convert a list of headings into an array of Heading objects.
     *
     * @param {NodeListOf<HTMLHeadingElement>} headings - A list of headings.
     * @returns {Heading[]} An array of Heading objects.
     */
    (headings: NodeListOf<HTMLHeadingElement>): Heading[] => {
      const formattedHeadings: Heading[] = [];

      Array.from(headings).forEach((heading) => {
        const title: string = heading.textContent!;
        const id = slugify(title);
        const depth = getDepth(heading);
        const children: Heading[] = [];

        heading.id = id;

        formattedHeadings.push({
          depth,
          id,
          children,
          title,
        });
      });

      return formattedHeadings;
    },
    [getDepth]
  );

  const buildSubTree = useCallback(
    /**
     * Build the heading subtree.
     *
     * @param {Heading} parent - The heading parent.
     * @param {Heading} currentHeading - The current heading element.
     */
    (parent: Heading, currentHeading: Heading): void => {
      if (parent.depth === currentHeading.depth - 1) {
        parent.children.push(currentHeading);
      } else {
        const lastItem = parent.children[parent.children.length - 1];
        buildSubTree(lastItem, currentHeading);
      }
    },
    []
  );

  const buildTree = useCallback(
    /**
     * Build a heading tree.
     *
     * @param {Heading[]} headings - An array of Heading objects.
     * @returns {Heading[]} The headings tree.
     */
    (headings: Heading[]): Heading[] => {
      const tree: Heading[] = [];

      headings.forEach((heading) => {
        if (heading.depth === 0) {
          tree.push(heading);
        } else {
          const lastItem = tree[tree.length - 1];
          buildSubTree(lastItem, heading);
        }
      });

      return tree;
    },
    [buildSubTree]
  );

  const getHeadingsTree = useCallback(
    /**
     * Retrieve a headings tree from a list of headings element.
     *
     * @param {NodeListOf<HTMLHeadingElement>} headings - A headings list.
     * @returns {Heading[]} The headings tree.
     */
    (headings: NodeListOf<HTMLHeadingElement>): Heading[] => {
      const formattedHeadings = formatHeadings(headings);

      return buildTree(formattedHeadings);
    },
    [formatHeadings, buildTree]
  );

  useEffect(() => {
    if (allHeadings) {
      const headingsList = getHeadingsTree(allHeadings);
      setHeadingsTree(headingsList);
    }
  }, [allHeadings, getHeadingsTree]);

  return headingsTree;
};

export default useHeadingsTree;