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.
|
* 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user