a tool for shared writing and social publishing

render footnotes properly in published page

+280 -184
+58
app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnoteSideColumn.tsx
··· 1 + "use client"; 2 + 3 + import { useCallback } from "react"; 4 + import { PublishedFootnote } from "./PublishedFootnotes"; 5 + import { TextBlockCore } from "../Blocks/TextBlockCore"; 6 + import { FootnoteSideColumnLayout } from "components/Footnotes/FootnoteSideColumnLayout"; 7 + 8 + type PublishedFootnoteItem = PublishedFootnote & { 9 + id: string; 10 + }; 11 + 12 + export function PublishedFootnoteSideColumn(props: { 13 + footnotes: PublishedFootnote[]; 14 + }) { 15 + let items: PublishedFootnoteItem[] = props.footnotes.map((fn) => ({ 16 + ...fn, 17 + id: fn.footnoteId, 18 + })); 19 + 20 + let getAnchorSelector = useCallback( 21 + (item: PublishedFootnoteItem) => `#fnref-${item.id}`, 22 + [], 23 + ); 24 + 25 + let renderItem = useCallback( 26 + (item: PublishedFootnoteItem & { top: number }) => ( 27 + <> 28 + <a 29 + href={`#fnref-${item.footnoteId}`} 30 + className="text-accent-contrast font-medium text-xs no-underline hover:underline" 31 + > 32 + {item.index}. 33 + </a>{" "} 34 + <span className="text-secondary"> 35 + {item.contentPlaintext ? ( 36 + <TextBlockCore 37 + plaintext={item.contentPlaintext} 38 + facets={item.contentFacets} 39 + index={[]} 40 + /> 41 + ) : ( 42 + <span className="italic text-tertiary">Empty footnote</span> 43 + )} 44 + </span> 45 + </> 46 + ), 47 + [], 48 + ); 49 + 50 + return ( 51 + <FootnoteSideColumnLayout 52 + items={items} 53 + visible={props.footnotes.length > 0} 54 + getAnchorSelector={getAnchorSelector} 55 + renderItem={renderItem} 56 + /> 57 + ); 58 + }
+6
app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 27 27 buildFootnoteIndexMap, 28 28 PublishedFootnoteSection, 29 29 } from "./Footnotes/PublishedFootnotes"; 30 + import { PublishedFootnoteSideColumn } from "./Footnotes/PublishedFootnoteSideColumn"; 30 31 31 32 export function LinearDocumentPage({ 32 33 blocks, ··· 69 70 !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) 70 71 } 71 72 pageOptions={pageOptions} 73 + footnoteSideColumn={ 74 + !props.hasContentToRight ? ( 75 + <PublishedFootnoteSideColumn footnotes={footnotes} /> 76 + ) : undefined 77 + } 72 78 > 73 79 {!isSubpage && profile && ( 74 80 <PostHeader
+13 -2
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 185 185 pageId?: string; 186 186 pageOptions?: React.ReactNode; 187 187 allPages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[]; 188 + hasContentToRight?: boolean; 188 189 }; 189 190 190 191 // Component that renders either Canvas or Linear page based on page type ··· 286 287 <> 287 288 {!sharedProps.fullPageScroll && <BookendSpacer />} 288 289 289 - <PageRenderer page={firstPage} {...sharedProps} /> 290 + <PageRenderer 291 + page={firstPage} 292 + {...sharedProps} 293 + hasContentToRight={ 294 + openPageIds.length > 0 || !!(drawer && !drawer.pageId) 295 + } 296 + /> 290 297 291 298 {drawer && !drawer.pageId && ( 292 299 <InteractionDrawer ··· 306 313 /> 307 314 )} 308 315 309 - {openPageIds.map((openPage) => { 316 + {openPageIds.map((openPage, openPageIndex) => { 310 317 const pageKey = getPageKey(openPage); 311 318 312 319 // Handle thread pages ··· 372 379 {...sharedProps} 373 380 fullPageScroll={false} 374 381 pageId={page.id} 382 + hasContentToRight={ 383 + openPageIndex < openPageIds.length - 1 || 384 + !!(drawer && drawer.pageId === page.id) 385 + } 375 386 pageOptions={ 376 387 <PageOptions 377 388 onClick={() => closePage(openPage)}
+36 -182
components/Footnotes/FootnoteSideColumn.tsx
··· 1 - import { useEffect, useRef, useState, useCallback } from "react"; 1 + import { useCallback } from "react"; 2 2 import { useFootnoteContext } from "./FootnoteContext"; 3 3 import { FootnoteEditor } from "./FootnoteEditor"; 4 4 import { useReplicache } from "src/replicache"; 5 5 import { useEntitySetContext } from "components/EntitySetProvider"; 6 6 import { deleteFootnoteFromBlock } from "./deleteFootnoteFromBlock"; 7 + import { FootnoteSideColumnLayout } from "./FootnoteSideColumnLayout"; 7 8 8 - type PositionedFootnote = { 9 + type EditorFootnoteItem = { 10 + id: string; 11 + index: number; 9 12 footnoteEntityID: string; 10 13 blockID: string; 11 - index: number; 12 - top: number; 13 14 }; 14 - 15 - const GAP = 4; 16 15 17 16 export function FootnoteSideColumn(props: { 18 17 pageEntityID: string; ··· 21 20 let { footnotes } = useFootnoteContext(); 22 21 let { permissions } = useEntitySetContext(); 23 22 let rep = useReplicache(); 24 - let containerRef = useRef<HTMLDivElement>(null); 25 - let innerRef = useRef<HTMLDivElement>(null); 26 - let [positions, setPositions] = useState<PositionedFootnote[]>([]); 27 - let [scrollOffset, setScrollOffset] = useState(0); 28 - 29 - let calculatePositions = useCallback(() => { 30 - let container = containerRef.current; 31 - let inner = innerRef.current; 32 - if (!container || !inner || footnotes.length === 0) { 33 - setPositions([]); 34 - return; 35 - } 36 - 37 - let scrollWrapper = container.closest(".pageWrapper") 38 - ?.querySelector(".pageScrollWrapper") as HTMLElement | null; 39 - if (!scrollWrapper) return; 40 - 41 - let scrollTop = scrollWrapper.scrollTop; 42 - let scrollWrapperRect = scrollWrapper.getBoundingClientRect(); 43 - setScrollOffset(scrollTop); 44 - 45 - // Phase 1: Batch read — measure all anchor positions and item heights 46 - let measurements: { 47 - footnoteEntityID: string; 48 - blockID: string; 49 - index: number; 50 - anchorTop: number; 51 - height: number; 52 - }[] = []; 53 - 54 - for (let fn of footnotes) { 55 - let supEl = scrollWrapper.querySelector( 56 - `.footnote-ref[data-footnote-id="${fn.footnoteEntityID}"]`, 57 - ) as HTMLElement | null; 58 - if (!supEl) continue; 59 - 60 - let supRect = supEl.getBoundingClientRect(); 61 - let anchorTop = supRect.top - scrollWrapperRect.top + scrollTop; 62 - 63 - // Measure actual rendered height of the side item element 64 - let itemEl = inner.querySelector( 65 - `[data-footnote-side-id="${fn.footnoteEntityID}"]`, 66 - ) as HTMLElement | null; 67 - let height = itemEl ? itemEl.offsetHeight : 54; // fallback for first render 68 23 69 - measurements.push({ 70 - footnoteEntityID: fn.footnoteEntityID, 71 - blockID: fn.blockID, 72 - index: fn.index, 73 - anchorTop, 74 - height, 75 - }); 76 - } 77 - 78 - // Phase 2: Resolve collisions using measured heights 79 - let resolved: PositionedFootnote[] = []; 80 - let nextAvailableTop = 0; 81 - for (let m of measurements) { 82 - let top = Math.max(m.anchorTop, nextAvailableTop); 83 - resolved.push({ 84 - footnoteEntityID: m.footnoteEntityID, 85 - blockID: m.blockID, 86 - index: m.index, 87 - top, 88 - }); 89 - nextAvailableTop = top + m.height + GAP; 90 - } 91 - 92 - // Phase 3: Batch write — set positions via state update (React handles DOM writes) 93 - setPositions(resolved); 94 - }, [footnotes]); 95 - 96 - useEffect(() => { 97 - if (!props.visible) return; 98 - calculatePositions(); 99 - 100 - let scrollWrapper = containerRef.current?.closest(".pageWrapper") 101 - ?.querySelector(".pageScrollWrapper") as HTMLElement | null; 102 - if (!scrollWrapper) return; 103 - 104 - let onScroll = () => { 105 - setScrollOffset(scrollWrapper!.scrollTop); 106 - }; 107 - 108 - scrollWrapper.addEventListener("scroll", onScroll); 109 - 110 - let resizeObserver = new ResizeObserver(calculatePositions); 111 - resizeObserver.observe(scrollWrapper); 112 - 113 - let mutationObserver = new MutationObserver(calculatePositions); 114 - mutationObserver.observe(scrollWrapper, { 115 - childList: true, 116 - subtree: true, 117 - characterData: true, 118 - }); 119 - 120 - return () => { 121 - scrollWrapper!.removeEventListener("scroll", onScroll); 122 - resizeObserver.disconnect(); 123 - mutationObserver.disconnect(); 124 - }; 125 - }, [props.visible, calculatePositions]); 24 + let items: EditorFootnoteItem[] = footnotes.map((fn) => ({ 25 + id: fn.footnoteEntityID, 26 + index: fn.index, 27 + footnoteEntityID: fn.footnoteEntityID, 28 + blockID: fn.blockID, 29 + })); 126 30 127 - if (!props.visible || footnotes.length === 0) return null; 31 + let getAnchorSelector = useCallback( 32 + (item: EditorFootnoteItem) => 33 + `.footnote-ref[data-footnote-id="${item.id}"]`, 34 + [], 35 + ); 128 36 129 - return ( 130 - <div 131 - ref={containerRef} 132 - className="footnote-side-column hidden lg:block absolute top-0 left-full w-[200px] ml-3 pointer-events-none" 133 - style={{ height: "100%" }} 134 - > 135 - <div 136 - ref={innerRef} 137 - className="relative pointer-events-auto" 138 - style={{ transform: `translateY(-${scrollOffset}px)` }} 139 - > 140 - {positions.map((fn) => ( 141 - <FootnoteSideItem 142 - key={fn.footnoteEntityID} 143 - footnoteEntityID={fn.footnoteEntityID} 144 - top={fn.top} 145 - onResize={calculatePositions} 146 - > 147 - <FootnoteEditor 148 - footnoteEntityID={fn.footnoteEntityID} 149 - index={fn.index} 150 - editable={permissions.write} 151 - onDelete={ 152 - permissions.write 153 - ? () => deleteFootnoteFromBlock(fn.footnoteEntityID, fn.blockID, rep.rep) 154 - : undefined 155 - } 156 - /> 157 - </FootnoteSideItem> 158 - ))} 159 - </div> 160 - </div> 37 + let renderItem = useCallback( 38 + (item: EditorFootnoteItem & { top: number }) => ( 39 + <FootnoteEditor 40 + footnoteEntityID={item.footnoteEntityID} 41 + index={item.index} 42 + editable={permissions.write} 43 + onDelete={ 44 + permissions.write 45 + ? () => deleteFootnoteFromBlock(item.footnoteEntityID, item.blockID, rep.rep) 46 + : undefined 47 + } 48 + /> 49 + ), 50 + [permissions.write, rep.rep], 161 51 ); 162 - } 163 - 164 - function FootnoteSideItem(props: { 165 - children: React.ReactNode; 166 - footnoteEntityID: string; 167 - top: number; 168 - onResize: () => void; 169 - }) { 170 - let ref = useRef<HTMLDivElement>(null); 171 - let [overflows, setOverflows] = useState(false); 172 - 173 - useEffect(() => { 174 - let el = ref.current; 175 - if (!el) return; 176 - 177 - let check = () => setOverflows(el!.scrollHeight > el!.clientHeight + 1); 178 - check(); 179 - 180 - // Watch for content changes (text edits) 181 - let mo = new MutationObserver(check); 182 - mo.observe(el, { childList: true, subtree: true, characterData: true }); 183 - 184 - // Watch for size changes (expand/collapse on hover) and trigger reflow 185 - let ro = new ResizeObserver(() => { 186 - check(); 187 - props.onResize(); 188 - }); 189 - ro.observe(el); 190 - 191 - return () => { 192 - mo.disconnect(); 193 - ro.disconnect(); 194 - }; 195 - }, [props.onResize]); 196 52 197 53 return ( 198 - <div 199 - ref={ref} 200 - data-footnote-side-id={props.footnoteEntityID} 201 - className={`absolute left-0 right-0 text-xs footnote-side-enter footnote-side-item${overflows ? " has-overflow" : ""}`} 202 - style={{ top: props.top }} 203 - > 204 - {props.children} 205 - </div> 54 + <FootnoteSideColumnLayout 55 + items={items} 56 + visible={props.visible} 57 + getAnchorSelector={getAnchorSelector} 58 + renderItem={renderItem} 59 + /> 206 60 ); 207 61 }
+167
components/Footnotes/FootnoteSideColumnLayout.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; 4 + 5 + export type FootnoteSideItem = { 6 + id: string; 7 + index: number; 8 + }; 9 + 10 + const GAP = 4; 11 + 12 + export function FootnoteSideColumnLayout<T extends FootnoteSideItem>(props: { 13 + items: T[]; 14 + visible: boolean; 15 + getAnchorSelector: (item: T) => string; 16 + renderItem: (item: T & { top: number }) => ReactNode; 17 + }) { 18 + let containerRef = useRef<HTMLDivElement>(null); 19 + let innerRef = useRef<HTMLDivElement>(null); 20 + let [positions, setPositions] = useState<(T & { top: number })[]>([]); 21 + let [scrollOffset, setScrollOffset] = useState(0); 22 + 23 + let calculatePositions = useCallback(() => { 24 + let container = containerRef.current; 25 + let inner = innerRef.current; 26 + if (!container || !inner || props.items.length === 0) { 27 + setPositions([]); 28 + return; 29 + } 30 + 31 + let scrollWrapper = container.closest(".pageWrapper") 32 + ?.querySelector(".pageScrollWrapper") as HTMLElement | null; 33 + if (!scrollWrapper) return; 34 + 35 + let scrollTop = scrollWrapper.scrollTop; 36 + let scrollWrapperRect = scrollWrapper.getBoundingClientRect(); 37 + setScrollOffset(scrollTop); 38 + 39 + let measurements: (T & { anchorTop: number; height: number })[] = []; 40 + 41 + for (let item of props.items) { 42 + let supEl = scrollWrapper.querySelector( 43 + props.getAnchorSelector(item), 44 + ) as HTMLElement | null; 45 + if (!supEl) continue; 46 + 47 + let supRect = supEl.getBoundingClientRect(); 48 + let anchorTop = supRect.top - scrollWrapperRect.top + scrollTop; 49 + 50 + let itemEl = inner.querySelector( 51 + `[data-footnote-side-id="${item.id}"]`, 52 + ) as HTMLElement | null; 53 + let height = itemEl ? itemEl.offsetHeight : 54; 54 + 55 + measurements.push({ ...item, anchorTop, height }); 56 + } 57 + 58 + let resolved: (T & { top: number })[] = []; 59 + let nextAvailableTop = 0; 60 + for (let m of measurements) { 61 + let top = Math.max(m.anchorTop, nextAvailableTop); 62 + resolved.push({ 63 + ...m, 64 + top, 65 + }); 66 + nextAvailableTop = top + m.height + GAP; 67 + } 68 + 69 + setPositions(resolved); 70 + }, [props.items, props.getAnchorSelector]); 71 + 72 + useEffect(() => { 73 + if (!props.visible) return; 74 + calculatePositions(); 75 + 76 + let scrollWrapper = containerRef.current?.closest(".pageWrapper") 77 + ?.querySelector(".pageScrollWrapper") as HTMLElement | null; 78 + if (!scrollWrapper) return; 79 + 80 + let onScroll = () => { 81 + setScrollOffset(scrollWrapper!.scrollTop); 82 + }; 83 + 84 + scrollWrapper.addEventListener("scroll", onScroll); 85 + 86 + let resizeObserver = new ResizeObserver(calculatePositions); 87 + resizeObserver.observe(scrollWrapper); 88 + 89 + let mutationObserver = new MutationObserver(calculatePositions); 90 + mutationObserver.observe(scrollWrapper, { 91 + childList: true, 92 + subtree: true, 93 + characterData: true, 94 + }); 95 + 96 + return () => { 97 + scrollWrapper!.removeEventListener("scroll", onScroll); 98 + resizeObserver.disconnect(); 99 + mutationObserver.disconnect(); 100 + }; 101 + }, [props.visible, calculatePositions]); 102 + 103 + if (!props.visible || props.items.length === 0) return null; 104 + 105 + return ( 106 + <div 107 + ref={containerRef} 108 + className="footnote-side-column hidden lg:block absolute top-0 left-full w-[200px] ml-3 pointer-events-none" 109 + style={{ height: "100%" }} 110 + > 111 + <div 112 + ref={innerRef} 113 + className="relative pointer-events-auto" 114 + style={{ transform: `translateY(-${scrollOffset}px)` }} 115 + > 116 + {positions.map((item) => ( 117 + <SideItem key={item.id} id={item.id} top={item.top} onResize={calculatePositions}> 118 + {props.renderItem(item)} 119 + </SideItem> 120 + ))} 121 + </div> 122 + </div> 123 + ); 124 + } 125 + 126 + function SideItem(props: { 127 + children: ReactNode; 128 + id: string; 129 + top: number; 130 + onResize: () => void; 131 + }) { 132 + let ref = useRef<HTMLDivElement>(null); 133 + let [overflows, setOverflows] = useState(false); 134 + 135 + useEffect(() => { 136 + let el = ref.current; 137 + if (!el) return; 138 + 139 + let check = () => setOverflows(el!.scrollHeight > el!.clientHeight + 1); 140 + check(); 141 + 142 + let ro = new ResizeObserver(() => { 143 + check(); 144 + props.onResize(); 145 + }); 146 + ro.observe(el); 147 + 148 + let mo = new MutationObserver(check); 149 + mo.observe(el, { childList: true, subtree: true, characterData: true }); 150 + 151 + return () => { 152 + ro.disconnect(); 153 + mo.disconnect(); 154 + }; 155 + }, [props.onResize]); 156 + 157 + return ( 158 + <div 159 + ref={ref} 160 + data-footnote-side-id={props.id} 161 + className={`absolute left-0 right-0 text-xs footnote-side-enter footnote-side-item${overflows ? " has-overflow" : ""}`} 162 + style={{ top: props.top }} 163 + > 164 + {props.children} 165 + </div> 166 + ); 167 + }