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:
parent
fc6b91ccb0
commit
305a20d1d1
7 changed files with 384 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
73
src/anchor/scroll-job.test.ts
Normal file
73
src/anchor/scroll-job.test.ts
Normal 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
73
src/anchor/scroll-job.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue