a tool for shared writing and social publishing
at main 178 lines 5.0 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 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}