Skip to content
File

Blob: src/client/sites/islands/site-outline-controller.tsx

typescript74 lines
1import { useEffect } from "react";
2import { OutlinePresentation } from "@/shared/editor/presentation/outline";
3import { resolveViewportActiveOutlineHeading } from "@/shared/editor/model/outline";
4import type { SiteOutlineControllerProps } from "@/shared/sites/island-schemas";
5 
6const OUTLINE_LINK_SELECTOR = ".tiptap-outline__link[data-outline-id]";
7 
8export 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}