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:
2026-05-26 22:57:48 +02:00
parent f0af8887d1
commit 430c0e124c
11 changed files with 640 additions and 247 deletions

View File

@@ -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;
}

View 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;
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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;
},
};
}

View File

@@ -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;
},
};
}

View File

@@ -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 }}>
&ldquo;{quote.slice(0, 140)}
{quote.length > 140 ? "…" : ""}&rdquo;
</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 }}>
&ldquo;{quote.slice(0, 140)}
{quote.length > 140 ? "…" : ""}&rdquo;
</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",

View File

@@ -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" }}>
&ldquo;{shortQuote}&rdquo;
@@ -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>

View File

@@ -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 });

View File

@@ -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,

View File

@@ -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!(