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.
*/
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
import {
PdfHighlighter,
PdfLoader,
@@ -116,39 +124,38 @@ function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
};
}
/**
* Trivial container that renders every stored highlight as a TextHighlight.
* 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?"
*/
interface HighlightContainerProps {
readonly activeAnnotationId: string | null | undefined;
readonly onHighlightClicked: ((annotationId: string) => void) | undefined;
}
const ActiveAnnotationContext = createContext<string | null | undefined>(
undefined,
);
const HighlightClickContext = createContext<((annotationId: string) => void) | undefined>(
undefined,
);
function makeSpikeHighlightContainer(props: HighlightContainerProps) {
return function SpikeHighlightContainer(): ReactNode {
const { highlight, isScrolledTo } = useHighlightContainerContext();
const isActive = props.activeAnnotationId === highlight.id;
return (
<div
data-highlight-id={highlight.id}
data-ce-active={isActive ? "true" : "false"}
style={{ display: "contents" }}
onClickCapture={(e) => {
// Click-capture so the click registers before the library's
// built-in selection-clearing logic eats it. Stop propagation
// so the highlight click doesn't also start a new selection.
e.stopPropagation();
props.onHighlightClicked?.(highlight.id);
}}
>
<MonitoredHighlightContainer>
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
</MonitoredHighlightContainer>
</div>
);
};
/**
* Stable highlight row — component type never changes so PdfHighlighter does
* not remount highlight layers on activation changes (which disturbs scroll).
* Active/focus styling reads from context instead.
*/
function SpikeHighlightContainer(): ReactNode {
const activeAnnotationId = useContext(ActiveAnnotationContext);
const onHighlightClicked = useContext(HighlightClickContext);
const { highlight, isScrolledTo } = useHighlightContainerContext();
const isActive = activeAnnotationId === highlight.id;
return (
<div
data-highlight-id={highlight.id}
data-ce-active={isActive ? "true" : "false"}
style={{ display: "contents" }}
onClickCapture={(e) => {
e.stopPropagation();
onHighlightClicked?.(highlight.id);
}}
>
<MonitoredHighlightContainer>
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
</MonitoredHighlightContainer>
</div>
);
}
/**
@@ -279,9 +286,21 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
hideAnnotationLayer,
hideXfaLayer,
} = props;
const HighlightContainer = useMemo(
() => makeSpikeHighlightContainer({ activeAnnotationId, onHighlightClicked }),
[activeAnnotationId, onHighlightClicked],
const onHighlightClickedRef = useRef(onHighlightClicked);
onHighlightClickedRef.current = 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 = [
debugTextLayer ? "ce-debug-textlayer" : null,
@@ -322,6 +341,17 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
const highlightsRef = useRef(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(() => {
const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null;
if (!requestKey || !scrollToAnnotationId) return;
@@ -348,51 +378,42 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
},
scrollStateRef.current,
);
}, [scrollToAnnotationId, scrollRequestKey, highlights, debugTextLayer]);
}, [scrollToAnnotationId, scrollRequestKey, highlightsSignature, debugTextLayer]);
return (
<div
className={wrapperClasses.length > 0 ? wrapperClasses : undefined}
style={{ height: "100%" }}
>
<PdfLoader
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/",
}}
>
<PdfLoader document={pdfLoaderDocument}>
{(pdfDocument) => (
<PdfHighlighter
pdfDocument={pdfDocument}
highlights={highlights}
utilsRef={(u) => {
utilsRef.current = u;
}}
onSelection={(selection) => {
const capture = captureFromPdfSelection(selection);
const selectors = selectorsFromPdfCapture(capture);
if (debugTextLayer) {
console.log("[ce] onSelection", {
text: capture.text,
page: capture.page,
rects: capture.rects,
selectorTypes: selectors.map((s) => s.type),
raw: selection,
});
}
onSelectionCaptured(capture, selectors);
}}
>
<HighlightContainer />
</PdfHighlighter>
<ActiveAnnotationContext.Provider value={activeAnnotationId}>
<HighlightClickContext.Provider value={handleHighlightClicked}>
<PdfHighlighter
pdfDocument={pdfDocument}
highlights={highlightsForViewer}
utilsRef={(u) => {
utilsRef.current = u;
}}
onSelection={(selection) => {
const capture = captureFromPdfSelection(selection);
const selectors = selectorsFromPdfCapture(capture);
if (debugTextLayer) {
console.log("[ce] onSelection", {
text: capture.text,
page: capture.page,
rects: capture.rects,
selectorTypes: selectors.map((s) => s.type),
raw: selection,
});
}
onSelectionCaptured(capture, selectors);
}}
>
<SpikeHighlightContainer />
</PdfHighlighter>
</HighlightClickContext.Provider>
</ActiveAnnotationContext.Provider>
)}
</PdfLoader>
</div>