generated from coulomb/repo-seed
- Blob URL stability, scroll centre, strip-only visual guide - Focus-gated linking, unlink clears overlay, field badge tooltips - Capture layout (viewer centre), grey guide lines, Add field button - Workplans CE-WP-0006 (done) and CE-WP-0007 (T01-T09 done, T10-T12 todo) - Integration tests and viewer-url helpers
148 lines
5.1 KiB
TypeScript
148 lines
5.1 KiB
TypeScript
/**
|
|
* ViewerShell — the centre pane.
|
|
*
|
|
* Hosts the viewer adapter (currently the T02 PDF spike) and shows whatever
|
|
* is active. `work/` consumes only the adapter's public surface
|
|
* (`PdfSpikeViewer`) — it never touches PDF.js or react-pdf-highlighter-plus
|
|
* directly. When the PDF library is swapped (or the spike is replaced),
|
|
* only the adapter module changes; this shell stays the same.
|
|
*
|
|
* The annotation toolbar lived here in earlier iterations; CE-WP-0005-iter4
|
|
* moved it into the evidence sidebar so the capture form appears in the
|
|
* sidebar's document-flow position. The viewer now only renders the PDF
|
|
* and surfaces the activate/click events.
|
|
*/
|
|
|
|
import { useCallback, useMemo } from "react";
|
|
import { PdfSpikeViewer, type StoredAnnotation } from "@anchor/index";
|
|
import { resolvePdfViewerUrl } from "@source/pdf/viewer-url";
|
|
import type { AnnotationId } from "@shared/ids";
|
|
import {
|
|
useActiveDocument,
|
|
useEngine,
|
|
useEngineEventTick,
|
|
useLastActivatedEvidence,
|
|
usePendingSelection,
|
|
usePdfByteStore,
|
|
useScrollToAnnotation,
|
|
} from "./EngineContext";
|
|
import { useDebugFlag } from "./useDebugFlags";
|
|
|
|
export function ViewerShell() {
|
|
const engine = useEngine();
|
|
const byteStore = usePdfByteStore();
|
|
const { document, representation } = useActiveDocument();
|
|
const { set: setPending } = usePendingSelection();
|
|
const { id: scrollToId, version: scrollVersion, scrollTo } = useScrollToAnnotation();
|
|
const [debugTextLayer] = useDebugFlag("textLayer");
|
|
const [hideCanvas] = useDebugFlag("hideCanvas");
|
|
const [hideTextLayer] = useDebugFlag("hideTextLayer");
|
|
const [hideAnnotationLayer] = useDebugFlag("hideAnnotationLayer");
|
|
const [hideXfaLayer] = useDebugFlag("hideXfaLayer");
|
|
const activeEvidenceId = useLastActivatedEvidence();
|
|
|
|
// The viewer needs to re-fetch its highlight list whenever annotations
|
|
// change. The tick is included in the memo deps so the list re-resolves.
|
|
const annotationTick = useEngineEventTick("AnnotationCreated");
|
|
const annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
|
|
|
|
const annotations = useMemo<StoredAnnotation[]>(() => {
|
|
if (!document) return [];
|
|
return engine.annotations.listByDocument(document.id).map((a) => ({
|
|
id: a.id,
|
|
text: a.quote ?? "",
|
|
selectors: a.selectors,
|
|
}));
|
|
}, [document, engine, annotationTick, annotationUpdateTick]);
|
|
|
|
// The annotation id that visually represents the "active" focus —
|
|
// derived from the active evidence's first annotation.
|
|
const activeAnnotationId = useMemo<AnnotationId | null>(() => {
|
|
if (!activeEvidenceId) return null;
|
|
const item = engine.evidence.get(activeEvidenceId);
|
|
return item?.annotationIds[0] ?? null;
|
|
}, [activeEvidenceId, engine]);
|
|
|
|
const fileUrl = useMemo(() => {
|
|
if (!document) return null;
|
|
return resolvePdfViewerUrl(document, byteStore);
|
|
}, [document, byteStore]);
|
|
|
|
const scrollRequestKey =
|
|
scrollToId !== null ? `${scrollToId}:${scrollVersion}` : null;
|
|
|
|
const handleHighlightClicked = useCallback(
|
|
(annotationId: string) => {
|
|
if (!document) return;
|
|
const item = engine.evidence.findByAnnotationId(
|
|
document.id,
|
|
annotationId as AnnotationId,
|
|
);
|
|
if (!item) return;
|
|
engine.evidence.activate(item.id, "citation-card");
|
|
// Re-trigger scroll so a click on the highlight also keeps it
|
|
// centred in the viewport.
|
|
scrollTo(annotationId as AnnotationId);
|
|
},
|
|
[document, engine, scrollTo],
|
|
);
|
|
|
|
if (!document || !representation || !fileUrl) {
|
|
return (
|
|
<main
|
|
style={{
|
|
flex: 1,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: "#666",
|
|
fontFamily: "system-ui, sans-serif",
|
|
}}
|
|
>
|
|
Upload a PDF on the left to begin.
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main
|
|
style={{
|
|
flex: 1,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
|
<PdfSpikeViewer
|
|
// Re-key on document + debug flags only — scroll requests must
|
|
// not remount the viewer (that re-fetches the PDF blob).
|
|
key={[
|
|
document.id,
|
|
debugTextLayer ? "d" : "n",
|
|
hideCanvas ? "hc" : "",
|
|
hideTextLayer ? "ht" : "",
|
|
hideAnnotationLayer ? "ha" : "",
|
|
hideXfaLayer ? "hx" : "",
|
|
].join("#")}
|
|
pdfUrl={fileUrl}
|
|
storedAnnotations={annotations}
|
|
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
|
|
{...(scrollRequestKey ? { scrollRequestKey } : {})}
|
|
activeAnnotationId={activeAnnotationId}
|
|
onHighlightClicked={handleHighlightClicked}
|
|
debugTextLayer={debugTextLayer}
|
|
hideCanvas={hideCanvas}
|
|
hideTextLayer={hideTextLayer}
|
|
hideAnnotationLayer={hideAnnotationLayer}
|
|
hideXfaLayer={hideXfaLayer}
|
|
onSelectionCaptured={(capture, selectors) => {
|
|
setPending({ capture, selectors });
|
|
}}
|
|
/>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|