diff --git a/app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnoteSideColumn.tsx b/app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnoteSideColumn.tsx new file mode 100644 index 00000000..7ab0a2fc --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnoteSideColumn.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useCallback } from "react"; +import { PublishedFootnote } from "./PublishedFootnotes"; +import { TextBlockCore } from "../Blocks/TextBlockCore"; +import { FootnoteSideColumnLayout } from "components/Footnotes/FootnoteSideColumnLayout"; + +type PublishedFootnoteItem = PublishedFootnote & { + id: string; +}; + +export function PublishedFootnoteSideColumn(props: { + footnotes: PublishedFootnote[]; +}) { + let items: PublishedFootnoteItem[] = props.footnotes.map((fn) => ({ + ...fn, + id: fn.footnoteId, + })); + + let getAnchorSelector = useCallback( + (item: PublishedFootnoteItem) => `#fnref-${item.id}`, + [], + ); + + let renderItem = useCallback( + (item: PublishedFootnoteItem & { top: number }) => ( + <> + + {item.index}. + {" "} + + {item.contentPlaintext ? ( + + ) : ( + Empty footnote + )} + + + ), + [], + ); + + return ( + 0} + getAnchorSelector={getAnchorSelector} + renderItem={renderItem} + /> + ); +} diff --git a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index 07d0cd5c..ade4ed02 100644 --- a/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -27,6 +27,7 @@ import { buildFootnoteIndexMap, PublishedFootnoteSection, } from "./Footnotes/PublishedFootnotes"; +import { PublishedFootnoteSideColumn } from "./Footnotes/PublishedFootnoteSideColumn"; export function LinearDocumentPage({ blocks, @@ -69,6 +70,11 @@ export function LinearDocumentPage({ !!drawer && (pageId ? drawer.pageId === pageId : !drawer.pageId) } pageOptions={pageOptions} + footnoteSideColumn={ + !props.hasContentToRight ? ( + + ) : undefined + } > {!isSubpage && profile && ( {!sharedProps.fullPageScroll && } - + 0 || !!(drawer && !drawer.pageId) + } + /> {drawer && !drawer.pageId && ( )} - {openPageIds.map((openPage) => { + {openPageIds.map((openPage, openPageIndex) => { const pageKey = getPageKey(openPage); // Handle thread pages @@ -372,6 +379,10 @@ export function PostPages({ {...sharedProps} fullPageScroll={false} pageId={page.id} + hasContentToRight={ + openPageIndex < openPageIds.length - 1 || + !!(drawer && drawer.pageId === page.id) + } pageOptions={ closePage(openPage)} diff --git a/components/Footnotes/FootnoteSideColumn.tsx b/components/Footnotes/FootnoteSideColumn.tsx index 6e27ef81..e07622b6 100644 --- a/components/Footnotes/FootnoteSideColumn.tsx +++ b/components/Footnotes/FootnoteSideColumn.tsx @@ -1,19 +1,18 @@ -import { useEffect, useRef, useState, useCallback } from "react"; +import { useCallback } from "react"; import { useFootnoteContext } from "./FootnoteContext"; import { FootnoteEditor } from "./FootnoteEditor"; import { useReplicache } from "src/replicache"; import { useEntitySetContext } from "components/EntitySetProvider"; import { deleteFootnoteFromBlock } from "./deleteFootnoteFromBlock"; +import { FootnoteSideColumnLayout } from "./FootnoteSideColumnLayout"; -type PositionedFootnote = { +type EditorFootnoteItem = { + id: string; + index: number; footnoteEntityID: string; blockID: string; - index: number; - top: number; }; -const GAP = 4; - export function FootnoteSideColumn(props: { pageEntityID: string; visible: boolean; @@ -21,187 +20,42 @@ export function FootnoteSideColumn(props: { let { footnotes } = useFootnoteContext(); let { permissions } = useEntitySetContext(); let rep = useReplicache(); - let containerRef = useRef(null); - let innerRef = useRef(null); - let [positions, setPositions] = useState([]); - let [scrollOffset, setScrollOffset] = useState(0); - - let calculatePositions = useCallback(() => { - let container = containerRef.current; - let inner = innerRef.current; - if (!container || !inner || footnotes.length === 0) { - setPositions([]); - return; - } - - let scrollWrapper = container.closest(".pageWrapper") - ?.querySelector(".pageScrollWrapper") as HTMLElement | null; - if (!scrollWrapper) return; - - let scrollTop = scrollWrapper.scrollTop; - let scrollWrapperRect = scrollWrapper.getBoundingClientRect(); - setScrollOffset(scrollTop); - - // Phase 1: Batch read — measure all anchor positions and item heights - let measurements: { - footnoteEntityID: string; - blockID: string; - index: number; - anchorTop: number; - height: number; - }[] = []; - - for (let fn of footnotes) { - let supEl = scrollWrapper.querySelector( - `.footnote-ref[data-footnote-id="${fn.footnoteEntityID}"]`, - ) as HTMLElement | null; - if (!supEl) continue; - - let supRect = supEl.getBoundingClientRect(); - let anchorTop = supRect.top - scrollWrapperRect.top + scrollTop; - - // Measure actual rendered height of the side item element - let itemEl = inner.querySelector( - `[data-footnote-side-id="${fn.footnoteEntityID}"]`, - ) as HTMLElement | null; - let height = itemEl ? itemEl.offsetHeight : 54; // fallback for first render - - measurements.push({ - footnoteEntityID: fn.footnoteEntityID, - blockID: fn.blockID, - index: fn.index, - anchorTop, - height, - }); - } - - // Phase 2: Resolve collisions using measured heights - let resolved: PositionedFootnote[] = []; - let nextAvailableTop = 0; - for (let m of measurements) { - let top = Math.max(m.anchorTop, nextAvailableTop); - resolved.push({ - footnoteEntityID: m.footnoteEntityID, - blockID: m.blockID, - index: m.index, - top, - }); - nextAvailableTop = top + m.height + GAP; - } - - // Phase 3: Batch write — set positions via state update (React handles DOM writes) - setPositions(resolved); - }, [footnotes]); - - useEffect(() => { - if (!props.visible) return; - calculatePositions(); - - let scrollWrapper = containerRef.current?.closest(".pageWrapper") - ?.querySelector(".pageScrollWrapper") as HTMLElement | null; - if (!scrollWrapper) return; - - let onScroll = () => { - setScrollOffset(scrollWrapper!.scrollTop); - }; - scrollWrapper.addEventListener("scroll", onScroll); - - let resizeObserver = new ResizeObserver(calculatePositions); - resizeObserver.observe(scrollWrapper); - - let mutationObserver = new MutationObserver(calculatePositions); - mutationObserver.observe(scrollWrapper, { - childList: true, - subtree: true, - characterData: true, - }); - - return () => { - scrollWrapper!.removeEventListener("scroll", onScroll); - resizeObserver.disconnect(); - mutationObserver.disconnect(); - }; - }, [props.visible, calculatePositions]); - - if (!props.visible || footnotes.length === 0) return null; - - return ( -
-
- {positions.map((fn) => ( - - deleteFootnoteFromBlock(fn.footnoteEntityID, fn.blockID, rep.rep) - : undefined - } - /> - - ))} -
-
+ let items: EditorFootnoteItem[] = footnotes.map((fn) => ({ + id: fn.footnoteEntityID, + index: fn.index, + footnoteEntityID: fn.footnoteEntityID, + blockID: fn.blockID, + })); + + let getAnchorSelector = useCallback( + (item: EditorFootnoteItem) => + `.footnote-ref[data-footnote-id="${item.id}"]`, + [], ); -} -function FootnoteSideItem(props: { - children: React.ReactNode; - footnoteEntityID: string; - top: number; - onResize: () => void; -}) { - let ref = useRef(null); - let [overflows, setOverflows] = useState(false); - - useEffect(() => { - let el = ref.current; - if (!el) return; - - let check = () => setOverflows(el!.scrollHeight > el!.clientHeight + 1); - check(); - - // Watch for content changes (text edits) - let mo = new MutationObserver(check); - mo.observe(el, { childList: true, subtree: true, characterData: true }); - - // Watch for size changes (expand/collapse on hover) and trigger reflow - let ro = new ResizeObserver(() => { - check(); - props.onResize(); - }); - ro.observe(el); - - return () => { - mo.disconnect(); - ro.disconnect(); - }; - }, [props.onResize]); + let renderItem = useCallback( + (item: EditorFootnoteItem & { top: number }) => ( + deleteFootnoteFromBlock(item.footnoteEntityID, item.blockID, rep.rep) + : undefined + } + /> + ), + [permissions.write, rep.rep], + ); return ( -
- {props.children} -
+ ); } diff --git a/components/Footnotes/FootnoteSideColumnLayout.tsx b/components/Footnotes/FootnoteSideColumnLayout.tsx new file mode 100644 index 00000000..8379f057 --- /dev/null +++ b/components/Footnotes/FootnoteSideColumnLayout.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; + +export type FootnoteSideItem = { + id: string; + index: number; +}; + +const GAP = 4; + +export function FootnoteSideColumnLayout(props: { + items: T[]; + visible: boolean; + getAnchorSelector: (item: T) => string; + renderItem: (item: T & { top: number }) => ReactNode; +}) { + let containerRef = useRef(null); + let innerRef = useRef(null); + let [positions, setPositions] = useState<(T & { top: number })[]>([]); + let [scrollOffset, setScrollOffset] = useState(0); + + let calculatePositions = useCallback(() => { + let container = containerRef.current; + let inner = innerRef.current; + if (!container || !inner || props.items.length === 0) { + setPositions([]); + return; + } + + let scrollWrapper = container.closest(".pageWrapper") + ?.querySelector(".pageScrollWrapper") as HTMLElement | null; + if (!scrollWrapper) return; + + let scrollTop = scrollWrapper.scrollTop; + let scrollWrapperRect = scrollWrapper.getBoundingClientRect(); + setScrollOffset(scrollTop); + + let measurements: (T & { anchorTop: number; height: number })[] = []; + + for (let item of props.items) { + let supEl = scrollWrapper.querySelector( + props.getAnchorSelector(item), + ) as HTMLElement | null; + if (!supEl) continue; + + let supRect = supEl.getBoundingClientRect(); + let anchorTop = supRect.top - scrollWrapperRect.top + scrollTop; + + let itemEl = inner.querySelector( + `[data-footnote-side-id="${item.id}"]`, + ) as HTMLElement | null; + let height = itemEl ? itemEl.offsetHeight : 54; + + measurements.push({ ...item, anchorTop, height }); + } + + let resolved: (T & { top: number })[] = []; + let nextAvailableTop = 0; + for (let m of measurements) { + let top = Math.max(m.anchorTop, nextAvailableTop); + resolved.push({ + ...m, + top, + }); + nextAvailableTop = top + m.height + GAP; + } + + setPositions(resolved); + }, [props.items, props.getAnchorSelector]); + + useEffect(() => { + if (!props.visible) return; + calculatePositions(); + + let scrollWrapper = containerRef.current?.closest(".pageWrapper") + ?.querySelector(".pageScrollWrapper") as HTMLElement | null; + if (!scrollWrapper) return; + + let onScroll = () => { + setScrollOffset(scrollWrapper!.scrollTop); + }; + + scrollWrapper.addEventListener("scroll", onScroll); + + let resizeObserver = new ResizeObserver(calculatePositions); + resizeObserver.observe(scrollWrapper); + + let mutationObserver = new MutationObserver(calculatePositions); + mutationObserver.observe(scrollWrapper, { + childList: true, + subtree: true, + characterData: true, + }); + + return () => { + scrollWrapper!.removeEventListener("scroll", onScroll); + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [props.visible, calculatePositions]); + + if (!props.visible || props.items.length === 0) return null; + + return ( +
+
+ {positions.map((item) => ( + + {props.renderItem(item)} + + ))} +
+
+ ); +} + +function SideItem(props: { + children: ReactNode; + id: string; + top: number; + onResize: () => void; +}) { + let ref = useRef(null); + let [overflows, setOverflows] = useState(false); + + useEffect(() => { + let el = ref.current; + if (!el) return; + + let check = () => setOverflows(el!.scrollHeight > el!.clientHeight + 1); + check(); + + let ro = new ResizeObserver(() => { + check(); + props.onResize(); + }); + ro.observe(el); + + let mo = new MutationObserver(check); + mo.observe(el, { childList: true, subtree: true, characterData: true }); + + return () => { + ro.disconnect(); + mo.disconnect(); + }; + }, [props.onResize]); + + return ( +
+ {props.children} +
+ ); +}