File
Blob: src/client/components/editor/lib/use-viewport-active-heading.ts
| 1 | import { useEffect, useEffectEvent, useMemo, useState } from "react"; |
| 2 | import type { Editor } from "@tiptap/react"; |
| 3 | import { resolveViewportActiveOutlineHeading } from "@/shared/editor/model/outline"; |
| 4 | import type { HeadingOutlineItem } from "./heading-outline"; |
| 5 | |
| 6 | function 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 | |
| 16 | export 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 | } |