File size: 7,716 Bytes
20354ec
 
 
76fc93a
20354ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76fc93a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20354ec
 
76fc93a
 
 
20354ec
 
76fc93a
20354ec
 
 
0094189
 
20354ec
 
0094189
76fc93a
 
 
0094189
 
 
 
76fc93a
0094189
 
 
76fc93a
 
 
0094189
 
76fc93a
0094189
 
20354ec
 
76fc93a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20354ec
 
 
76fc93a
 
20354ec
76fc93a
 
 
 
 
 
 
 
 
 
20354ec
76fc93a
 
 
 
 
 
 
 
 
 
 
 
 
 
20354ec
76fc93a
 
 
 
 
 
 
 
20354ec
76fc93a
 
20354ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76fc93a
 
 
 
20354ec
76fc93a
 
20354ec
 
 
9b7d64e
 
 
 
20354ec
 
 
 
9b7d64e
20354ec
 
 
 
 
9b7d64e
 
 
 
20354ec
 
 
 
 
9b7d64e
20354ec
9b7d64e
 
76fc93a
 
20354ec
76fc93a
 
 
20354ec
 
 
9b7d64e
20354ec
9b7d64e
20354ec
 
9b7d64e
20354ec
 
 
9b7d64e
 
 
20354ec
9b7d64e
 
20354ec
 
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
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import type { Editor } from "@tiptap/core";

const SCROLL_OFFSET_PX = 100;

interface TocItem {
  id: string;
  text: string;
  level: number;
  pos: number;
}

interface TocNode extends TocItem {
  children: TocNode[];
}

function extractHeadings(editor: Editor): TocItem[] {
  const items: TocItem[] = [];
  editor.state.doc.descendants((node, pos) => {
    if (node.type.name === "heading") {
      const level = node.attrs.level as number;
      const text = node.textContent;
      if (text.trim()) {
        items.push({ id: `heading-${pos}`, text, level, pos });
      }
    }
  });
  return items;
}

function buildTree(headings: TocItem[]): TocNode[] {
  const root: TocNode[] = [];
  const stack: TocNode[] = [];

  for (const h of headings) {
    const node: TocNode = { ...h, children: [] };

    while (stack.length > 0 && stack[stack.length - 1].level >= h.level) {
      stack.pop();
    }

    if (stack.length === 0) {
      root.push(node);
    } else {
      stack[stack.length - 1].children.push(node);
    }

    stack.push(node);
  }

  return root;
}

/**
 * Resolve ProseMirror position to the closest heading DOM element.
 */
function resolveHeadingElement(editor: Editor, pos: number): HTMLElement | null {
  try {
    const dom = editor.view.domAtPos(pos + 1);
    let el: HTMLElement | null =
      dom.node instanceof HTMLElement ? dom.node : dom.node.parentElement;
    while (el && !/^H[1-6]$/i.test(el.tagName)) {
      el = el.parentElement;
    }
    return el;
  } catch {
    return null;
  }
}

interface TableOfContentsProps {
  editor: Editor | null;
  scrollContainer?: HTMLElement | null;
  autoCollapse?: boolean;
  onNavigate?: () => void;
}

