generated from coulomb/repo-seed
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user