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 let [scrollOffset, setScrollOffset] = useState(0);
24
25 let calculatePositions = useCallback(() => {
26 let container = containerRef.current;
27 let inner = innerRef.current;
28 if (!container || !inner || props.items.length === 0) {
29 setPositions([]);
30 return;
31 }
32
33 let scrollWrapper = container.closest(".pageWrapper")
34 ?.querySelector(".pageScrollWrapper") as HTMLElement | null;
35 if (!scrollWrapper) return;
36
37 let scrollTop = scrollWrapper.scrollTop;
38 let scrollWrapperRect = scrollWrapper.getBoundingClientRect();
39 setScrollOffset(scrollTop);
40
41 let measurements: (T & { anchorTop: number; height: number })[] = [];
42
43 for (let item of props.items) {
44 let supEl = scrollWrapper.querySelector(
45 props.getAnchorSelector(item),
46 ) as HTMLElement | null;
47 if (!supEl) continue;
48
49 let supRect = supEl.getBoundingClientRect();
50 let anchorTop = supRect.top - scrollWrapperRect.top + scrollTop;
51
52 let itemEl = inner.querySelector(
53 `[data-footnote-side-id="${item.id}"]`,
54 ) as HTMLElement | null;
55 let height = itemEl ? itemEl.offsetHeight : 54;
56
57 measurements.push({ ...item, anchorTop, height });
58 }
59
60 let resolved: (T & { top: number })[] = [];
61 let nextAvailableTop = 0;
62 for (let m of measurements) {
63 let top = Math.max(m.anchorTop, nextAvailableTop);
64 resolved.push({
65 ...m,
66 top,
67 });
68 nextAvailableTop = top + m.height + GAP;
69 }
70
71 setPositions(resolved);
72 }, [props.items, props.getAnchorSelector]);
73
74 useEffect(() => {
75 if (!props.visible) return;
76 calculatePositions();
77
78 let scrollWrapper = containerRef.current?.closest(".pageWrapper")
79 ?.querySelector(".pageScrollWrapper") as HTMLElement | null;
80 if (!scrollWrapper) return;
81
82 let onScroll = () => {
83 setScrollOffset(scrollWrapper!.scrollTop);
84 };
85
86 scrollWrapper.addEventListener("scroll", onScroll);
87
88 let resizeObserver = new ResizeObserver(calculatePositions);
89 resizeObserver.observe(scrollWrapper);
90
91 let mutationObserver = new MutationObserver(calculatePositions);
92 mutationObserver.observe(scrollWrapper, {
93 childList: true,
94 subtree: true,
95 characterData: true,
96 });
97
98 return () => {
99 scrollWrapper!.removeEventListener("scroll", onScroll);
100 resizeObserver.disconnect();
101 mutationObserver.disconnect();
102 };
103 }, [props.visible, calculatePositions]);
104
105 if (!props.visible || props.items.length === 0) return null;
106
107 return (
108 <div
109 ref={containerRef}
110 className={`footnote-side-column hidden lg:block absolute top-0 w-[200px] pointer-events-none ${
111 props.fullPageScroll
112 ? "left-[calc(50%+var(--page-width-units)/2+12px)]"
113 : "left-full ml-3"
114 }`}
115 style={{ height: "100%" }}
116 >
117 <div
118 ref={innerRef}
119 className="relative pointer-events-auto"
120 style={{ transform: `translateY(-${scrollOffset}px)` }}
121 >
122 {positions.map((item) => (
123 <SideItem key={item.id} id={item.id} top={item.top} onResize={calculatePositions}>
124 {props.renderItem(item)}
125 </SideItem>
126 ))}
127 </div>
128 </div>
129 );
130}
131
132function SideItem(props: {
133 children: ReactNode;
134 id: string;
135 top: number;
136 onResize: () => void;
137}) {
138 let ref = useRef<HTMLDivElement>(null);
139 let [overflows, setOverflows] = useState(false);
140 let isFocused = useUIState(
141 (s) =>
142 s.focusedEntity?.entityType === "footnote" &&
143 s.focusedEntity.entityID === props.id,
144 );
145
146 useEffect(() => {
147 let el = ref.current;
148 if (!el) return;
149
150 let check = () => setOverflows(el!.scrollHeight > el!.clientHeight + 1);
151 check();
152
153 let ro = new ResizeObserver(() => {
154 check();
155 props.onResize();
156 });
157 ro.observe(el);
158
159 let mo = new MutationObserver(check);
160 mo.observe(el, { childList: true, subtree: true, characterData: true });
161
162 return () => {
163 ro.disconnect();
164 mo.disconnect();
165 };
166 }, [props.onResize]);
167
168 return (
169 <div
170 ref={ref}
171 data-footnote-side-id={props.id}
172 className={`absolute left-0 right-0 text-sm footnote-side-enter footnote-side-item${overflows ? " has-overflow" : ""}${isFocused ? " footnote-side-focused" : ""}`}
173 style={{ top: props.top }}
174 >
175 {props.children}
176 </div>
177 );
178}