CE-WP-0008: fix capture field values and viewport scroll retry

- Wire fieldValues state in FormsApp so controlled inputs persist typed data
- Add runScrollToHighlightJob with rAF retries when utils/highlights not ready
- Re-trigger scroll when highlights update after PDF load
- Tests: scroll-job unit test, forms-field-values integration tests
- Workplan CE-WP-0008 marked done
This commit is contained in:
tegwick 2026-06-08 01:07:52 +02:00
parent fc6b91ccb0
commit 305a20d1d1
7 changed files with 384 additions and 17 deletions

View file

@ -41,6 +41,7 @@ import "./debug-textlayer.css";
import type { NormalizedRect, Selector } from "@shared/selector";
import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "./types";
import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math";
import { runScrollToHighlightJob } from "./scroll-job";
export { selectorsFromPdfCapture };
@ -292,7 +293,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
.filter((c): c is string => c !== null)
.join(" ");
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
const lastScrollKeyRef = useRef<string | null>(null);
const scrollStateRef = useRef({ lastCompletedKey: null as string | null });
const highlights = useMemo<Highlight[]>(() => {
const out: Highlight[] = [];
@ -324,27 +325,30 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
useEffect(() => {
const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null;
if (!requestKey || !scrollToAnnotationId) return;
if (lastScrollKeyRef.current === requestKey) return;
const utils = utilsRef.current;
const target = highlightsRef.current.find((h) => h.id === scrollToAnnotationId);
if (scrollStateRef.current.lastCompletedKey === requestKey) return;
if (debugTextLayer) {
console.log("[ce] scrollToAnnotation requested", {
id: scrollToAnnotationId,
requestKey,
utilsAvailable: !!utils,
targetFound: !!target,
utilsAvailable: !!utilsRef.current,
targetFound: !!highlightsRef.current.find((h) => h.id === scrollToAnnotationId),
knownIds: highlightsRef.current.map((h) => h.id),
});
}
if (!utils || !target) return;
utils.scrollToHighlight(target);
lastScrollKeyRef.current = requestKey;
// After the library scrolls the page into view, nudge so the highlight
// centre aligns with the scroll container centre (CE-WP-0006-T02).
requestAnimationFrame(() => {
centerHighlightInViewer(utils, target);
});
}, [scrollToAnnotationId, scrollRequestKey, debugTextLayer]);
return runScrollToHighlightJob(
{ requestKey, annotationId: scrollToAnnotationId },
{
getUtils: () => utilsRef.current,
findHighlight: (id) => highlightsRef.current.find((h) => h.id === id),
scrollToHighlight: (utils, target) => utils.scrollToHighlight(target),
centerHighlight: (utils, target) => centerHighlightInViewer(utils, target),
scheduleFrame: (fn) => requestAnimationFrame(fn),
},
scrollStateRef.current,
);
}, [scrollToAnnotationId, scrollRequestKey, highlights, debugTextLayer]);
return (
<div

View file

@ -0,0 +1,73 @@
/**
* CE-WP-0008-T02 scroll job retries until utils and highlight exist.
*/
import { describe, expect, it, vi } from "vitest";
import type { Highlight, PdfHighlighterUtils } from "react-pdf-highlighter-plus";
import { runScrollToHighlightJob } from "./scroll-job";
const TARGET = {
id: "ann_test",
type: "text",
content: { text: "quote" },
position: {
boundingRect: {
x1: 0,
y1: 0,
x2: 1,
y2: 1,
width: 1,
height: 1,
pageNumber: 2,
},
rects: [],
},
} as Highlight;
describe("runScrollToHighlightJob (CE-WP-0008-T02)", () => {
it("retries until utils and highlight are available", () => {
const frames: Array<() => void> = [];
const scrollToHighlight = vi.fn();
const centerHighlight = vi.fn();
let utils: PdfHighlighterUtils | null = null;
let highlight: Highlight | undefined;
const state = { lastCompletedKey: null as string | null };
const cancel = runScrollToHighlightJob(
{ requestKey: "ann_test:1", annotationId: "ann_test" },
{
getUtils: () => utils,
findHighlight: (id) => (id === "ann_test" ? highlight : undefined),
scrollToHighlight: (_u, target) => scrollToHighlight(target),
centerHighlight,
scheduleFrame: (fn) => {
frames.push(fn);
return frames.length;
},
maxAttempts: 5,
},
state,
);
expect(scrollToHighlight).not.toHaveBeenCalled();
// First two frames: still missing utils / highlight.
frames.shift()?.();
frames.shift()?.();
expect(scrollToHighlight).not.toHaveBeenCalled();
utils = { scrollToHighlight: vi.fn() } as unknown as PdfHighlighterUtils;
highlight = TARGET;
frames.shift()?.();
expect(scrollToHighlight).toHaveBeenCalledWith(TARGET);
expect(state.lastCompletedKey).toBe("ann_test:1");
frames.shift()?.();
expect(centerHighlight).toHaveBeenCalledWith(utils, TARGET);
cancel();
});
});

73
src/anchor/scroll-job.ts Normal file
View file

@ -0,0 +1,73 @@
/**
* Retryable scroll-to-highlight job for PdfSpikeViewer.
*
* The PDF highlighter's utils ref and highlight DOM are not always ready on
* the first effect tick (especially for page-2+ passages). This helper retries
* via rAF until both are available or attempts are exhausted.
*/
import type { Highlight, PdfHighlighterUtils } from "react-pdf-highlighter-plus";
export const DEFAULT_SCROLL_ATTEMPTS = 40;
export interface ScrollToHighlightJob {
readonly requestKey: string;
readonly annotationId: string;
}
export interface ScrollToHighlightDeps {
readonly getUtils: () => PdfHighlighterUtils | null;
readonly findHighlight: (annotationId: string) => Highlight | undefined;
readonly scrollToHighlight: (
utils: PdfHighlighterUtils,
target: Highlight,
) => void;
readonly centerHighlight: (
utils: PdfHighlighterUtils,
target: Highlight,
) => void;
readonly scheduleFrame: (fn: () => void) => number;
readonly maxAttempts?: number;
}
export interface ScrollToHighlightState {
lastCompletedKey: string | null;
}
/**
* Attempt scroll for `job`. Returns a cancel function. Sets
* `state.lastCompletedKey` only after a successful scroll.
*/
export function runScrollToHighlightJob(
job: ScrollToHighlightJob,
deps: ScrollToHighlightDeps,
state: ScrollToHighlightState,
): () => void {
let cancelled = false;
let attempt = 0;
const maxAttempts = deps.maxAttempts ?? DEFAULT_SCROLL_ATTEMPTS;
const tick = () => {
if (cancelled) return;
if (state.lastCompletedKey === job.requestKey) return;
const utils = deps.getUtils();
const target = deps.findHighlight(job.annotationId);
if (!utils || !target) {
if (attempt < maxAttempts) {
attempt += 1;
deps.scheduleFrame(tick);
}
return;
}
deps.scrollToHighlight(utils, target);
state.lastCompletedKey = job.requestKey;
deps.scheduleFrame(() => deps.centerHighlight(utils, target));
};
tick();
return () => {
cancelled = true;
};
}

View file

@ -64,9 +64,14 @@ export function FormsApp() {
[schema],
);
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const [showAddFieldForm, setShowAddFieldForm] = useState(false);
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
const handleFieldValueChange = useCallback((fieldId: string, value: string) => {
setFieldValues((prev) => ({ ...prev, [fieldId]: value }));
}, []);
const nextFieldId = useCallback((fields: readonly FormFieldSchema[]): string => {
let max = 0;
for (const f of fields) {
@ -119,6 +124,8 @@ export function FormsApp() {
<ViewerShell />
<FormPane
schema={schema}
fieldValues={fieldValues}
onFieldValueChange={handleFieldValueChange}
showAddFieldForm={showAddFieldForm}
editingFieldId={editingFieldId}
onRequestAddField={() => {
@ -156,6 +163,8 @@ function ScrollBridge() {
function FormPane({
schema,
fieldValues,
onFieldValueChange,
showAddFieldForm,
editingFieldId,
onRequestAddField,
@ -166,6 +175,8 @@ function FormPane({
onCancelFieldEdit,
}: {
schema: FormSchema;
fieldValues: Readonly<Record<string, string>>;
onFieldValueChange: (fieldId: string, value: string) => void;
showAddFieldForm: boolean;
editingFieldId: string | null;
onRequestAddField: () => void;
@ -253,6 +264,8 @@ function FormPane({
{document ? (
<FormRenderer
schema={schema}
values={fieldValues}
onValueChange={onFieldValueChange}
linkCounts={linkCounts}
linkHints={linkHints}
showAddFieldForm={showAddFieldForm}