File
Blob: src/client/sites/islands/site-outline-controller.tsx
| 1 | import { useEffect } from "react"; |
| 2 | import { OutlinePresentation } from "@/shared/editor/presentation/outline"; |
| 3 | import { resolveViewportActiveOutlineHeading } from "@/shared/editor/model/outline"; |
| 4 | import type { SiteOutlineControllerProps } from "@/shared/sites/island-schemas"; |
| 5 | |
| 6 | const OUTLINE_LINK_SELECTOR = ".tiptap-outline__link[data-outline-id]"; |
| 7 | |
| 8 | export function SiteOutlineController({ items }: SiteOutlineControllerProps) { |
| 9 | useEffect(() => { |
| 10 | const links = Array.from(document.querySelectorAll<HTMLAnchorElement>(OUTLINE_LINK_SELECTOR)); |
| 11 | if (links.length === 0) return; |
| 12 | |
| 13 | const headings = Array.from(new Set(links.map((link) => link.dataset.outlineId).filter(Boolean))) |
| 14 | .map((id) => { |
| 15 | const element = document.getElementById(id!); |
| 16 | return element instanceof HTMLElement ? { id: id!, element } : null; |
| 17 | }) |
| 18 | .filter((entry): entry is { id: string; element: HTMLElement } => entry !== null); |
| 19 | |
| 20 | if (headings.length === 0) return; |
| 21 | |
| 22 | let rafId: number | null = null; |
| 23 | |
| 24 | const setActive = (activeId: string | null) => { |
| 25 | for (const link of links) { |
| 26 | const active = activeId !== null && link.dataset.outlineId === activeId; |
| 27 | link.dataset.active = active ? "true" : "false"; |
| 28 | if (active) { |
| 29 | link.setAttribute("aria-current", "location"); |
| 30 | } else { |
| 31 | link.removeAttribute("aria-current"); |
| 32 | } |
| 33 | } |
| 34 | }; |
| 35 | |
| 36 | const recompute = () => { |
| 37 | rafId = null; |
| 38 | setActive( |
| 39 | resolveViewportActiveOutlineHeading( |
| 40 | headings.map((heading) => { |
| 41 | const rect = heading.element.getBoundingClientRect(); |
| 42 | return { |
| 43 | id: heading.id, |
| 44 | top: rect.top, |
| 45 | bottom: rect.bottom, |
| 46 | hidden: heading.element.offsetParent === null, |
| 47 | }; |
| 48 | }), |
| 49 | { top: 0, height: window.innerHeight }, |
| 50 | ), |
| 51 | ); |
| 52 | }; |
| 53 | |
| 54 | const schedule = () => { |
| 55 | if (rafId !== null) return; |
| 56 | rafId = window.requestAnimationFrame(recompute); |
| 57 | }; |
| 58 | |
| 59 | recompute(); |
| 60 | window.addEventListener("scroll", schedule, { passive: true }); |
| 61 | window.addEventListener("resize", schedule, { passive: true }); |
| 62 | window.addEventListener("hashchange", schedule, { passive: true }); |
| 63 | |
| 64 | return () => { |
| 65 | if (rafId !== null) window.cancelAnimationFrame(rafId); |
| 66 | window.removeEventListener("scroll", schedule); |
| 67 | window.removeEventListener("resize", schedule); |
| 68 | window.removeEventListener("hashchange", schedule); |
| 69 | }; |
| 70 | }, []); |
| 71 | |
| 72 | return <OutlinePresentation items={items} mode="link" variant="rail" />; |
| 73 | } |