export function TableOfContents({ editor, scrollContainer, autoCollapse = false, onNavigate }: TableOfContentsProps) {
  const [headings, setHeadings] = useState<TocItem[]>([]);
  const [activePos, setActivePos] = useState<number | null>(null);

  const debounceRef = useRef(0);

  useEffect(() => {
    if (!editor) return;
    setHeadings(extractHeadings(editor));
    // 60ms debounce: short enough that headings typed by any collaborator
    // (local or remote via Yjs) appear in the TOC with no perceptible lag
    // while still coalescing bursts of per-keystroke updates.
    const debouncedUpdate = () => {
      clearTimeout(debounceRef.current);
      debounceRef.current = window.setTimeout(
        () => setHeadings(extractHeadings(editor)),
        60,
      );
    };
    editor.on("update", debouncedUpdate);
    // Yjs remote transactions also fire "transaction" - listen to both so
    // the TOC keeps pace with collaborators editing in other browsers.
    editor.on("transaction", debouncedUpdate);
    return () => {
      editor.off("update", debouncedUpdate);
      editor.off("transaction", debouncedUpdate);
      clearTimeout(debounceRef.current);
    };
  }, [editor]);

  // Scroll-spy via IntersectionObserver - no per-frame scroll work.
  //
  // Semantics: active heading = the *topmost visible heading inside the scroll
  // container's reading zone* (below the SCROLL_OFFSET_PX top margin).
  // This is intentionally different from the publisher-side TOC which uses a
  // "last heading scrolled past the offset line" rule (see
  // `backend/src/publisher/html-renderer.ts`). Rationale:
  //  - Editor: users navigate non-linearly (click TOC, jump, split view). The
  //    topmost-visible rule reacts instantly when a heading enters the
  //    reading zone, so the sidebar always reflects what the user is looking
  //    at.
  //  - Publisher: readers scroll linearly through the article. The
  //    last-scrolled-past rule matches classic docs UX (MDN, Docusaurus) and
  //    keeps the active entry stable while reading body text between
  //    headings.
  // Both use IntersectionObserver as the primitive; only the selection logic
  // differs. Do not "unify" the two without considering the UX implications.
  useEffect(() => {
    if (!editor || headings.length === 0) return;

    const root = scrollContainer ?? null;
    const visibleSet = new Map<Element, number>();

    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            visibleSet.set(entry.target, headings.findIndex(
              (h) => resolveHeadingElement(editor, h.pos) === entry.target,
            ));
          } else {
            visibleSet.delete(entry.target);
          }
        }
        let bestIdx = -1;
        for (const idx of visibleSet.values()) {
          if (idx >= 0 && (bestIdx < 0 || idx < bestIdx)) bestIdx = idx;
        }
        if (visibleSet.size > 0) {
          setActivePos(bestIdx >= 0 ? headings[bestIdx].pos : null);
        }
      },
      {
        root,
        rootMargin: `-${SCROLL_OFFSET_PX}px 0px 0px 0px`,
        threshold: 0,
      },
    );

    const elements: HTMLElement[] = [];
    for (const h of headings) {
      const el = resolveHeadingElement(editor, h.pos);
      if (el) {
        observer.observe(el);
        elements.push(el);
      }
    }

    return () => observer.disconnect();
  }, [editor, headings, scrollContainer]);

  const tree = useMemo(() => buildTree(headings), [headings]);

  const expandedPositions = useMemo(() => {
    const set = new Set<number>();
    if (activePos == null) return set;

    const walk = (nodes: TocNode[], ancestors: number[]): boolean => {
      for (const node of nodes) {
        if (node.pos === activePos) {
          ancestors.forEach((p) => set.add(p));
          set.add(node.pos);
          return true;
        }
        if (node.children.length > 0 && walk(node.children, [...ancestors, node.pos])) {
          return true;
        }
      }
      return false;
    };
    walk(tree, []);
    return set;
  }, [tree, activePos]);

  const scrollTo = useCallback((pos: number) => {
    if (!editor) return;
    const el = resolveHeadingElement(editor, pos);
    if (el) {
      el.scrollIntoView({ behavior: "smooth", block: "start" });
    }
    editor.chain().focus().setTextSelection(pos).run();
    onNavigate?.();
  }, [editor, onNavigate]);

  if (!editor || headings.length === 0) {
    return (
      <div className="table-of-contents">
        <div className="toc-title">Table of contents</div>
        <div className="toc-empty">Headings will appear here as you write.</div>
      </div>
    );
  }

  const renderList = (nodes: TocNode[], isRoot: boolean) => (
    <ul>
      {nodes.map((node) => {
        const isExpanded = expandedPositions.has(node.pos);
        const hasChildren = node.children.length > 0;

        return (
          <li key={node.id}>
            <a
              className={activePos === node.pos ? "active" : undefined}
              onClick={(e) => {
                e.preventDefault();
                scrollTo(node.pos);
              }}
            >
              {node.text}
            </a>
            {hasChildren && (
              <div
                className="toc-children"
                style={autoCollapse ? {
                  gridTemplateRows: isExpanded ? "1fr" : "0fr",
                  opacity: isExpanded ? 1 : 0,
                } : {
                  gridTemplateRows: "1fr",
                  opacity: 1,
                }}
              >
                {renderList(node.children, false)}
              </div>
            )}
          </li>
        );
      })}
    </ul>
  );

  return (
    <div className="table-of-contents">
      <div className="toc-title">Table of contents</div>
      <nav>
        {renderList(tree, true)}
      </nav>
    </div>
  );
}