generated from coulomb/repo-seed
Refine evidence UX: sidebar capture form, inline edit, click highlight
Significant UX iteration: Visual palette - Debug text-layer overlay flips from yellow to light grey so it no longer collides with the evidence highlight colour. - New highlight-styles.css matches the sidebar's #fff8d6/#e0c050 palette so a passage marked in the document and its sidebar card speak the same visual language. - Active (focused) evidence: same fill, thick #b78b1c outline on both the highlight and the sidebar card. Library's red --scrolledTo box-shadow is suppressed. Activation model - Click an evidence card in the sidebar → activates that item + scrolls the viewer to the passage + thickens the borders (existing behaviour, now visually clearer). - Click a highlight in the document → activates the evidence that owns that annotation. New `findByAnnotationId()` on EvidenceService is the reverse lookup. Wired through a new `onHighlightClicked` prop on PdfSpikeViewer + `activeAnnotationId` prop that drives the data-ce-active attribute on the highlight wrapper. Inline edit - Each evidence card has a ✎ button that flips the card into an inline form with the citation (quote) and commentary fields. - Saving calls a new `AnnotationService.updateQuote()` + existing `EvidenceService.updateCommentary()`. The selectors are untouched, so the marked passage in the document stays put — the inline hint says so explicitly. - New `AnnotationUpdated` event added to the engine event vocabulary (SharedContracts.md §4 updated). Capture form placement - The yellow "New annotation" toolbar that lived above the viewer is gone. A new InlineCaptureForm component is now slotted into the sidebar between the cards that bracket the new selection in document flow (sorted by page + y of the first PdfRectSelector). If the new selection is before all existing evidence it appears at the top; if after all of them, at the bottom. - The legacy AnnotationToolbar.tsx is removed; the public surface re-exports `InlineCaptureForm` instead. Test updates - tests/integration/citation-card-export-e2e.dom.test.tsx: switched to the seed-session helper (matches the other E2Es) since the fixture-button click path is gone. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,39 +2,34 @@
|
||||
* Debug overlay for PDF text layer alignment.
|
||||
*
|
||||
* The text layer is normally invisible (`opacity: 0`) and selectable.
|
||||
* When `.ce-debug-textlayer` is on a parent, every text span becomes a
|
||||
* yellow highlight so it's obvious where text is selectable and where it
|
||||
* When `.ce-debug-textlayer` is on a parent, every text node becomes a
|
||||
* light grey box so it's obvious where text is selectable and where it
|
||||
* isn't — useful for diagnosing OCR misalignment, scan-only PDFs, and
|
||||
* text-layer shift caused by font fallbacks.
|
||||
*
|
||||
* Light grey was chosen so the debug overlay does not clash with the
|
||||
* citation-yellow used for evidence highlights (see highlight-styles.css).
|
||||
*
|
||||
* Toggle via the "Debug text layer" entry in SessionMenu.
|
||||
*/
|
||||
|
||||
.ce-debug-textlayer .textLayer {
|
||||
outline: 2px dashed rgba(255, 0, 0, 0.5);
|
||||
background: rgba(255, 0, 0, 0.05);
|
||||
outline: 2px dashed rgba(120, 120, 120, 0.55);
|
||||
background: rgba(120, 120, 120, 0.06);
|
||||
}
|
||||
|
||||
/* PDF.js 4.x wraps marked content in nested spans/divs — cover every
|
||||
descendant so the entire selectable area is visible regardless of how
|
||||
the renderer nested things. */
|
||||
.ce-debug-textlayer .textLayer * {
|
||||
background: rgba(255, 220, 0, 0.45) !important;
|
||||
color: rgba(0, 0, 100, 0.85) !important;
|
||||
background: rgba(170, 170, 170, 0.4) !important;
|
||||
color: rgba(40, 40, 40, 0.85) !important;
|
||||
opacity: 1 !important;
|
||||
outline: 1px solid rgba(0, 100, 255, 0.3);
|
||||
outline: 1px solid rgba(100, 100, 100, 0.35);
|
||||
}
|
||||
|
||||
/* Make the canvas-rendered layer dim so the text-layer overlay stands
|
||||
/* Dim the canvas-rendered layer slightly so the debug overlay stands
|
||||
out by contrast. */
|
||||
.ce-debug-textlayer canvas {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* Make any existing TextHighlight rectangles obvious even in debug
|
||||
mode (the highlighter's own yellow gets washed out by our debug
|
||||
yellow). */
|
||||
.ce-debug-textlayer .TextHighlight__part {
|
||||
background: rgba(0, 200, 0, 0.45) !important;
|
||||
outline: 2px solid rgba(0, 120, 0, 0.7) !important;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
38
src/anchor/highlight-styles.css
Normal file
38
src/anchor/highlight-styles.css
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Evidence highlight styling — matches the sidebar's "evidence card"
|
||||
* palette so the viewer and the sidebar speak the same visual language.
|
||||
*
|
||||
* .TextHighlight__part inactive highlight (light yellow fill,
|
||||
* thin amber border)
|
||||
* .TextHighlight--active … the currently-focused evidence — same
|
||||
* fill, thicker border
|
||||
*
|
||||
* The "active" class is applied by the spike viewer when the parent
|
||||
* wrapper is marked with `data-ce-active="true"` so a single
|
||||
* `activeAnnotationId` prop drives the entire viewer's focus state
|
||||
* without per-highlight component coupling.
|
||||
*
|
||||
* We override the library's red `--scrolledTo` box-shadow so an
|
||||
* activation doesn't flash a red ring that doesn't match the palette.
|
||||
*/
|
||||
|
||||
.TextHighlight__part {
|
||||
background: #fff8d6 !important;
|
||||
outline: 1px solid #e0c050 !important;
|
||||
outline-offset: 0;
|
||||
cursor: pointer;
|
||||
transition: outline 0.15s ease;
|
||||
}
|
||||
|
||||
[data-ce-active="true"] .TextHighlight__part {
|
||||
outline: 3px solid #b78b1c !important;
|
||||
background: #fff5b8 !important;
|
||||
}
|
||||
|
||||
/* The library applies `--scrolledTo` after a programmatic scroll. We
|
||||
override its red box-shadow so the "you just landed on this" cue
|
||||
sticks with the yellow palette. The thicker border from
|
||||
`data-ce-active` already conveys focus. */
|
||||
.TextHighlight--scrolledTo .TextHighlight__part {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "react-pdf-highlighter-plus";
|
||||
import "react-pdf-highlighter-plus/style/style.css";
|
||||
import "react-pdf-highlighter-plus/style/pdf_viewer.css";
|
||||
import "./highlight-styles.css";
|
||||
import "./debug-textlayer.css";
|
||||
|
||||
import type { NormalizedRect, Selector } from "@shared/selector";
|
||||
@@ -112,20 +113,34 @@ function captureFromPdfSelection(sel: PdfSelection): PdfSelectionCapture {
|
||||
* 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?"
|
||||
*/
|
||||
function SpikeHighlightContainer(): ReactNode {
|
||||
const { highlight, isScrolledTo } = useHighlightContainerContext();
|
||||
// Wrap the highlight in a data-tagged container so the visual-guide
|
||||
// overlay's HighlightRectBridge can locate it via DOM query. The
|
||||
// wrapper uses display: contents so it doesn't affect layout — the
|
||||
// bounding rect is gathered from the live TextHighlight children at
|
||||
// query time.
|
||||
return (
|
||||
<div data-highlight-id={highlight.id} style={{ display: "contents" }}>
|
||||
<MonitoredHighlightContainer>
|
||||
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
|
||||
</MonitoredHighlightContainer>
|
||||
</div>
|
||||
);
|
||||
interface HighlightContainerProps {
|
||||
readonly activeAnnotationId: string | null | undefined;
|
||||
readonly onHighlightClicked: ((annotationId: string) => void) | undefined;
|
||||
}
|
||||
|
||||
function makeSpikeHighlightContainer(props: HighlightContainerProps) {
|
||||
return function SpikeHighlightContainer(): ReactNode {
|
||||
const { highlight, isScrolledTo } = useHighlightContainerContext();
|
||||
const isActive = props.activeAnnotationId === highlight.id;
|
||||
return (
|
||||
<div
|
||||
data-highlight-id={highlight.id}
|
||||
data-ce-active={isActive ? "true" : "false"}
|
||||
style={{ display: "contents" }}
|
||||
onClickCapture={(e) => {
|
||||
// Click-capture so the click registers before the library's
|
||||
// built-in selection-clearing logic eats it. Stop propagation
|
||||
// so the highlight click doesn't also start a new selection.
|
||||
e.stopPropagation();
|
||||
props.onHighlightClicked?.(highlight.id);
|
||||
}}
|
||||
>
|
||||
<MonitoredHighlightContainer>
|
||||
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
|
||||
</MonitoredHighlightContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +183,18 @@ export interface PdfSpikeViewerProps {
|
||||
/** Annotation id to scroll to and highlight on mount, if any. */
|
||||
readonly scrollToAnnotationId?: string;
|
||||
/**
|
||||
* When true, paint the PDF text-layer spans in yellow so it's
|
||||
* Annotation id currently focused. The matching highlight gets a
|
||||
* thicker border (see highlight-styles.css). `null`/undefined means
|
||||
* "no active highlight".
|
||||
*/
|
||||
readonly activeAnnotationId?: string | null;
|
||||
/**
|
||||
* Called when the user clicks an existing highlight in the page.
|
||||
* The receiver typically activates the matching evidence item.
|
||||
*/
|
||||
onHighlightClicked?(annotationId: string): void;
|
||||
/**
|
||||
* When true, paint the PDF text-layer spans in light grey so it's
|
||||
* obvious which glyphs have a selectable text overlay and which are
|
||||
* image-only. Also logs every onSelection event to the console.
|
||||
*/
|
||||
@@ -188,7 +214,19 @@ export interface StoredAnnotation {
|
||||
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
|
||||
*/
|
||||
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
||||
const { pdfUrl, storedAnnotations, onSelectionCaptured, scrollToAnnotationId, debugTextLayer } = props;
|
||||
const {
|
||||
pdfUrl,
|
||||
storedAnnotations,
|
||||
onSelectionCaptured,
|
||||
scrollToAnnotationId,
|
||||
activeAnnotationId,
|
||||
onHighlightClicked,
|
||||
debugTextLayer,
|
||||
} = props;
|
||||
const HighlightContainer = useMemo(
|
||||
() => makeSpikeHighlightContainer({ activeAnnotationId, onHighlightClicked }),
|
||||
[activeAnnotationId, onHighlightClicked],
|
||||
);
|
||||
const utilsRef = useRef<PdfHighlighterUtils | null>(null);
|
||||
const [didScroll, setDidScroll] = useState<string | null>(null);
|
||||
|
||||
@@ -261,7 +299,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
||||
onSelectionCaptured(capture, selectors);
|
||||
}}
|
||||
>
|
||||
<SpikeHighlightContainer />
|
||||
<HighlightContainer />
|
||||
</PdfHighlighter>
|
||||
)}
|
||||
</PdfLoader>
|
||||
|
||||
@@ -62,6 +62,12 @@ export interface AnnotationResolutionFailedEvent {
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export interface AnnotationUpdatedEvent {
|
||||
readonly type: "AnnotationUpdated";
|
||||
readonly annotationId: AnnotationId;
|
||||
readonly annotation: Annotation;
|
||||
}
|
||||
|
||||
export interface EvidenceItemCreatedEvent {
|
||||
readonly type: "EvidenceItemCreated";
|
||||
readonly evidenceItemId: EvidenceItemId;
|
||||
@@ -128,6 +134,7 @@ export type EngineEvent =
|
||||
| DocumentRepresentationGeneratedEvent
|
||||
| DocumentRemovedEvent
|
||||
| AnnotationCreatedEvent
|
||||
| AnnotationUpdatedEvent
|
||||
| AnnotationResolvedEvent
|
||||
| AnnotationResolutionFailedEvent
|
||||
| EvidenceItemCreatedEvent
|
||||
|
||||
@@ -39,6 +39,12 @@ export interface AnnotationService {
|
||||
status: AnnotationResolutionStatus,
|
||||
opts: { readonly confidence: number; readonly reason?: string },
|
||||
): Annotation;
|
||||
/**
|
||||
* Edit the human-facing `quote` text on an annotation without touching
|
||||
* the underlying selectors. Selectors stay the source of truth for
|
||||
* locating the passage; the quote is the user's editable display copy.
|
||||
*/
|
||||
updateQuote(id: AnnotationId, quote: string): Annotation;
|
||||
}
|
||||
|
||||
export function createAnnotationService(
|
||||
@@ -98,5 +104,26 @@ export function createAnnotationService(
|
||||
}
|
||||
return stored;
|
||||
},
|
||||
updateQuote(id, quote) {
|
||||
const existing = annotations.get(id);
|
||||
if (!existing) {
|
||||
throw new Error(`AnnotationService.updateQuote: unknown id ${id}`);
|
||||
}
|
||||
const trimmed = quote.length === 0 ? undefined : quote;
|
||||
const updated: Annotation = {
|
||||
...existing,
|
||||
// exactOptionalPropertyTypes: drop `quote` when empty rather
|
||||
// than setting it to undefined.
|
||||
...(trimmed !== undefined ? { quote: trimmed } : {}),
|
||||
updatedAt: now(),
|
||||
};
|
||||
if (trimmed === undefined && "quote" in updated) {
|
||||
// Remove the field outright when clearing.
|
||||
delete (updated as { quote?: string }).quote;
|
||||
}
|
||||
const stored = annotations.update(updated);
|
||||
bus.emit({ type: "AnnotationUpdated", annotationId: stored.id, annotation: stored });
|
||||
return stored;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +44,15 @@ export interface EvidenceService {
|
||||
id: EvidenceItemId,
|
||||
source?: EvidenceItemActivatedEvent["source"],
|
||||
): EvidenceItem;
|
||||
/**
|
||||
* Reverse lookup: find the evidence item that owns a given annotation.
|
||||
* Used by the viewer's click-on-highlight handler so a click on the
|
||||
* passage activates the right sidebar row.
|
||||
*/
|
||||
findByAnnotationId(
|
||||
documentId: DocumentId,
|
||||
annotationId: AnnotationId,
|
||||
): EvidenceItem | null;
|
||||
}
|
||||
|
||||
export function createEvidenceService(
|
||||
@@ -123,5 +132,11 @@ export function createEvidenceService(
|
||||
});
|
||||
return existing;
|
||||
},
|
||||
findByAnnotationId(documentId, annotationId) {
|
||||
for (const item of items.listByDocument(documentId, annotationLookup)) {
|
||||
if (item.annotationIds.includes(annotationId)) return item;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
/**
|
||||
* EvidenceSidebar — the right pane.
|
||||
*
|
||||
* Lists `EvidenceItem`s scoped to the currently-active document. Each row
|
||||
* shows quote + commentary + status. Clicking a row emits
|
||||
* `EvidenceItemActivated` via the engine, which T08 will translate into a
|
||||
* scroll-to-passage in the viewer.
|
||||
* Lists `EvidenceItem`s scoped to the active document, sorted by their
|
||||
* position in the document (first PdfRectSelector's page + y). Each row:
|
||||
*
|
||||
* CE-WP-0004-T04 added: a per-item Export popover (Copy as Markdown /
|
||||
* Copy as HTML), a transient toast confirming the copy, and the
|
||||
* Cmd/Ctrl+Shift+C keyboard shortcut that exports the currently-active
|
||||
* evidence as Markdown.
|
||||
* - Click → activates the evidence item (highlights its passage in
|
||||
* the viewer + thickens its border).
|
||||
* - Edit pencil → inline form to change the citation quote and
|
||||
* commentary. The underlying selectors stay untouched, so the
|
||||
* marked passage in the document doesn't move.
|
||||
* - Export popover → copy as Markdown / HTML (CE-WP-0004).
|
||||
*
|
||||
* The "create new evidence from a fresh selection" form
|
||||
* (`InlineCaptureForm`) is slotted into the list at the right
|
||||
* document-flow position whenever there is a pending selection — so a
|
||||
* new capture appears between the cards that bracket it, or at the
|
||||
* top/bottom if it's the first or last passage in the document.
|
||||
*
|
||||
* Cmd/Ctrl+Shift+C exports the active evidence as Markdown.
|
||||
*/
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -20,14 +29,18 @@ import {
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import type { Annotation } from "@shared/annotation";
|
||||
import type { EvidenceItem } from "@shared/evidence";
|
||||
import type { EvidenceItemId } from "@shared/ids";
|
||||
import type { AnnotationId, EvidenceItemId } from "@shared/ids";
|
||||
import type { PdfRectSelector, Selector } from "@shared/selector";
|
||||
|
||||
import {
|
||||
useActiveDocument,
|
||||
useEngine,
|
||||
useEngineEventTick,
|
||||
useEngineRevision,
|
||||
useLastActivatedEvidence,
|
||||
usePendingSelection,
|
||||
useScrollToAnnotation,
|
||||
} from "./EngineContext";
|
||||
import {
|
||||
@@ -35,6 +48,7 @@ import {
|
||||
type ExportFormat,
|
||||
type ExportResult,
|
||||
} from "./useExportEvidence";
|
||||
import { InlineCaptureForm } from "./InlineCaptureForm";
|
||||
|
||||
const TOAST_TIMEOUT_MS = 2000;
|
||||
|
||||
@@ -45,7 +59,6 @@ export interface EvidenceSidebarProps {
|
||||
interface ToastState {
|
||||
readonly message: string;
|
||||
readonly tone: "success" | "error";
|
||||
/** Bumps on every new toast so timers don't dismiss the *next* toast. */
|
||||
readonly key: number;
|
||||
}
|
||||
|
||||
@@ -67,23 +80,84 @@ function describeSuccess(format: ExportFormat): string {
|
||||
return format === "markdown" ? "Copied as Markdown" : "Copied as HTML";
|
||||
}
|
||||
|
||||
/**
|
||||
* A sortable scalar key for "where in the document is this passage".
|
||||
* Page-first, then y-coordinate (0..1 within the page). Returns
|
||||
* Infinity for items without a usable position so they sink to the
|
||||
* bottom. The same scheme is used for `EvidenceItem`s (via their
|
||||
* first annotation) and for the pending selection's capture.
|
||||
*/
|
||||
function docOrderKey(selectors: readonly Selector[]): number {
|
||||
for (const s of selectors) {
|
||||
if (s.type === "PdfRectSelector") {
|
||||
const rect: PdfRectSelector = s;
|
||||
const top = rect.rects[0]?.y ?? 0;
|
||||
return rect.page * 1000 + top;
|
||||
}
|
||||
}
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function annotationOrderKey(annotation: Annotation | null): number {
|
||||
if (!annotation) return Number.POSITIVE_INFINITY;
|
||||
return docOrderKey(annotation.selectors);
|
||||
}
|
||||
|
||||
export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
||||
const engine = useEngine();
|
||||
const { document } = useActiveDocument();
|
||||
const { scrollTo } = useScrollToAnnotation();
|
||||
const activeId = useLastActivatedEvidence();
|
||||
const { exportItem } = useExportEvidence();
|
||||
const { pending } = usePendingSelection();
|
||||
|
||||
const createTick = useEngineEventTick("EvidenceItemCreated");
|
||||
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
||||
const annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
|
||||
const revision = useEngineRevision();
|
||||
|
||||
const items = useMemo<readonly EvidenceItem[]>(() => {
|
||||
if (!document) return [];
|
||||
return engine.evidence.listByDocument(document.id);
|
||||
}, [document, engine, createTick, updateTick, revision]);
|
||||
// Build the sorted view-model: each item gets its order key + the
|
||||
// first annotation up-front so the render below doesn't have to
|
||||
// re-resolve them inside the map.
|
||||
const sortedItems = useMemo(() => {
|
||||
if (!document) return [] as readonly { item: EvidenceItem; annotation: Annotation | null; order: number }[];
|
||||
const items = engine.evidence.listByDocument(document.id);
|
||||
const out = items.map((item) => {
|
||||
const firstAnnId = item.annotationIds[0];
|
||||
const annotation = firstAnnId ? engine.annotations.get(firstAnnId) : null;
|
||||
return { item, annotation, order: annotationOrderKey(annotation) };
|
||||
});
|
||||
out.sort((a, b) => a.order - b.order);
|
||||
return out;
|
||||
}, [
|
||||
document,
|
||||
engine,
|
||||
createTick,
|
||||
updateTick,
|
||||
annotationUpdateTick,
|
||||
revision,
|
||||
]);
|
||||
|
||||
const pendingOrder = useMemo<number>(() => {
|
||||
if (!pending) return Number.POSITIVE_INFINITY;
|
||||
const c = pending.capture;
|
||||
return c.page * 1000 + (c.boundingRect?.y ?? 0);
|
||||
}, [pending]);
|
||||
|
||||
// Find the insert position for the pending capture form: first index
|
||||
// whose order > pendingOrder, or sortedItems.length to append.
|
||||
const pendingInsertIndex = useMemo(() => {
|
||||
if (!pending) return -1;
|
||||
for (let i = 0; i < sortedItems.length; i++) {
|
||||
if (sortedItems[i]!.order > pendingOrder) return i;
|
||||
}
|
||||
return sortedItems.length;
|
||||
}, [pending, pendingOrder, sortedItems]);
|
||||
|
||||
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
|
||||
const [editingId, setEditingId] = useState<EvidenceItemId | null>(null);
|
||||
const [editQuote, setEditQuote] = useState("");
|
||||
const [editCommentary, setEditCommentary] = useState("");
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const toastKeyRef = useRef(0);
|
||||
|
||||
@@ -127,6 +201,48 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [activeId, engine, runExport]);
|
||||
|
||||
const activateItem = useCallback(
|
||||
(item: EvidenceItem, firstAnnotationId: AnnotationId | undefined) => {
|
||||
engine.evidence.activate(item.id, "sidebar");
|
||||
if (firstAnnotationId) scrollTo(firstAnnotationId);
|
||||
props.onActivate?.(item);
|
||||
},
|
||||
[engine, scrollTo, props],
|
||||
);
|
||||
|
||||
const beginEdit = useCallback(
|
||||
(item: EvidenceItem, annotation: Annotation | null) => {
|
||||
setEditingId(item.id);
|
||||
setEditQuote(annotation?.quote ?? "");
|
||||
setEditCommentary(item.commentary ?? "");
|
||||
setOpenExportFor(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingId(null);
|
||||
}, []);
|
||||
|
||||
const saveEdit = useCallback(
|
||||
(item: EvidenceItem, annotation: Annotation | null) => {
|
||||
try {
|
||||
if (annotation) {
|
||||
engine.annotations.updateQuote(annotation.id, editQuote);
|
||||
}
|
||||
// updateCommentary expects a string — empty string clears it.
|
||||
engine.evidence.updateCommentary(item.id, editCommentary);
|
||||
setEditingId(null);
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof Error ? `Save failed: ${err.message}` : "Save failed",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
},
|
||||
[engine, editQuote, editCommentary, showToast],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
@@ -143,138 +259,62 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
||||
{!document && (
|
||||
<p style={{ fontSize: 12, color: "#888" }}>No document open.</p>
|
||||
)}
|
||||
{document && items.length === 0 && (
|
||||
{document && sortedItems.length === 0 && !pending && (
|
||||
<p style={{ fontSize: 12, color: "#888" }}>
|
||||
No evidence yet. Select a passage in the viewer to create one.
|
||||
No evidence yet. Drag-select a passage in the viewer to start a
|
||||
capture.
|
||||
</p>
|
||||
)}
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||
{items.map((item) => {
|
||||
const firstAnnotationId = item.annotationIds[0];
|
||||
const annotation = firstAnnotationId
|
||||
? engine.annotations.get(firstAnnotationId)
|
||||
: null;
|
||||
const quote = annotation?.quote ?? "(no quote)";
|
||||
const isActive = activeId === item.id;
|
||||
const isExportOpen = openExportFor === item.id;
|
||||
{sortedItems.map((entry, i) => {
|
||||
const slotForCapture = pendingInsertIndex === i;
|
||||
return (
|
||||
<li key={item.id} style={{ marginBottom: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
background: isActive ? "#e8f0ff" : "#fff8d6",
|
||||
border: isActive ? "2px solid #0050b3" : "1px solid #e0c050",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
engine.evidence.activate(item.id, "sidebar");
|
||||
if (firstAnnotationId) scrollTo(firstAnnotationId);
|
||||
props.onActivate?.(item);
|
||||
}}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 8,
|
||||
paddingRight: 80,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
|
||||
“{quote.slice(0, 140)}
|
||||
{quote.length > 140 ? "…" : ""}”
|
||||
</div>
|
||||
{item.commentary && (
|
||||
<div style={{ color: "#333", marginBottom: 4 }}>
|
||||
{item.commentary}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: "#666", fontSize: 11 }}>
|
||||
status: {item.status}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isExportOpen}
|
||||
aria-label="Export evidence item"
|
||||
data-testid={`export-toggle-${item.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<Fragment key={entry.item.id}>
|
||||
{slotForCapture && (
|
||||
<li>
|
||||
<InlineCaptureForm />
|
||||
</li>
|
||||
)}
|
||||
<li style={{ marginBottom: 8 }}>
|
||||
<EvidenceCard
|
||||
item={entry.item}
|
||||
annotation={entry.annotation}
|
||||
isActive={activeId === entry.item.id}
|
||||
isExportOpen={openExportFor === entry.item.id}
|
||||
isEditing={editingId === entry.item.id}
|
||||
editQuote={editQuote}
|
||||
editCommentary={editCommentary}
|
||||
onActivate={() =>
|
||||
activateItem(entry.item, entry.annotation?.id)
|
||||
}
|
||||
onBeginEdit={() => beginEdit(entry.item, entry.annotation)}
|
||||
onChangeQuote={setEditQuote}
|
||||
onChangeCommentary={setEditCommentary}
|
||||
onSaveEdit={() => saveEdit(entry.item, entry.annotation)}
|
||||
onCancelEdit={cancelEdit}
|
||||
onToggleExport={() =>
|
||||
setOpenExportFor((current) =>
|
||||
current === item.id ? null : item.id,
|
||||
);
|
||||
current === entry.item.id ? null : entry.item.id,
|
||||
)
|
||||
}
|
||||
onCopyMarkdown={async () => {
|
||||
setOpenExportFor(null);
|
||||
await runExport(entry.item, "markdown");
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
right: 6,
|
||||
fontSize: 11,
|
||||
padding: "2px 6px",
|
||||
background: "white",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
onCopyHtml={async () => {
|
||||
setOpenExportFor(null);
|
||||
await runExport(entry.item, "html");
|
||||
}}
|
||||
>
|
||||
Export ▾
|
||||
</button>
|
||||
{isExportOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
data-testid={`export-menu-${item.id}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 28,
|
||||
right: 6,
|
||||
zIndex: 10,
|
||||
background: "white",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 3,
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.15)",
|
||||
padding: 4,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
minWidth: 160,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setOpenExportFor(null);
|
||||
await runExport(item, "markdown");
|
||||
}}
|
||||
style={menuButtonStyle}
|
||||
>
|
||||
Copy as Markdown
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setOpenExportFor(null);
|
||||
await runExport(item, "html");
|
||||
}}
|
||||
style={menuButtonStyle}
|
||||
>
|
||||
Copy as HTML
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
/>
|
||||
</li>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{pendingInsertIndex === sortedItems.length && (
|
||||
<li>
|
||||
<InlineCaptureForm />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{toast && (
|
||||
<div
|
||||
@@ -302,6 +342,240 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface EvidenceCardProps {
|
||||
readonly item: EvidenceItem;
|
||||
readonly annotation: Annotation | null;
|
||||
readonly isActive: boolean;
|
||||
readonly isExportOpen: boolean;
|
||||
readonly isEditing: boolean;
|
||||
readonly editQuote: string;
|
||||
readonly editCommentary: string;
|
||||
onActivate(): void;
|
||||
onBeginEdit(): void;
|
||||
onChangeQuote(next: string): void;
|
||||
onChangeCommentary(next: string): void;
|
||||
onSaveEdit(): void;
|
||||
onCancelEdit(): void;
|
||||
onToggleExport(): void;
|
||||
onCopyMarkdown(): Promise<void>;
|
||||
onCopyHtml(): Promise<void>;
|
||||
}
|
||||
|
||||
function EvidenceCard(p: EvidenceCardProps) {
|
||||
const quote = p.annotation?.quote ?? "(no quote)";
|
||||
return (
|
||||
<div
|
||||
data-testid={`evidence-card-${p.item.id}`}
|
||||
data-active={p.isActive ? "true" : "false"}
|
||||
style={{
|
||||
position: "relative",
|
||||
background: "#fff8d6",
|
||||
border: p.isActive ? "3px solid #b78b1c" : "1px solid #e0c050",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{p.isEditing ? (
|
||||
<div style={{ padding: 8, fontSize: 12 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
color: "#666",
|
||||
fontSize: 11,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
Citation text
|
||||
</label>
|
||||
<textarea
|
||||
value={p.editQuote}
|
||||
onChange={(e) => p.onChangeQuote(e.target.value)}
|
||||
data-testid={`evidence-edit-quote-${p.item.id}`}
|
||||
rows={3}
|
||||
style={{
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
fontSize: 12,
|
||||
padding: 4,
|
||||
marginBottom: 6,
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
color: "#666",
|
||||
fontSize: 11,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
Commentary
|
||||
</label>
|
||||
<textarea
|
||||
value={p.editCommentary}
|
||||
onChange={(e) => p.onChangeCommentary(e.target.value)}
|
||||
data-testid={`evidence-edit-commentary-${p.item.id}`}
|
||||
rows={2}
|
||||
placeholder="(empty)"
|
||||
style={{
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
fontSize: 12,
|
||||
padding: 4,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={p.onSaveEdit}
|
||||
data-testid={`evidence-edit-save-${p.item.id}`}
|
||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={p.onCancelEdit}
|
||||
data-testid={`evidence-edit-cancel-${p.item.id}`}
|
||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<span
|
||||
style={{ fontSize: 11, color: "#888", alignSelf: "center" }}
|
||||
>
|
||||
The marked passage in the document stays the same.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={p.onActivate}
|
||||
aria-current={p.isActive ? "true" : undefined}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: 8,
|
||||
paddingRight: 96,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontStyle: "italic", marginBottom: 4 }}>
|
||||
“{quote.slice(0, 140)}
|
||||
{quote.length > 140 ? "…" : ""}”
|
||||
</div>
|
||||
{p.item.commentary && (
|
||||
<div style={{ color: "#333", marginBottom: 4 }}>
|
||||
{p.item.commentary}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: "#666", fontSize: 11 }}>
|
||||
status: {p.item.status}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
right: 6,
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Edit citation and commentary"
|
||||
data-testid={`evidence-edit-toggle-${p.item.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
p.onBeginEdit();
|
||||
}}
|
||||
title="Edit citation and commentary"
|
||||
style={iconButtonStyle}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={p.isExportOpen}
|
||||
aria-label="Export evidence item"
|
||||
data-testid={`export-toggle-${p.item.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
p.onToggleExport();
|
||||
}}
|
||||
style={iconButtonStyle}
|
||||
>
|
||||
Export ▾
|
||||
</button>
|
||||
</div>
|
||||
{p.isExportOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
data-testid={`export-menu-${p.item.id}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 28,
|
||||
right: 6,
|
||||
zIndex: 10,
|
||||
background: "white",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 3,
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.15)",
|
||||
padding: 4,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
minWidth: 160,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await p.onCopyMarkdown();
|
||||
}}
|
||||
style={menuButtonStyle}
|
||||
>
|
||||
Copy as Markdown
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await p.onCopyHtml();
|
||||
}}
|
||||
style={menuButtonStyle}
|
||||
>
|
||||
Copy as HTML
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const iconButtonStyle: CSSProperties = {
|
||||
fontSize: 11,
|
||||
padding: "2px 6px",
|
||||
background: "white",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
lineHeight: 1,
|
||||
};
|
||||
|
||||
const menuButtonStyle: CSSProperties = {
|
||||
textAlign: "left",
|
||||
background: "transparent",
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
/**
|
||||
* AnnotationToolbar — wires "I selected text" into "evidence appears in
|
||||
* the sidebar".
|
||||
* `InlineCaptureForm` — the "I just selected text, let me save it as
|
||||
* evidence" form. Renders only when a pendingSelection is set; the
|
||||
* EvidenceSidebar slots it into the right position in document order
|
||||
* so the form appears between the cards that bracket the new
|
||||
* selection.
|
||||
*
|
||||
* Visible only when a `pendingSelection` is set (the viewer publishes
|
||||
* captures into context, then this toolbar lets the user attach commentary
|
||||
* and commit). On Save it runs the full pipeline:
|
||||
* Behaviour matches the pre-iteration AnnotationToolbar (which used to
|
||||
* live above the viewer):
|
||||
*
|
||||
* 1. `createSelectors(capture, representation)` — anchor builds the
|
||||
* maximal selector set against the active representation.
|
||||
* 2. `engine.annotations.create(...)` — engine mints an Annotation +
|
||||
* emits AnnotationCreated.
|
||||
* 3. `engine.evidence.create(...)` — engine mints the EvidenceItem with
|
||||
* the user's commentary, emits EvidenceItemCreated.
|
||||
*
|
||||
* The sidebar re-renders via the engine event bus, so no other glue is
|
||||
* needed.
|
||||
* 3. `engine.evidence.create(...)` — engine mints the EvidenceItem
|
||||
* with the user's commentary, emits EvidenceItemCreated.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { createSelectors } from "@anchor/index";
|
||||
|
||||
import {
|
||||
useActiveDocument,
|
||||
useEngine,
|
||||
usePendingSelection,
|
||||
} from "./EngineContext";
|
||||
|
||||
export function AnnotationToolbar() {
|
||||
export function InlineCaptureForm() {
|
||||
const engine = useEngine();
|
||||
const { document, representation } = useActiveDocument();
|
||||
const { pending, set } = usePendingSelection();
|
||||
@@ -60,16 +61,19 @@ export function AnnotationToolbar() {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="inline-capture-form"
|
||||
style={{
|
||||
borderBottom: "1px solid #f0c040",
|
||||
border: "1px dashed #b78b1c",
|
||||
background: "#fff8d6",
|
||||
padding: 8,
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
marginBottom: 8,
|
||||
borderRadius: 2,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>
|
||||
New annotation ({pending.selectors.length} selector{pending.selectors.length === 1 ? "" : "s"})
|
||||
New annotation ({pending.selectors.length} selector
|
||||
{pending.selectors.length === 1 ? "" : "s"})
|
||||
</div>
|
||||
<div style={{ marginBottom: 6, fontStyle: "italic", color: "#444" }}>
|
||||
“{shortQuote}”
|
||||
@@ -88,10 +92,19 @@ export function AnnotationToolbar() {
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<button onClick={handleSave} style={{ fontSize: 12, padding: "4px 10px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
data-testid="inline-capture-save"
|
||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||
>
|
||||
Save evidence
|
||||
</button>
|
||||
<button onClick={handleDiscard} style={{ fontSize: 12, padding: "4px 10px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDiscard}
|
||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
@@ -7,33 +7,37 @@
|
||||
* directly. When the PDF library is swapped (or the spike is replaced),
|
||||
* only the adapter module changes; this shell stays the same.
|
||||
*
|
||||
* T06 scope: load + render the active PDF + show stored annotations. The
|
||||
* selection-capture → annotation pipeline is wired in T07; the
|
||||
* click-to-reopen pipeline is wired in T08.
|
||||
* 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 { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { PdfSpikeViewer, type StoredAnnotation } from "@anchor/index";
|
||||
import type { AnnotationId } from "@shared/ids";
|
||||
import {
|
||||
useActiveDocument,
|
||||
useEngine,
|
||||
useEngineEventTick,
|
||||
useLastActivatedEvidence,
|
||||
usePendingSelection,
|
||||
useScrollToAnnotation,
|
||||
} from "./EngineContext";
|
||||
import { AnnotationToolbar } from "./AnnotationToolbar";
|
||||
import { useDebugFlag } from "./useDebugFlags";
|
||||
|
||||
export function ViewerShell() {
|
||||
const engine = useEngine();
|
||||
const { document, representation } = useActiveDocument();
|
||||
const { set: setPending } = usePendingSelection();
|
||||
const { id: scrollToId, version: scrollVersion } = useScrollToAnnotation();
|
||||
const { id: scrollToId, version: scrollVersion, scrollTo } = useScrollToAnnotation();
|
||||
const [debugTextLayer] = useDebugFlag("textLayer");
|
||||
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 [];
|
||||
@@ -42,21 +46,39 @@ export function ViewerShell() {
|
||||
text: a.quote ?? "",
|
||||
selectors: a.selectors,
|
||||
}));
|
||||
}, [document, engine, annotationTick]);
|
||||
}, [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;
|
||||
// CE-WP-0005: uploads + sample sessions stash a `blob:` URL on
|
||||
// `document.uri` via the per-session `PdfByteStore`. Prefer that
|
||||
// over the legacy fixture-path fallback so user uploads don't get
|
||||
// resolved against `/fixtures/pdfs/` (which would either 404 or —
|
||||
// worse — silently return the wrong file when the filename happens
|
||||
// to collide with a bundled fixture).
|
||||
if (document.uri) return document.uri;
|
||||
const titleOrId = document.title ?? document.id;
|
||||
return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`;
|
||||
}, [document]);
|
||||
|
||||
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
|
||||
@@ -69,7 +91,7 @@ export function ViewerShell() {
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
Pick a fixture on the left to begin.
|
||||
Upload a PDF on the left to begin.
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -84,18 +106,17 @@ export function ViewerShell() {
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<AnnotationToolbar />
|
||||
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
||||
<PdfSpikeViewer
|
||||
// Re-key on scrollVersion so clicking the same item twice still
|
||||
// triggers the viewer's mount-time scroll effect. Also re-key on
|
||||
// debugTextLayer so toggling it remounts the viewer (the CSS
|
||||
// class is on a parent, but re-mounting is the simplest way to
|
||||
// make sure the text layer is re-painted with the new style).
|
||||
// debugTextLayer so toggling it remounts the viewer.
|
||||
key={`${document.id}#${scrollVersion}#${debugTextLayer ? "d" : "n"}`}
|
||||
pdfUrl={fileUrl}
|
||||
storedAnnotations={annotations}
|
||||
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
onHighlightClicked={handleHighlightClicked}
|
||||
debugTextLayer={debugTextLayer}
|
||||
onSelectionCaptured={(capture, selectors) => {
|
||||
setPending({ capture, selectors });
|
||||
|
||||
@@ -7,7 +7,7 @@ export {
|
||||
type ExportFormat,
|
||||
type ExportResult,
|
||||
} from "./useExportEvidence";
|
||||
export { AnnotationToolbar } from "./AnnotationToolbar";
|
||||
export { InlineCaptureForm } from "./InlineCaptureForm";
|
||||
export { useDebugFlag, type DebugFlag } from "./useDebugFlags";
|
||||
export {
|
||||
EngineProvider,
|
||||
|
||||
@@ -23,12 +23,11 @@ import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Document, DocumentRepresentation } from "@shared/document";
|
||||
import type { DocumentId, RepresentationId } from "@shared/ids";
|
||||
import type { Selector } from "@shared/selector";
|
||||
|
||||
import type { PdfSelectionCapture } from "@anchor/index";
|
||||
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
|
||||
import { seedSessionWithDoc } from "./helpers/seed-session";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
@@ -64,38 +63,6 @@ vi.mock("@anchor/index", async (importOriginal) => {
|
||||
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
|
||||
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
|
||||
|
||||
vi.mock("@source/index", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@source/index")>();
|
||||
return {
|
||||
...original,
|
||||
ingestPdf: vi.fn(async (_input: unknown, options?: { filename?: string }) => {
|
||||
const documentId = ("doc_test_" + Math.random().toString(36).slice(2, 10)) as DocumentId;
|
||||
const representationId = ("rep_test_" + Math.random().toString(36).slice(2, 10)) as RepresentationId;
|
||||
const document: Document = {
|
||||
id: documentId,
|
||||
mediaType: "application/pdf",
|
||||
...(options?.filename ? { title: options.filename } : {}),
|
||||
fingerprint: "synthetic-fingerprint-for-test",
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
updatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
const representation: DocumentRepresentation = {
|
||||
id: representationId,
|
||||
documentId,
|
||||
representationType: "pdf-text",
|
||||
contentHash: "synthetic-fingerprint-for-test",
|
||||
canonicalText: SYNTHETIC_CANONICAL,
|
||||
pageMap: [{ page: 1, width: 595, height: 842 }],
|
||||
offsetMap: [
|
||||
{ page: 1, globalStart: 0, globalEnd: SYNTHETIC_CANONICAL.length, pageLength: SYNTHETIC_CANONICAL.length },
|
||||
],
|
||||
generatedAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
return { document, representation };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -139,15 +106,14 @@ describe("CE-WP-0004-T05 — PRD scenario steps 10-11 end-to-end", () => {
|
||||
viewerSnapshot.pdfUrl = null;
|
||||
viewerSnapshot.onSelectionCaptured = null;
|
||||
globalThis.localStorage?.clear();
|
||||
globalThis.fetch = vi.fn(async () =>
|
||||
new Response(new Uint8Array([0x25, 0x50, 0x44, 0x46]).buffer, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/pdf" },
|
||||
}),
|
||||
);
|
||||
if (typeof window !== "undefined") {
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
seedSessionWithDoc({
|
||||
sessionName: "T05",
|
||||
documentTitle: FIXTURE.filename,
|
||||
canonicalText: SYNTHETIC_CANONICAL,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -163,8 +129,7 @@ describe("CE-WP-0004-T05 — PRD scenario steps 10-11 end-to-end", () => {
|
||||
installClipboardSpy();
|
||||
|
||||
// --- Steps 1-5 (recap from CE-WP-0002-T09) -------------------------
|
||||
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
|
||||
await user.click(fixtureBtn);
|
||||
// CE-WP-0005: doc pre-seeded into session — skip fixture-button click.
|
||||
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
|
||||
await act(async () => {
|
||||
viewerSnapshot.onSelectionCaptured!(
|
||||
|
||||
Reference in New Issue
Block a user