generated from coulomb/repo-seed
CE-WP-0006/0007: Capture view polish, workplans, and UX refinements
- 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
This commit is contained in:
@@ -189,6 +189,11 @@ export interface PdfSpikeViewerProps {
|
||||
onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void;
|
||||
/** Annotation id to scroll to and highlight on mount, if any. */
|
||||
readonly scrollToAnnotationId?: string;
|
||||
/**
|
||||
* Bumps when the same annotation should be re-scrolled (e.g. repeat click).
|
||||
* Format is opaque — typically `${annotationId}:${version}`.
|
||||
*/
|
||||
readonly scrollRequestKey?: string;
|
||||
/**
|
||||
* Annotation id currently focused. The matching highlight gets a
|
||||
* thicker border (see highlight-styles.css). `null`/undefined means
|
||||
@@ -217,6 +222,35 @@ export interface PdfSpikeViewerProps {
|
||||
readonly hideXfaLayer?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudge the PDF scroll container so `highlight` sits vertically centred.
|
||||
* Best-effort: depends on highlight layer DOM being present after scroll.
|
||||
*/
|
||||
function centerHighlightInViewer(
|
||||
utils: PdfHighlighterUtils,
|
||||
highlight: Highlight,
|
||||
attempt = 0,
|
||||
): void {
|
||||
const viewer = utils.getViewer();
|
||||
const container = viewer?.container as HTMLElement | undefined;
|
||||
if (!container) return;
|
||||
const rect = getHighlightClientRects(highlight.id);
|
||||
if (!rect) {
|
||||
if (attempt < 12) {
|
||||
requestAnimationFrame(() =>
|
||||
centerHighlightInViewer(utils, highlight, attempt + 1),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cRect = container.getBoundingClientRect();
|
||||
const highlightCenterY = rect.top + rect.height / 2;
|
||||
const containerCenterY = cRect.top + cRect.height / 2;
|
||||
const delta = highlightCenterY - containerCenterY;
|
||||
if (Math.abs(delta) < 4) return;
|
||||
container.scrollTop += delta;
|
||||
}
|
||||
|
||||
export interface StoredAnnotation {
|
||||
readonly id: string;
|
||||
readonly text: string;
|
||||
@@ -235,6 +269,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
||||
storedAnnotations,
|
||||
onSelectionCaptured,
|
||||
scrollToAnnotationId,
|
||||
scrollRequestKey,
|
||||
activeAnnotationId,
|
||||
onHighlightClicked,
|
||||
debugTextLayer,
|
||||
@@ -257,7 +292,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
||||
.filter((c): c is string => c !== null)
|
||||
.join(" ");
|
||||
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
|
||||
const [didScroll, setDidScroll] = useState<string | null>(null);
|
||||
const lastScrollKeyRef = useRef<string | null>(null);
|
||||
|
||||
const highlights = useMemo<Highlight[]>(() => {
|
||||
const out: Highlight[] = [];
|
||||
@@ -283,22 +318,33 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
||||
return out;
|
||||
}, [storedAnnotations, debugTextLayer]);
|
||||
|
||||
const highlightsRef = useRef(highlights);
|
||||
highlightsRef.current = highlights;
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollToAnnotationId || didScroll === scrollToAnnotationId) return;
|
||||
const requestKey = scrollRequestKey ?? scrollToAnnotationId ?? null;
|
||||
if (!requestKey || !scrollToAnnotationId) return;
|
||||
if (lastScrollKeyRef.current === requestKey) return;
|
||||
const utils = utilsRef.current;
|
||||
const target = highlights.find((h) => h.id === scrollToAnnotationId);
|
||||
const target = highlightsRef.current.find((h) => h.id === scrollToAnnotationId);
|
||||
if (debugTextLayer) {
|
||||
console.log("[ce] scrollToAnnotation requested", {
|
||||
id: scrollToAnnotationId,
|
||||
requestKey,
|
||||
utilsAvailable: !!utils,
|
||||
targetFound: !!target,
|
||||
knownIds: highlights.map((h) => h.id),
|
||||
knownIds: highlightsRef.current.map((h) => h.id),
|
||||
});
|
||||
}
|
||||
if (!utils || !target) return;
|
||||
utils.scrollToHighlight(target);
|
||||
setDidScroll(scrollToAnnotationId);
|
||||
}, [scrollToAnnotationId, highlights, didScroll, debugTextLayer]);
|
||||
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 (
|
||||
<div
|
||||
|
||||
@@ -288,7 +288,7 @@ function ActiveTopBar({
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ id: "review" as const, label: "Review" },
|
||||
{ id: "forms" as const, label: "Forms" },
|
||||
{ id: "forms" as const, label: "Capture" },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
/**
|
||||
* FormsApp — the evidence-backed form mode for CE-WP-0003.
|
||||
* FormsApp (Capture mode) — evidence-backed form layout (CE-WP-0003/0006/0007).
|
||||
*
|
||||
* Layout:
|
||||
* Layout (CE-WP-0007):
|
||||
*
|
||||
* ┌────────────┬─────────────────┬─────────────┐
|
||||
* │ Collection │ FormRenderer │ ViewerShell │
|
||||
* │ │ (left) │ (right) │
|
||||
* │ Collection │ ViewerShell │ FormPane │
|
||||
* ├────────────┴─────────────────┴─────────────┤
|
||||
* │ EvidenceStrip (bottom) │
|
||||
* │ EvidenceStrip (bottom) │
|
||||
* └────────────────────────────────────────────┘
|
||||
*
|
||||
* Linking interaction (T05):
|
||||
* 1. User clicks an evidence card in the strip → it becomes "selected
|
||||
* for linking" (highlighted; banner appears in the form pane).
|
||||
* 2. User then clicks a form field. The field's `FormFieldActivated`
|
||||
* event triggers `bindings.linkEvidenceToTarget(selected, field)`.
|
||||
* 3. The selected-for-linking state clears; the field's link count
|
||||
* chip increments.
|
||||
*
|
||||
* Active-evidence cycling (T06) and the visual guide overlay (T07) build
|
||||
* on this composition without changing the link interaction.
|
||||
* Linking: field must have focus; clicking evidence links directly.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { EvidenceItem } from "@shared/evidence";
|
||||
import type { AnnotationId, EvidenceItemId } from "@shared/ids";
|
||||
import type { EvidenceLink } from "@shared/evidence-link";
|
||||
import type { EvidenceItemId } from "@shared/ids";
|
||||
|
||||
import { Overlay, useActiveState, useBinder } from "@binder/index";
|
||||
import {
|
||||
Overlay,
|
||||
useActiveState,
|
||||
useBinder,
|
||||
useRegisterRect,
|
||||
} from "@binder/index";
|
||||
import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer";
|
||||
import {
|
||||
CollectionList,
|
||||
ViewerShell,
|
||||
@@ -39,19 +36,54 @@ import {
|
||||
|
||||
import { FormRenderer } from "@binder/FormRenderer";
|
||||
|
||||
import { ActiveEvidenceChips, type ActiveEvidenceChipsItem } from "./ActiveEvidenceChips";
|
||||
import { DEMO_SCHEMA } from "./demo-schema";
|
||||
import { HighlightRectBridge } from "./HighlightRectBridge";
|
||||
|
||||
export type EvidenceStripFilter = "all" | "attached";
|
||||
|
||||
const STRIP_FILTER_EVENT = "citation-evidence:strip-filter";
|
||||
|
||||
function publishStripFilter(mode: EvidenceStripFilter) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(STRIP_FILTER_EVENT, { detail: mode }));
|
||||
}
|
||||
|
||||
function quotePreview(text: string, max = 80): string {
|
||||
const t = text.trim();
|
||||
return t.length > max ? `${t.slice(0, max)}…` : t;
|
||||
}
|
||||
|
||||
export function FormsApp() {
|
||||
const [schema, setSchema] = useState<FormSchema>(() => ({
|
||||
...DEMO_SCHEMA,
|
||||
fields: [...DEMO_SCHEMA.fields],
|
||||
}));
|
||||
|
||||
const fieldLabels = useMemo(
|
||||
() => new Map(schema.fields.map((f) => [f.id, f.label] as const)),
|
||||
[schema],
|
||||
);
|
||||
|
||||
const addField = useCallback(() => {
|
||||
setSchema((prev) => {
|
||||
const n = prev.fields.length + 1;
|
||||
const field: FormFieldSchema = {
|
||||
type: "text",
|
||||
id: `field_${n}`,
|
||||
label: `New field ${n}`,
|
||||
};
|
||||
return { ...prev, fields: [...prev.fields, field] };
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
|
||||
<CollectionList />
|
||||
<FormPane />
|
||||
<ViewerShell />
|
||||
<FormPane schema={schema} onAddField={addField} />
|
||||
</div>
|
||||
<EvidenceStrip />
|
||||
<EvidenceStrip fieldLabels={fieldLabels} />
|
||||
<ScrollBridge />
|
||||
<HighlightRectBridge />
|
||||
<Overlay />
|
||||
@@ -59,12 +91,6 @@ export function FormsApp() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge: the binder's active-state machine doesn't know how to scroll
|
||||
* the viewer; the work-level `useScrollToAnnotation` does. This subscriber
|
||||
* reads `activeAnnotationId` from the binder and calls `scrollTo` whenever
|
||||
* it changes. Lives in app/ because that's where both subsystems meet.
|
||||
*/
|
||||
function ScrollBridge() {
|
||||
const { state } = useActiveState();
|
||||
const { scrollTo } = useScrollToAnnotation();
|
||||
@@ -76,88 +102,114 @@ function ScrollBridge() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function FormPane() {
|
||||
function FormPane({
|
||||
schema,
|
||||
onAddField,
|
||||
}: {
|
||||
schema: FormSchema;
|
||||
onAddField: () => void;
|
||||
}) {
|
||||
const { document } = useActiveDocument();
|
||||
const { bindings } = useBinder();
|
||||
const engine = useEngine();
|
||||
const linkTick = useEngineEventTick("EvidenceLinkCreated");
|
||||
const { state: activeState } = useActiveState();
|
||||
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
|
||||
const { state: activeState, setActiveEvidence } = useActiveState();
|
||||
|
||||
const [selectedForLinking, setSelectedForLinking] = useState<EvidenceItemId | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
return engine.bus.on("FormFieldActivated", () => {
|
||||
publishStripFilter("attached");
|
||||
});
|
||||
}, [engine]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = activeState.activeTarget;
|
||||
if (!target || activeState.activeEvidenceItemId) return;
|
||||
const links = bindings.listEvidenceForTarget(target);
|
||||
if (links.length === 0) return;
|
||||
const item = engine.evidence.get(links[0]!.evidenceItemId);
|
||||
if (!item) return;
|
||||
setActiveEvidence(item.id, item.annotationIds[0] ?? null);
|
||||
}, [
|
||||
activeState.activeTarget,
|
||||
activeState.activeEvidenceItemId,
|
||||
bindings,
|
||||
engine,
|
||||
linkTick,
|
||||
unlinkTick,
|
||||
setActiveEvidence,
|
||||
]);
|
||||
|
||||
// Compute per-field link counts. Re-derives on link create.
|
||||
const linkCounts = useMemo<Record<string, number>>(() => {
|
||||
const out: Record<string, number> = {};
|
||||
for (const field of DEMO_SCHEMA.fields) {
|
||||
for (const field of schema.fields) {
|
||||
out[field.id] = bindings.listEvidenceForTarget({
|
||||
targetType: "form-field",
|
||||
targetId: field.id,
|
||||
}).length;
|
||||
}
|
||||
void linkTick;
|
||||
void unlinkTick;
|
||||
return out;
|
||||
}, [bindings, linkTick]);
|
||||
}, [schema.fields, bindings, linkTick, unlinkTick]);
|
||||
|
||||
// Compute chip items for the currently-active target.
|
||||
const activeChipItems = useMemo<readonly ActiveEvidenceChipsItem[]>(() => {
|
||||
if (!activeState.activeTarget) return [];
|
||||
const links = bindings.listEvidenceForTarget(activeState.activeTarget);
|
||||
return links
|
||||
.map((link): ActiveEvidenceChipsItem | null => {
|
||||
const item = engine.evidence.get(link.evidenceItemId);
|
||||
if (!item) return null;
|
||||
const annotationId: AnnotationId | null = item.annotationIds[0] ?? null;
|
||||
const annotation = annotationId ? engine.annotations.get(annotationId) : null;
|
||||
return {
|
||||
evidenceItemId: link.evidenceItemId,
|
||||
annotationId,
|
||||
quote: annotation?.quote ?? "(no quote)",
|
||||
...(item.commentary ? { commentary: item.commentary } : {}),
|
||||
};
|
||||
})
|
||||
.filter((c): c is ActiveEvidenceChipsItem => c !== null);
|
||||
// linkTick is included so newly created links populate the chips
|
||||
// without an explicit refresh.
|
||||
}, [activeState.activeTarget, bindings, engine, linkTick]);
|
||||
|
||||
// Listen for FormFieldActivated and, if an evidence is staged, create
|
||||
// the link. The state machine reduction (focus-target) happens in
|
||||
// parallel via ActiveStateProvider's own handler.
|
||||
useEffect(() => {
|
||||
return engine.bus.on("FormFieldActivated", (event) => {
|
||||
if (!selectedForLinking) return;
|
||||
bindings.linkEvidenceToTarget({
|
||||
evidenceItemId: selectedForLinking,
|
||||
target: event.target,
|
||||
const linkHints = useMemo<Record<string, string>>(() => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const field of schema.fields) {
|
||||
const links = bindings.listEvidenceForTarget({
|
||||
targetType: "form-field",
|
||||
targetId: field.id,
|
||||
});
|
||||
setSelectedForLinking(null);
|
||||
});
|
||||
}, [engine, bindings, selectedForLinking]);
|
||||
if (links.length === 0) continue;
|
||||
const item = engine.evidence.get(links[0]!.evidenceItemId);
|
||||
const ann = item?.annotationIds[0]
|
||||
? engine.annotations.get(item.annotationIds[0])
|
||||
: null;
|
||||
const quote = ann?.quote ?? item?.commentary ?? "";
|
||||
if (quote) out[field.id] = quotePreview(quote);
|
||||
}
|
||||
void linkTick;
|
||||
void unlinkTick;
|
||||
return out;
|
||||
}, [schema.fields, bindings, engine, linkTick, unlinkTick]);
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
flex: "1 1 0",
|
||||
flex: "0 0 320px",
|
||||
minWidth: 320,
|
||||
borderRight: "1px solid #ddd",
|
||||
borderLeft: "1px solid #ddd",
|
||||
overflow: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<SelectedBanner
|
||||
selectedForLinking={selectedForLinking}
|
||||
onClear={() => setSelectedForLinking(null)}
|
||||
/>
|
||||
{document ? (
|
||||
<>
|
||||
<FormRenderer schema={DEMO_SCHEMA} linkCounts={linkCounts} />
|
||||
<ActiveEvidenceChips items={activeChipItems} />
|
||||
</>
|
||||
<FormRenderer
|
||||
schema={schema}
|
||||
linkCounts={linkCounts}
|
||||
linkHints={linkHints}
|
||||
headerAction={
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-field-button"
|
||||
onClick={onAddField}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "4px 10px",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 4,
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Add field
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyHint />
|
||||
)}
|
||||
<SelectionContext setSelected={setSelectedForLinking} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -165,97 +217,114 @@ function FormPane() {
|
||||
function EmptyHint() {
|
||||
return (
|
||||
<p style={{ padding: 12, color: "#666", fontSize: 13, fontFamily: "system-ui, sans-serif" }}>
|
||||
Pick a fixture from the collection list to start binding evidence.
|
||||
Pick a document from the collection to start capturing evidence links.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedBanner({
|
||||
selectedForLinking,
|
||||
onClear,
|
||||
function EvidenceStrip({
|
||||
fieldLabels,
|
||||
}: {
|
||||
selectedForLinking: EvidenceItemId | null;
|
||||
onClear: () => void;
|
||||
fieldLabels: ReadonlyMap<string, string>;
|
||||
}) {
|
||||
if (!selectedForLinking) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
padding: 8,
|
||||
background: "#fff4d6",
|
||||
borderBottom: "1px solid #f0c040",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
fontSize: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Evidence staged for linking. Click a form field to link it, or{" "}
|
||||
</span>
|
||||
<button onClick={onClear} style={{ fontSize: 12, padding: "2px 8px" }}>
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the strip's "stage this evidence" callback to the FormPane's
|
||||
* local state. The strip lives in a sibling DOM subtree; rather than
|
||||
* lifting `selectedForLinking` all the way up to FormsApp, we publish a
|
||||
* setter into a module-scoped event target.
|
||||
*
|
||||
* Simpler than another context for one local handshake.
|
||||
*/
|
||||
const STAGED_EVENT = "citation-evidence:staged-for-linking";
|
||||
|
||||
function SelectionContext({
|
||||
setSelected,
|
||||
}: {
|
||||
setSelected: (id: EvidenceItemId | null) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<EvidenceItemId | null>).detail;
|
||||
setSelected(detail);
|
||||
};
|
||||
window.addEventListener(STAGED_EVENT, handler);
|
||||
return () => window.removeEventListener(STAGED_EVENT, handler);
|
||||
}, [setSelected]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function publishStagedForLinking(id: EvidenceItemId | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(STAGED_EVENT, { detail: id }));
|
||||
}
|
||||
|
||||
function EvidenceStrip() {
|
||||
const engine = useEngine();
|
||||
const { bindings } = useBinder();
|
||||
const { document } = useActiveDocument();
|
||||
const createTick = useEngineEventTick("EvidenceItemCreated");
|
||||
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
||||
const linkTick = useEngineEventTick("EvidenceLinkCreated");
|
||||
const { state: activeState } = useActiveState();
|
||||
const [stagedId, setStagedId] = useState<EvidenceItemId | null>(null);
|
||||
const unlinkTick = useEngineEventTick("EvidenceLinkRemoved");
|
||||
const { state: activeState, setActiveEvidence, clearActiveEvidence } =
|
||||
useActiveState();
|
||||
|
||||
const items = useMemo<readonly EvidenceItem[]>(() => {
|
||||
const [userFilter, setUserFilter] = useState<EvidenceStripFilter>("all");
|
||||
const [sessionFilter, setSessionFilter] = useState<EvidenceStripFilter | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const effectiveFilter = sessionFilter ?? userFilter;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
setSessionFilter((e as CustomEvent<EvidenceStripFilter>).detail);
|
||||
};
|
||||
window.addEventListener(STRIP_FILTER_EVENT, handler);
|
||||
return () => window.removeEventListener(STRIP_FILTER_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeState.activeTarget) {
|
||||
setSessionFilter(null);
|
||||
}
|
||||
}, [activeState.activeTarget]);
|
||||
|
||||
const allItems = useMemo<readonly EvidenceItem[]>(() => {
|
||||
if (!document) return [];
|
||||
void createTick;
|
||||
void updateTick;
|
||||
void linkTick;
|
||||
void unlinkTick;
|
||||
return engine.evidence.listByDocument(document.id);
|
||||
}, [document, engine, createTick, updateTick, linkTick]);
|
||||
}, [document, engine, createTick, updateTick, linkTick, unlinkTick]);
|
||||
|
||||
const handleStage = (id: EvidenceItemId) => {
|
||||
const next = stagedId === id ? null : id;
|
||||
setStagedId(next);
|
||||
publishStagedForLinking(next);
|
||||
};
|
||||
const items = useMemo(() => {
|
||||
if (effectiveFilter !== "attached" || !activeState.activeTarget) {
|
||||
return allItems;
|
||||
}
|
||||
const links = bindings.listEvidenceForTarget(activeState.activeTarget);
|
||||
const ids = new Set(links.map((l) => l.evidenceItemId));
|
||||
const attached = allItems.filter((item) => ids.has(item.id));
|
||||
return attached.length > 0 ? attached : allItems;
|
||||
}, [
|
||||
allItems,
|
||||
effectiveFilter,
|
||||
activeState.activeTarget,
|
||||
bindings,
|
||||
linkTick,
|
||||
unlinkTick,
|
||||
]);
|
||||
|
||||
const tryLink = useCallback(
|
||||
(evidenceItemId: EvidenceItemId, fieldId: string): boolean => {
|
||||
const existing = bindings
|
||||
.listEvidenceForTarget({ targetType: "form-field", targetId: fieldId })
|
||||
.some((l) => l.evidenceItemId === evidenceItemId);
|
||||
if (existing) return false;
|
||||
bindings.linkEvidenceToTarget({
|
||||
evidenceItemId,
|
||||
target: { targetType: "form-field", targetId: fieldId },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[bindings],
|
||||
);
|
||||
|
||||
const handleCardClick = useCallback(
|
||||
(item: EvidenceItem) => {
|
||||
const annId = item.annotationIds[0] ?? null;
|
||||
setActiveEvidence(item.id, annId);
|
||||
|
||||
const target = activeState.activeTarget;
|
||||
if (target?.targetType === "form-field") {
|
||||
tryLink(item.id, target.targetId);
|
||||
}
|
||||
},
|
||||
[activeState.activeTarget, setActiveEvidence, tryLink],
|
||||
);
|
||||
|
||||
const handleUnlink = useCallback(
|
||||
(link: EvidenceLink) => {
|
||||
bindings.unlinkEvidence(link.id);
|
||||
if (
|
||||
activeState.activeEvidenceItemId === link.evidenceItemId &&
|
||||
activeState.activeTarget?.targetType === link.targetType &&
|
||||
activeState.activeTarget?.targetId === link.targetId
|
||||
) {
|
||||
clearActiveEvidence();
|
||||
}
|
||||
},
|
||||
[bindings, activeState, clearActiveEvidence],
|
||||
);
|
||||
|
||||
if (!document) return null;
|
||||
|
||||
@@ -267,56 +336,185 @@ function EvidenceStrip() {
|
||||
background: "#fafafa",
|
||||
padding: 8,
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
overflowX: "auto",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
flex: "0 0 auto",
|
||||
minHeight: 100,
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
{items.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}>
|
||||
No evidence yet. Switch to Review mode to capture a passage.
|
||||
</p>
|
||||
)}
|
||||
{items.map((item) => {
|
||||
const firstAnn = item.annotationIds[0]
|
||||
? engine.annotations.get(item.annotationIds[0])
|
||||
: null;
|
||||
const quote = firstAnn?.quote ?? "(no quote)";
|
||||
const isStaged = stagedId === item.id;
|
||||
const isActive = activeState.activeEvidenceItemId === item.id;
|
||||
return (
|
||||
<button
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 11 }}>
|
||||
<span style={{ color: "#666" }}>Show:</span>
|
||||
<FilterToggle
|
||||
label="All"
|
||||
active={effectiveFilter === "all"}
|
||||
onClick={() => {
|
||||
setUserFilter("all");
|
||||
setSessionFilter(null);
|
||||
}}
|
||||
/>
|
||||
<FilterToggle
|
||||
label="Linked to field"
|
||||
active={effectiveFilter === "attached"}
|
||||
onClick={() => {
|
||||
setUserFilter("attached");
|
||||
setSessionFilter(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, overflowX: "auto" }}>
|
||||
{items.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: "#888", margin: 0, alignSelf: "center" }}>
|
||||
{effectiveFilter === "attached"
|
||||
? "No evidence linked to the active field."
|
||||
: "No evidence yet. Switch to Review mode to capture a passage."}
|
||||
</p>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<EvidenceStripCard
|
||||
key={item.id}
|
||||
onClick={() => handleStage(item.id)}
|
||||
data-staged={isStaged ? "true" : "false"}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
style={{
|
||||
minWidth: 220,
|
||||
maxWidth: 280,
|
||||
textAlign: "left",
|
||||
fontSize: 12,
|
||||
padding: 8,
|
||||
border: isActive
|
||||
? "2px solid #0050b3"
|
||||
: isStaged
|
||||
? "2px solid #f0a000"
|
||||
: "1px solid #ccc",
|
||||
background: isActive ? "#e8f0ff" : isStaged ? "#fff4d6" : "white",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
|
||||
“{quote.slice(0, 100)}
|
||||
{quote.length > 100 ? "…" : ""}”
|
||||
</div>
|
||||
{item.commentary && (
|
||||
<div style={{ color: "#333" }}>{item.commentary}</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
item={item}
|
||||
isActive={activeState.activeEvidenceItemId === item.id}
|
||||
links={bindings.listTargetsForEvidence(item.id)}
|
||||
fieldLabels={fieldLabels}
|
||||
onClick={() => handleCardClick(item)}
|
||||
onUnlink={handleUnlink}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterToggle({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
border: active ? "1px solid #0050b3" : "1px solid #ccc",
|
||||
background: active ? "#e8f0ff" : "white",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceStripCard({
|
||||
item,
|
||||
isActive,
|
||||
links,
|
||||
fieldLabels,
|
||||
onClick,
|
||||
onUnlink,
|
||||
}: {
|
||||
item: EvidenceItem;
|
||||
isActive: boolean;
|
||||
links: readonly EvidenceLink[];
|
||||
fieldLabels: ReadonlyMap<string, string>;
|
||||
onClick: () => void;
|
||||
onUnlink: (link: EvidenceLink) => void;
|
||||
}) {
|
||||
const engine = useEngine();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useRegisterRect("evidence-card", item.id, ref);
|
||||
|
||||
const firstAnn = item.annotationIds[0]
|
||||
? engine.annotations.get(item.annotationIds[0])
|
||||
: null;
|
||||
const quote = firstAnn?.quote ?? "(no quote)";
|
||||
|
||||
const formLinks = links.filter((l) => l.targetType === "form-field");
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
position: "relative",
|
||||
minWidth: 220,
|
||||
maxWidth: 280,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{formLinks.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{formLinks.map((link) => {
|
||||
const label = fieldLabels.get(link.targetId) ?? link.targetId;
|
||||
return (
|
||||
<button
|
||||
key={link.id}
|
||||
type="button"
|
||||
title={`Linked to: ${label}. Click to remove link.`}
|
||||
aria-label={`Remove link to ${label}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnlink(link);
|
||||
}}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
lineHeight: 1,
|
||||
padding: "2px 4px",
|
||||
border: "1px solid #88a",
|
||||
borderRadius: 3,
|
||||
background: "#eef",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
fontSize: 12,
|
||||
padding: 8,
|
||||
border: isActive ? "2px solid #0050b3" : "1px solid #ccc",
|
||||
background: isActive ? "#e8f0ff" : "white",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontStyle: "italic",
|
||||
marginBottom: 4,
|
||||
paddingRight: formLinks.length ? 24 : 0,
|
||||
}}
|
||||
>
|
||||
“{quote.slice(0, 100)}
|
||||
{quote.length > 100 ? "…" : ""}”
|
||||
</div>
|
||||
{item.commentary && <div style={{ color: "#333" }}>{item.commentary}</div>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* design system can land later without changing the registry contract.
|
||||
*/
|
||||
|
||||
import { useRef, type ChangeEvent } from "react";
|
||||
import { useRef, type ChangeEvent, type ReactNode } from "react";
|
||||
|
||||
import type { EvidenceTarget } from "@shared/evidence-link";
|
||||
|
||||
@@ -49,12 +49,16 @@ export interface FormRendererProps {
|
||||
* label so the user can tell which fields already have evidence.
|
||||
*/
|
||||
readonly linkCounts?: Readonly<Record<string, number>>;
|
||||
/** Hover preview for the evidence-count chip (first linked quote). */
|
||||
readonly linkHints?: Readonly<Record<string, string>>;
|
||||
readonly headerAction?: ReactNode;
|
||||
}
|
||||
|
||||
function FieldRow({
|
||||
field,
|
||||
value,
|
||||
linkCount,
|
||||
linkHint,
|
||||
isActive,
|
||||
onChange,
|
||||
onFocus,
|
||||
@@ -62,6 +66,7 @@ function FieldRow({
|
||||
field: FormFieldSchema;
|
||||
value: string;
|
||||
linkCount: number;
|
||||
linkHint?: string;
|
||||
isActive: boolean;
|
||||
onChange: (next: string) => void;
|
||||
onFocus: () => void;
|
||||
@@ -100,6 +105,7 @@ function FieldRow({
|
||||
{linkCount > 0 ? (
|
||||
<span
|
||||
data-testid={`field-${field.id}-chip`}
|
||||
title={linkHint}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
padding: "1px 6px",
|
||||
@@ -128,6 +134,8 @@ export function FormRenderer({
|
||||
values,
|
||||
onValueChange,
|
||||
linkCounts,
|
||||
linkHints,
|
||||
headerAction,
|
||||
}: FormRendererProps) {
|
||||
const { state, focusTarget } = useActiveState();
|
||||
|
||||
@@ -142,15 +150,27 @@ export function FormRenderer({
|
||||
style={{ padding: 12 }}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<h2 style={{ fontSize: 14, marginTop: 0, fontFamily: "system-ui, sans-serif" }}>
|
||||
{schema.title}
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 14, margin: 0, fontFamily: "system-ui, sans-serif" }}>
|
||||
{schema.title}
|
||||
</h2>
|
||||
{headerAction}
|
||||
</div>
|
||||
{schema.fields.map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={values?.[field.id] ?? ""}
|
||||
linkCount={linkCounts?.[field.id] ?? 0}
|
||||
linkHint={linkHints?.[field.id]}
|
||||
isActive={isFieldActive(state, field.id)}
|
||||
onChange={(next) => onValueChange?.(field.id, next)}
|
||||
onFocus={() => handleFocus(field.id)}
|
||||
|
||||
@@ -76,7 +76,11 @@ export function createBindingService(
|
||||
return stored;
|
||||
},
|
||||
unlinkEvidence(id) {
|
||||
return links.delete(id);
|
||||
const removed = links.delete(id);
|
||||
if (removed) {
|
||||
bus.emit({ type: "EvidenceLinkRemoved", linkId: id });
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
updateLink(id, input) {
|
||||
const existing = links.get(id);
|
||||
|
||||
@@ -54,6 +54,7 @@ type Action =
|
||||
evidenceItemId: EvidenceItemId;
|
||||
annotationId: AnnotationId | null;
|
||||
}
|
||||
| { type: "clear-active-evidence" }
|
||||
| { type: "clear" };
|
||||
|
||||
function reducer(state: ActiveState, action: Action): ActiveState {
|
||||
@@ -78,6 +79,12 @@ function reducer(state: ActiveState, action: Action): ActiveState {
|
||||
activeEvidenceItemId: action.evidenceItemId,
|
||||
activeAnnotationId: action.annotationId,
|
||||
};
|
||||
case "clear-active-evidence":
|
||||
return {
|
||||
activeTarget: state.activeTarget,
|
||||
activeEvidenceItemId: null,
|
||||
activeAnnotationId: null,
|
||||
};
|
||||
case "clear":
|
||||
return EMPTY_ACTIVE_STATE;
|
||||
}
|
||||
@@ -90,6 +97,7 @@ export interface ActiveStateApi {
|
||||
evidenceItemId: EvidenceItemId,
|
||||
annotationId?: AnnotationId | null,
|
||||
): void;
|
||||
clearActiveEvidence(): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
@@ -144,13 +152,17 @@ export function ActiveStateProvider(props: ActiveStateProviderProps) {
|
||||
[props.bus],
|
||||
);
|
||||
|
||||
const clearActiveEvidence = useCallback(() => {
|
||||
dispatch({ type: "clear-active-evidence" });
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
dispatch({ type: "clear" });
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ActiveStateApi>(
|
||||
() => ({ state, focusTarget, setActiveEvidence, clear }),
|
||||
[state, focusTarget, setActiveEvidence, clear],
|
||||
() => ({ state, focusTarget, setActiveEvidence, clearActiveEvidence, clear }),
|
||||
[state, focusTarget, setActiveEvidence, clearActiveEvidence, clear],
|
||||
);
|
||||
|
||||
return createElement(ActiveStateContext.Provider, { value }, props.children);
|
||||
|
||||
@@ -33,6 +33,14 @@ function rectCenter(rect: DOMRect): { x: number; y: number } {
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
}
|
||||
|
||||
function rectBottomCenter(rect: DOMRect): { x: number; y: number } {
|
||||
return { x: rect.left + rect.width / 2, y: rect.bottom };
|
||||
}
|
||||
|
||||
function rectTopCenter(rect: DOMRect): { x: number; y: number } {
|
||||
return { x: rect.left + rect.width / 2, y: rect.top };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a quadratic bezier from `a` to `b` whose control point bulges
|
||||
* horizontally between them. The horizontal-bulge style is right for a
|
||||
@@ -55,8 +63,8 @@ export interface OverlayProps {
|
||||
}
|
||||
|
||||
export function Overlay({
|
||||
strokeColor = "#0050b3",
|
||||
strokeWidth = 2,
|
||||
strokeColor = "#999",
|
||||
strokeWidth = 1,
|
||||
className,
|
||||
}: OverlayProps = {}) {
|
||||
const { state } = useActiveState();
|
||||
@@ -72,10 +80,10 @@ export function Overlay({
|
||||
: null;
|
||||
const out: string[] = [];
|
||||
if (fieldRect && cardRect) {
|
||||
out.push(bezierPath(rectCenter(fieldRect), rectCenter(cardRect)));
|
||||
out.push(bezierPath(rectBottomCenter(fieldRect), rectTopCenter(cardRect)));
|
||||
}
|
||||
if (cardRect && highlightRect) {
|
||||
out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect)));
|
||||
out.push(bezierPath(rectTopCenter(cardRect), rectCenter(highlightRect)));
|
||||
}
|
||||
void version; // memo invalidator
|
||||
return out;
|
||||
|
||||
@@ -99,6 +99,11 @@ export interface EvidenceLinkUpdatedEvent {
|
||||
readonly link: EvidenceLink;
|
||||
}
|
||||
|
||||
export interface EvidenceLinkRemovedEvent {
|
||||
readonly type: "EvidenceLinkRemoved";
|
||||
readonly linkId: EvidenceLinkId;
|
||||
}
|
||||
|
||||
export interface FormFieldActivatedEvent {
|
||||
readonly type: "FormFieldActivated";
|
||||
readonly target: EvidenceTarget;
|
||||
@@ -142,6 +147,7 @@ export type EngineEvent =
|
||||
| EvidenceItemActivatedEvent
|
||||
| EvidenceLinkCreatedEvent
|
||||
| EvidenceLinkUpdatedEvent
|
||||
| EvidenceLinkRemovedEvent
|
||||
| FormFieldActivatedEvent
|
||||
| SessionCreatedEvent
|
||||
| SessionRenamedEvent
|
||||
|
||||
@@ -35,6 +35,7 @@ export {
|
||||
documentIdsIn,
|
||||
restoreFromStorage,
|
||||
restoreSnapshot,
|
||||
sanitizeDocumentForPersistence,
|
||||
type EngineSnapshot,
|
||||
type PersisterOptions,
|
||||
} from "./persistence";
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createEngine,
|
||||
restoreFromStorage,
|
||||
restoreSnapshot,
|
||||
sanitizeDocumentForPersistence,
|
||||
type Engine,
|
||||
type EngineEvent,
|
||||
type EngineSnapshot,
|
||||
@@ -170,6 +171,31 @@ describe("restoreFromStorage", () => {
|
||||
expect(dst.documents.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("strips blob: URIs from persisted documents", () => {
|
||||
const engine = createEngine();
|
||||
const docId = "doc_blob" as DocumentId;
|
||||
engine.documents.register({
|
||||
document: {
|
||||
id: docId,
|
||||
mediaType: "application/pdf",
|
||||
title: "upload.pdf",
|
||||
uri: "blob:http://localhost/dead",
|
||||
createdAt: "2026-06-07T00:00:00.000Z",
|
||||
updatedAt: "2026-06-07T00:00:00.000Z",
|
||||
},
|
||||
representation: fakeDocAndRep("blob").representation,
|
||||
});
|
||||
const snap = captureSnapshot(engine);
|
||||
expect(snap.documents[0]?.uri).toBeUndefined();
|
||||
expect(sanitizeDocumentForPersistence({
|
||||
id: docId,
|
||||
mediaType: "application/pdf",
|
||||
uri: "blob:x",
|
||||
createdAt: "x",
|
||||
updatedAt: "x",
|
||||
}).uri).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores malformed JSON without throwing", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const storage = memoryStorage();
|
||||
|
||||
@@ -29,8 +29,15 @@ export interface EngineSnapshot {
|
||||
readonly evidenceItems: readonly EvidenceItem[];
|
||||
}
|
||||
|
||||
/** Strip ephemeral blob URLs — they cannot survive reload without bytes. */
|
||||
export function sanitizeDocumentForPersistence(document: Document): Document {
|
||||
if (!document.uri || !document.uri.startsWith("blob:")) return document;
|
||||
const { uri: _uri, ...rest } = document;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function captureSnapshot(engine: Engine): EngineSnapshot {
|
||||
const documents = engine.documents.list();
|
||||
const documents = engine.documents.list().map(sanitizeDocumentForPersistence);
|
||||
// Gather representations per known document.
|
||||
const representations: DocumentRepresentation[] = [];
|
||||
const annotations: Annotation[] = [];
|
||||
|
||||
@@ -16,3 +16,8 @@ export {
|
||||
ingestPdfFromFile,
|
||||
type IngestPdfFromFileOptions,
|
||||
} from "./pdf/upload";
|
||||
export {
|
||||
isEphemeralBlobUri,
|
||||
resolvePdfViewerUrl,
|
||||
documentHasUploadedBytes,
|
||||
} from "./pdf/viewer-url";
|
||||
|
||||
@@ -49,7 +49,7 @@ beforeAll(async () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("ingestPdf — fixture corpus", () => {
|
||||
describe("ingestPdf — fixture corpus", { timeout: 30_000 }, () => {
|
||||
for (const fixture of FIXTURES) {
|
||||
describe(fixture.id, () => {
|
||||
const path = resolve(FIXTURE_DIR, fixture.filename);
|
||||
|
||||
54
src/source/pdf/viewer-url.test.ts
Normal file
54
src/source/pdf/viewer-url.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Document } from "@shared/document";
|
||||
import type { DocumentId } from "@shared/ids";
|
||||
|
||||
import { createPdfByteStore } from "./byte-store";
|
||||
import { isEphemeralBlobUri, resolvePdfViewerUrl } from "./viewer-url";
|
||||
|
||||
function doc(overrides: Partial<Document> = {}): Document {
|
||||
return {
|
||||
id: "doc_1" as DocumentId,
|
||||
mediaType: "application/pdf",
|
||||
title: "sample.pdf",
|
||||
createdAt: "2026-06-07T00:00:00.000Z",
|
||||
updatedAt: "2026-06-07T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolvePdfViewerUrl", () => {
|
||||
it("prefers live byte-store blob over persisted document.uri", () => {
|
||||
const store = createPdfByteStore({
|
||||
createObjectURL: () => "blob:live",
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
store.put("doc_1" as DocumentId, new Uint8Array([1]));
|
||||
const url = resolvePdfViewerUrl(
|
||||
doc({ uri: "blob:stale-from-localStorage" }),
|
||||
store,
|
||||
);
|
||||
expect(url).toBe("blob:live");
|
||||
});
|
||||
|
||||
it("uses non-blob document.uri when byte store is empty", () => {
|
||||
const store = createPdfByteStore();
|
||||
expect(resolvePdfViewerUrl(doc({ uri: "file:///x.pdf" }), store)).toBe(
|
||||
"file:///x.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to fixture path when only a stale blob uri is stored", () => {
|
||||
const store = createPdfByteStore();
|
||||
expect(
|
||||
resolvePdfViewerUrl(doc({ uri: "blob:revoked", title: "a.pdf" }), store),
|
||||
).toBe("/fixtures/pdfs/a.pdf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEphemeralBlobUri", () => {
|
||||
it("detects blob URLs", () => {
|
||||
expect(isEphemeralBlobUri("blob:http://localhost/x")).toBe(true);
|
||||
expect(isEphemeralBlobUri("/fixtures/pdfs/x.pdf")).toBe(false);
|
||||
});
|
||||
});
|
||||
43
src/source/pdf/viewer-url.ts
Normal file
43
src/source/pdf/viewer-url.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Resolve the URL the PDF viewer should load for a document.
|
||||
*
|
||||
* Uploaded PDFs are served via ephemeral `blob:` URLs owned by
|
||||
* `PdfByteStore`. Those URLs must not be persisted (see persistence
|
||||
* sanitization) and must be re-resolved from live bytes on every render.
|
||||
*/
|
||||
|
||||
import type { Document } from "@shared/document";
|
||||
import type { DocumentId } from "@shared/ids";
|
||||
|
||||
import type { PdfByteStore } from "./byte-store";
|
||||
|
||||
export function isEphemeralBlobUri(uri: string | undefined): boolean {
|
||||
return typeof uri === "string" && uri.startsWith("blob:");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer the byte store's live blob URL for uploaded documents; fall back
|
||||
* to a stable HTTP fixture path or a non-blob `document.uri`.
|
||||
*/
|
||||
export function resolvePdfViewerUrl(
|
||||
document: Document,
|
||||
byteStore: PdfByteStore,
|
||||
): string | null {
|
||||
const live = byteStore.get(document.id);
|
||||
if (live) return live.blobUrl;
|
||||
|
||||
if (document.uri && !isEphemeralBlobUri(document.uri)) {
|
||||
return document.uri;
|
||||
}
|
||||
|
||||
const titleOrId = document.title ?? document.id;
|
||||
return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`;
|
||||
}
|
||||
|
||||
/** True when bytes exist in the store but the document record lacks a URI. */
|
||||
export function documentHasUploadedBytes(
|
||||
documentId: DocumentId,
|
||||
byteStore: PdfByteStore,
|
||||
): boolean {
|
||||
return byteStore.has(documentId);
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
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,
|
||||
@@ -22,12 +23,14 @@ import {
|
||||
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();
|
||||
@@ -62,10 +65,11 @@ export function ViewerShell() {
|
||||
|
||||
const fileUrl = useMemo(() => {
|
||||
if (!document) return null;
|
||||
if (document.uri) return document.uri;
|
||||
const titleOrId = document.title ?? document.id;
|
||||
return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`;
|
||||
}, [document]);
|
||||
return resolvePdfViewerUrl(document, byteStore);
|
||||
}, [document, byteStore]);
|
||||
|
||||
const scrollRequestKey =
|
||||
scrollToId !== null ? `${scrollToId}:${scrollVersion}` : null;
|
||||
|
||||
const handleHighlightClicked = useCallback(
|
||||
(annotationId: string) => {
|
||||
@@ -112,13 +116,10 @@ export function ViewerShell() {
|
||||
>
|
||||
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
||||
<PdfSpikeViewer
|
||||
// Re-key on scrollVersion + debug flags so toggling any of
|
||||
// them remounts the viewer (the CSS classes are on a parent,
|
||||
// but a re-render is the simplest way to make sure the layers
|
||||
// get re-laid-out).
|
||||
// Re-key on document + debug flags only — scroll requests must
|
||||
// not remount the viewer (that re-fetches the PDF blob).
|
||||
key={[
|
||||
document.id,
|
||||
scrollVersion,
|
||||
debugTextLayer ? "d" : "n",
|
||||
hideCanvas ? "hc" : "",
|
||||
hideTextLayer ? "ht" : "",
|
||||
@@ -128,6 +129,7 @@ export function ViewerShell() {
|
||||
pdfUrl={fileUrl}
|
||||
storedAnnotations={annotations}
|
||||
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
|
||||
{...(scrollRequestKey ? { scrollRequestKey } : {})}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
onHighlightClicked={handleHighlightClicked}
|
||||
debugTextLayer={debugTextLayer}
|
||||
|
||||
Reference in New Issue
Block a user