a tool for shared writing and social publishing
1"use client";
2
3import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
4import { useUIState } from "src/useUIState";
5
6export type FootnoteSideItem = {
7 id: string;
8 index: number;
9};
10
11const GAP = 4;
12
13export function FootnoteSideColumnLayout<T extends FootnoteSideItem>(props: {
14 items: T[];
15 visible: boolean;
16 fullPageScroll?: boolean;
17 getAnchorSelector: (item: T) => string;
18 renderItem: (item: T & { top: number }) => ReactNode;
19}) {
20 let containerRef = useRef<HTMLDivElement>(null);
21 let innerRef = useRef<HTMLDivElement>(null);
22 let [positions, setPositions] = useState<(T & { top: number })[]>([]);
23
24 let calculatePositions = useCallback(() => {
25 let container = containerRef.current;
26 let inner = innerRef.current;
27 if (!container || !inner || props.items.length === 0) {
28 setPositions([]);
29 return;
30 }
31
32 let scrollWrapper = container.closest(".pageWrapper")
33 ?.querySelector(".pageScrollWrapper") as HTMLElement | null;
34 if (!scrollWrapper) return;
35
36 let scrollTop = scrollWrapper.scrollTop;
37 let containerRect = container.getBoundingClientRect();
38
39 // Sync scroll transform directly on the DOM (no React re-render)
40 inner.style.transform = `translateY(-${scrollTop}px)`;
41
42 let measurements: (T & { anchorTop: number; height: number })[] = [];
43
44 for (let item of props.items) {
45 let supEl = scrollWrapper.querySelector(
46 props.getAnchorSelector(item),
47 ) as HTMLElement | null;
48 if (!supEl) continue;
49
50 let supRect = supEl.getBoundingClientRect();
51 // Position relative to the side column container (which is absolute top-0 in pageWrapper),
52 // offset by the item's padding so the text visually aligns with the anchor
53 let anchorTop = supRect.top - containerRect.top + scrollTop - 4;
54
55 let itemEl = inner.querySelector(
56 `[data-footnote-side-id="${item.id}"]`,
57 ) as HTMLElement | null;
58 let height = itemEl ? itemEl.offsetHeight : 54;
59
60 measurements.push({ ...item, anchorTop, height });
61 }
62
63 let resolved: (T & { top: number })[] = [];
64 let nextAvailableTop = 0;
65 for (let m of measurements) {
66 let top = Math.max(m.anchorTop, nextAvailableTop);
67 resolved.push({
68 ...m,
69 top,
70 });
71 nextAvailableTop = top + m.height + GAP;
72 }
73
74 setPositions(resolved);
75 }, [props.items, props.getAnchorSelector]);
76
77 useEffect(() => {
78 if (!props.visible) return;
79 calculatePositions();
80
81 let scrollWrapper = containerRef.current?.closest(".pageWrapper")
82 ?.querySelector(".pageScrollWrapper") as HTMLElement | null;
83 if (!scrollWrapper) return;
84
85 // On scroll, update the transform directly without React re-render
86 let onScroll = () => {
87 let inner = innerRef.current;
88 if (inner) {
89 inner.style.transform = `translateY(-${scrollWrapper!.scrollTop}px)`;
90 }
91 };
92
93 scrollWrapper.addEventListener("scroll", onScroll, { passive: true });
94
95 // Forward wheel events from the side column to the scroll wrapper
96 let container = containerRef.current!;
97 let onWheel = (e: WheelEvent) => {
98 scrollWrapper!.scrollTop += e.deltaY;
99 };
100 container.addEventListener("wheel", onWheel, { passive: true });
101
102 let resizeObserver = new ResizeObserver(calculatePositions);
103 resizeObserver.observe(scrollWrapper);
104
105 // Observe all side items so positions recalculate when their heights change
106 let observeSideItems = () => {
107 let inner = innerRef.current;
108 if (!inner) return;
109 for (let el of inner.querySelectorAll("[data-footnote-side-id]")) {
110 resizeObserver.observe(el);
111 }
112 };
113 observeSideItems();
114
115 let mutationObserver = new MutationObserver(() => {
116 calculatePositions();
117 // Re-observe in case new items were added
118 observeSideItems();
119 });
120 mutationObserver.observe(scrollWrapper, {
121 childList: true,
122 subtree: true,
123 characterData: true,
124 });
125
126 // Also observe the inner container so we recalculate when side items
127 // are added/removed (they're siblings of scrollWrapper, not children)
128 let innerEl = innerRef.current;
129 if (innerEl) {
130 mutationObserver.observe(innerEl, {
131 childList: true,
132 subtree: true,
133 });
134 }
135
136 return () => {
137 scrollWrapper!.removeEventListener("scroll", onScroll);
138 container.removeEventListener("wheel", onWheel);
139 resizeObserver.disconnect();
140 mutationObserver.disconnect();
141 };
142 }, [props.visible, calculatePositions]);
143
144 if (!props.visible || props.items.length === 0) return null;
145
146 return (
147 <div
148 ref={containerRef}
149 className={`footnote-side-column hidden lg:block absolute top-0 w-[250px] ${
150 props.fullPageScroll
151 ? "left-[calc(50%+var(--page-width-units)/2+12px)]"
152 : "left-full ml-3"
153 }`}
154 style={{ height: "100%" }}
155 >
156 <div
157 ref={innerRef}
158 className="relative"
159 >
160 {positions.map((item) => (
161 <SideItem key={item.id} id={item.id} top={item.top}>
162 {props.renderItem(item)}
163 </SideItem>
164 ))}
165 </div>
166 </div>
167 );
168}
169
170function SideItem(props: {
171 children: ReactNode;
172 id: string;
173 top: number;
174}) {
175 let ref = useRef<HTMLDivElement>(null);
176 let [overflows, setOverflows] = useState(false);
177 let isFocused = useUIState(
178 (s) =>
179 s.focusedEntity?.entityType === "footnote" &&
180 s.focusedEntity.entityID === props.id,
181 );
182
183 useEffect(() => {
184 let el = ref.current;
185 if (!el) return;
186
187 let check = () => setOverflows(el!.scrollHeight > el!.clientHeight + 1);
188 check();
189
190 let mo = new MutationObserver(check);
191 mo.observe(el, { childList: true, subtree: true, characterData: true });
192
193 return () => {
194 mo.disconnect();
195 };
196 }, []);
197
198 return (
199 <div
200 ref={ref}
201 data-footnote-side-id={props.id}
202 className={`absolute left-0 right-0 text-sm footnote-side-enter footnote-side-item${overflows ? " has-overflow" : ""}${isFocused ? " footnote-side-focused" : ""}`}
203 style={{ top: props.top }}
204 >
205 {props.children}
206 </div>
207 );
208}