Fix viewport jumping back to top after evidence scroll

PdfLoader reloads the PDF when its document prop is a new object each
render. Memoize the loader config on pdfUrl only.

Also stabilize SpikeHighlightContainer via context (no remount on focus
change) and narrow scroll-effect deps to highlight id signature.
This commit is contained in:
2026-06-08 01:17:41 +02:00
parent 50c29da4b1
commit ba34ba868f

View File

@@ -14,7 +14,15 @@
* will build the real PDFViewerAdapter on top of this lessons-learned. * will build the real PDFViewerAdapter on top of this lessons-learned.
*/ */
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
import { import {
PdfHighlighter, PdfHighlighter,
PdfLoader, PdfLoader,
@@ -116,39 +124,38 @@ function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
}; };
} }
/** const ActiveAnnotationContext = createContext<string | null | undefined>(
* Trivial container that renders every stored highlight as a TextHighlight. undefined,
* For the spike, no editing tooling — just visual proof of "did the saved );
* coordinates land on the right passage on the right page after reload?" const HighlightClickContext = createContext<((annotationId: string) => void) | undefined>(
*/ undefined,
interface HighlightContainerProps { );
readonly activeAnnotationId: string | null | undefined;
readonly onHighlightClicked: ((annotationId: string) => void) | undefined;
}
function makeSpikeHighlightContainer(props: HighlightContainerProps) { /**
return function SpikeHighlightContainer(): ReactNode { * Stable highlight row — component type never changes so PdfHighlighter does
const { highlight, isScrolledTo } = useHighlightContainerContext(); * not remount highlight layers on activation changes (which disturbs scroll).
const isActive = props.activeAnnotationId === highlight.id; * Active/focus styling reads from context instead.
return ( */
<div function SpikeHighlightContainer(): ReactNode {
data-highlight-id={highlight.id} const activeAnnotationId = useContext(ActiveAnnotationContext);
data-ce-active={isActive ? "true" : "false"} const onHighlightClicked = useContext(HighlightClickContext);
style={{ display: "contents" }} const { highlight, isScrolledTo } = useHighlightContainerContext();
onClickCapture={(e) => { const isActive = activeAnnotationId === highlight.id;
// Click-capture so the click registers before the library's return (
// built-in selection-clearing logic eats it. Stop propagation <div
// so the highlight click doesn't also start a new selection. data-highlight-id={highlight.id}
e.stopPropagation(); data-ce-active={isActive ? "true" : "false"}
props.onHighlightClicked?.(highlight.id); style={{ display: "contents" }}
}} onClickCapture={(e) => {
> e.stopPropagation();
<MonitoredHighlightContainer> onHighlightClicked?.(highlight.id);
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} /> }}
</MonitoredHighlightContainer> >
</div> <MonitoredHighlightContainer>
); <TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
}; </MonitoredHighlightContainer>
</div>
);
} }
/** /**
@@ -279,9 +286,21 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
hideAnnotationLayer, hideAnnotationLayer,
hideXfaLayer, hideXfaLayer,
} = props; } = props;
const HighlightContainer = useMemo( const onHighlightClickedRef = useRef(onHighlightClicked);
() => makeSpikeHighlightContainer({ activeAnnotationId, onHighlightClicked }), onHighlightClickedRef.current = onHighlightClicked;
[activeAnnotationId, onHighlightClicked], const handleHighlightClicked = useCallback((annotationId: string) => {
onHighlightClickedRef.current?.(annotationId);
}, []);
const pdfLoaderDocument = useMemo(
() => ({
url: pdfUrl,
// PdfLoader's effect depends on `document` by reference — must be
// stable across re-renders or the PDF reloads and scroll resets to top.
cMapUrl: "/cmaps/",
cMapPacked: true,
standardFontDataUrl: "/standard_fonts/",
}),
[pdfUrl],
); );
const wrapperClasses = [ const wrapperClasses = [
debugTextLayer ? "ce-debug-textlayer" : null, debugTextLayer ? "ce-debug-textlayer" : null,
@@ -322,6 +341,17 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
const highlightsRef = useRef(highlights); const highlightsRef = useRef(highlights);
highlightsRef.current = highlights; highlightsRef.current = highlights;
const highlightsSignature = useMemo(
() => highlights.map((h) => h.id).join(","),
[highlights],
);
// Re-render highlight layers when focus moves so `data-ce-active` updates.
const highlightsForViewer = useMemo(
() => highlights,
[highlights, activeAnnotationId],
);
useEffect(() => { useEffect(() => {
const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null; const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null;
if (!requestKey || !scrollToAnnotationId) return; if (!requestKey || !scrollToAnnotationId) return;
@@ -348,51 +378,42 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
}, },
scrollStateRef.current, scrollStateRef.current,
); );
}, [scrollToAnnotationId, scrollRequestKey, highlights, debugTextLayer]); }, [scrollToAnnotationId, scrollRequestKey, highlightsSignature, debugTextLayer]);
return ( return (
<div <div
className={wrapperClasses.length > 0 ? wrapperClasses : undefined} className={wrapperClasses.length > 0 ? wrapperClasses : undefined}
style={{ height: "100%" }} style={{ height: "100%" }}
> >
<PdfLoader <PdfLoader document={pdfLoaderDocument}>
document={{
url: pdfUrl,
// Without these two, PDFs with CID fonts (most CJK + many
// European court documents) or unembedded standard fonts get
// rendered with substitute metrics, which shifts every
// text-layer span out of alignment with the canvas glyphs.
// vite.config.ts copies both directories from pdfjs-dist into
// the served root.
cMapUrl: "/cmaps/",
cMapPacked: true,
standardFontDataUrl: "/standard_fonts/",
}}
>
{(pdfDocument) => ( {(pdfDocument) => (
<PdfHighlighter <ActiveAnnotationContext.Provider value={activeAnnotationId}>
pdfDocument={pdfDocument} <HighlightClickContext.Provider value={handleHighlightClicked}>
highlights={highlights} <PdfHighlighter
utilsRef={(u) => { pdfDocument={pdfDocument}
utilsRef.current = u; highlights={highlightsForViewer}
}} utilsRef={(u) => {
onSelection={(selection) => { utilsRef.current = u;
const capture = captureFromPdfSelection(selection); }}
const selectors = selectorsFromPdfCapture(capture); onSelection={(selection) => {
if (debugTextLayer) { const capture = captureFromPdfSelection(selection);
console.log("[ce] onSelection", { const selectors = selectorsFromPdfCapture(capture);
text: capture.text, if (debugTextLayer) {
page: capture.page, console.log("[ce] onSelection", {
rects: capture.rects, text: capture.text,
selectorTypes: selectors.map((s) => s.type), page: capture.page,
raw: selection, rects: capture.rects,
}); selectorTypes: selectors.map((s) => s.type),
} raw: selection,
onSelectionCaptured(capture, selectors); });
}} }
> onSelectionCaptured(capture, selectors);
<HighlightContainer /> }}
</PdfHighlighter> >
<SpikeHighlightContainer />
</PdfHighlighter>
</HighlightClickContext.Provider>
</ActiveAnnotationContext.Provider>
)} )}
</PdfLoader> </PdfLoader>
</div> </div>