Skip to content
File

Blob: src/client/components/editor/lib/use-viewport-active-heading.ts

typescript97 lines
1import { useEffect, useEffectEvent, useMemo, useState } from "react";
2import type { Editor } from "@tiptap/react";
3import { resolveViewportActiveOutlineHeading } from "@/shared/editor/model/outline";
4import type { HeadingOutlineItem } from "./heading-outline";
5 
6function findScrollContainer(el: HTMLElement | null): HTMLElement | null {
7 let cur: HTMLElement | null = el;
8 while (cur && cur !== document.body) {
9 const style = getComputedStyle(cur);
10 if (style.overflowY === "auto" || style.overflowY === "scroll") return cur;
11 cur = cur.parentElement;
12 }
13 return null;
14}
15 
16export function useViewportActiveHeading(
17 editor: Editor | null | undefined,
18 headings: HeadingOutlineItem[],
19): number | null {
20 const [activePos, setActivePos] = useState<number | null>(null);
21 const readHeadings = useEffectEvent(() => headings);
22 
23 const positionsKey = useMemo(() => headings.map((h) => `${h.pos}:${h.level}`).join(","), [headings]);
24 
25 useEffect(() => {
26 if (!editor) return;
27 if (readHeadings().length === 0) {
28 return;
29 }
30 const scrollEl = findScrollContainer(editor.view.dom.parentElement);
31 if (!scrollEl) return;
32 
33 let rafId: number | null = null;
34 
35 const recompute = () => {
36 rafId = null;
37 const current = readHeadings();
38 if (current.length === 0) {
39 setActivePos((prev) => (prev === null ? prev : null));
40 return;
41 }
42 const containerRect = scrollEl.getBoundingClientRect();
43 const containerTop = containerRect.top;
44 const next = resolveViewportActiveOutlineHeading(
45 current
46 .map((heading) => {
47 const node = editor.view.nodeDOM(heading.pos);
48 if (!(node instanceof HTMLElement)) return null;
49 const rect = node.getBoundingClientRect();
50 return {
51 id: heading.pos,
52 top: rect.top,
53 bottom: rect.bottom,
54 // Skip collapsed/hidden headings (e.g. inside a closed details block).
55 hidden: node.offsetParent === null,
56 };
57 })
58 .filter(
59 (heading): heading is { id: number; top: number; bottom: number; hidden: boolean } => heading !== null,
60 ),
61 { top: containerTop, height: scrollEl.clientHeight },
62 );
63 setActivePos((prev) => (prev === next ? prev : next));
64 };
65 
66 const schedule = () => {
67 if (rafId !== null) return;
68 rafId = window.requestAnimationFrame(recompute);
69 };
70 
71 recompute();
72 
73 scrollEl.addEventListener("scroll", schedule, { passive: true });
74 window.addEventListener("resize", schedule, { passive: true });
75 
76 // Observe the scroll container's content wrapper so we recompute when
77 // anything above the editor reflows (title edits, cover toggles, image
78 // loads elsewhere on the page).
79 const ro = new ResizeObserver(schedule);
80 const contentRoot = scrollEl.firstElementChild;
81 if (contentRoot instanceof HTMLElement) {
82 ro.observe(contentRoot);
83 } else {
84 ro.observe(editor.view.dom);
85 }
86 
87 return () => {
88 scrollEl.removeEventListener("scroll", schedule);
89 window.removeEventListener("resize", schedule);
90 ro.disconnect();
91 if (rafId !== null) window.cancelAnimationFrame(rafId);
92 };
93 }, [editor, positionsKey]);
94 
95 return headings.length === 0 ? null : activePos;
96}