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,31 +124,31 @@ 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
* 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 { highlight, isScrolledTo } = useHighlightContainerContext();
const isActive = props.activeAnnotationId === highlight.id; const isActive = activeAnnotationId === highlight.id;
return ( return (
<div <div
data-highlight-id={highlight.id} data-highlight-id={highlight.id}
data-ce-active={isActive ? "true" : "false"} data-ce-active={isActive ? "true" : "false"}
style={{ display: "contents" }} style={{ display: "contents" }}
onClickCapture={(e) => { 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(); e.stopPropagation();
props.onHighlightClicked?.(highlight.id); onHighlightClicked?.(highlight.id);
}} }}
> >
<MonitoredHighlightContainer> <MonitoredHighlightContainer>
@@ -148,7 +156,6 @@ function makeSpikeHighlightContainer(props: HighlightContainerProps) {
</MonitoredHighlightContainer> </MonitoredHighlightContainer>
</div> </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,31 +378,20 @@ 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) => (
<ActiveAnnotationContext.Provider value={activeAnnotationId}>
<HighlightClickContext.Provider value={handleHighlightClicked}>
<PdfHighlighter <PdfHighlighter
pdfDocument={pdfDocument} pdfDocument={pdfDocument}
highlights={highlights} highlights={highlightsForViewer}
utilsRef={(u) => { utilsRef={(u) => {
utilsRef.current = u; utilsRef.current = u;
}} }}
@@ -391,8 +410,10 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
onSelectionCaptured(capture, selectors); onSelectionCaptured(capture, selectors);
}} }}
> >
<HighlightContainer /> <SpikeHighlightContainer />
</PdfHighlighter> </PdfHighlighter>
</HighlightClickContext.Provider>
</ActiveAnnotationContext.Provider>
)} )}
</PdfLoader> </PdfLoader>
</div> </div>