a tool for shared writing and social publishing
at refactor/pub-settings 208 lines 6.2 kB view raw
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}