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.
|
* Debug overlay for PDF text layer alignment.
|
||||||
*
|
*
|
||||||
* The text layer is normally invisible (`opacity: 0`) and selectable.
|
* The text layer is normally invisible (`opacity: 0`) and selectable.
|
||||||
* When `.ce-debug-textlayer` is on a parent, every text span becomes a
|
* When `.ce-debug-textlayer` is on a parent, every text node becomes a
|
||||||
* yellow highlight so it's obvious where text is selectable and where it
|
* 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
|
* isn't — useful for diagnosing OCR misalignment, scan-only PDFs, and
|
||||||
* text-layer shift caused by font fallbacks.
|
* 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.
|
* Toggle via the "Debug text layer" entry in SessionMenu.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.ce-debug-textlayer .textLayer {
|
.ce-debug-textlayer .textLayer {
|
||||||
outline: 2px dashed rgba(255, 0, 0, 0.5);
|
outline: 2px dashed rgba(120, 120, 120, 0.55);
|
||||||
background: rgba(255, 0, 0, 0.05);
|
background: rgba(120, 120, 120, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PDF.js 4.x wraps marked content in nested spans/divs — cover every
|
/* PDF.js 4.x wraps marked content in nested spans/divs — cover every
|
||||||
descendant so the entire selectable area is visible regardless of how
|
descendant so the entire selectable area is visible regardless of how
|
||||||
the renderer nested things. */
|
the renderer nested things. */
|
||||||
.ce-debug-textlayer .textLayer * {
|
.ce-debug-textlayer .textLayer * {
|
||||||
background: rgba(255, 220, 0, 0.45) !important;
|
background: rgba(170, 170, 170, 0.4) !important;
|
||||||
color: rgba(0, 0, 100, 0.85) !important;
|
color: rgba(40, 40, 40, 0.85) !important;
|
||||||
opacity: 1 !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. */
|
out by contrast. */
|
||||||
.ce-debug-textlayer canvas {
|
.ce-debug-textlayer canvas {
|
||||||
opacity: 0.35;
|
opacity: 0.4;
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
} from "react-pdf-highlighter-plus";
|
||||||
import "react-pdf-highlighter-plus/style/style.css";
|
import "react-pdf-highlighter-plus/style/style.css";
|
||||||
import "react-pdf-highlighter-plus/style/pdf_viewer.css";
|
import "react-pdf-highlighter-plus/style/pdf_viewer.css";
|
||||||
|
import "./highlight-styles.css";
|
||||||
import "./debug-textlayer.css";
|
import "./debug-textlayer.css";
|
||||||
|
|
||||||
import type { NormalizedRect, Selector } from "@shared/selector";
|
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
|
* 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?"
|
* coordinates land on the right passage on the right page after reload?"
|
||||||
*/
|
*/
|
||||||
function SpikeHighlightContainer(): ReactNode {
|
interface HighlightContainerProps {
|
||||||
const { highlight, isScrolledTo } = useHighlightContainerContext();
|
readonly activeAnnotationId: string | null | undefined;
|
||||||
// Wrap the highlight in a data-tagged container so the visual-guide
|
readonly onHighlightClicked: ((annotationId: string) => void) | undefined;
|
||||||
// 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
|
function makeSpikeHighlightContainer(props: HighlightContainerProps) {
|
||||||
// query time.
|
return function SpikeHighlightContainer(): ReactNode {
|
||||||
return (
|
const { highlight, isScrolledTo } = useHighlightContainerContext();
|
||||||
<div data-highlight-id={highlight.id} style={{ display: "contents" }}>
|
const isActive = props.activeAnnotationId === highlight.id;
|
||||||
<MonitoredHighlightContainer>
|
return (
|
||||||
<TextHighlight highlight={highlight} isScrolledTo={isScrolledTo} />
|
<div
|
||||||
</MonitoredHighlightContainer>
|
data-highlight-id={highlight.id}
|
||||||
</div>
|
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. */
|
/** Annotation id to scroll to and highlight on mount, if any. */
|
||||||
readonly scrollToAnnotationId?: string;
|
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
|
* obvious which glyphs have a selectable text overlay and which are
|
||||||
* image-only. Also logs every onSelection event to the console.
|
* 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
|
* - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed
|
||||||
*/
|
*/
|
||||||
export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
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 utilsRef = useRef<PdfHighlighterUtils | null>(null);
|
||||||
const [didScroll, setDidScroll] = useState<string | null>(null);
|
const [didScroll, setDidScroll] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -261,7 +299,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) {
|
|||||||
onSelectionCaptured(capture, selectors);
|
onSelectionCaptured(capture, selectors);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SpikeHighlightContainer />
|
<HighlightContainer />
|
||||||
</PdfHighlighter>
|
</PdfHighlighter>
|
||||||
)}
|
)}
|
||||||
</PdfLoader>
|
</PdfLoader>
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export interface AnnotationResolutionFailedEvent {
|
|||||||
readonly reason: string;
|
readonly reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnnotationUpdatedEvent {
|
||||||
|
readonly type: "AnnotationUpdated";
|
||||||
|
readonly annotationId: AnnotationId;
|
||||||
|
readonly annotation: Annotation;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EvidenceItemCreatedEvent {
|
export interface EvidenceItemCreatedEvent {
|
||||||
readonly type: "EvidenceItemCreated";
|
readonly type: "EvidenceItemCreated";
|
||||||
readonly evidenceItemId: EvidenceItemId;
|
readonly evidenceItemId: EvidenceItemId;
|
||||||
@@ -128,6 +134,7 @@ export type EngineEvent =
|
|||||||
| DocumentRepresentationGeneratedEvent
|
| DocumentRepresentationGeneratedEvent
|
||||||
| DocumentRemovedEvent
|
| DocumentRemovedEvent
|
||||||
| AnnotationCreatedEvent
|
| AnnotationCreatedEvent
|
||||||
|
| AnnotationUpdatedEvent
|
||||||
| AnnotationResolvedEvent
|
| AnnotationResolvedEvent
|
||||||
| AnnotationResolutionFailedEvent
|
| AnnotationResolutionFailedEvent
|
||||||
| EvidenceItemCreatedEvent
|
| EvidenceItemCreatedEvent
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export interface AnnotationService {
|
|||||||
status: AnnotationResolutionStatus,
|
status: AnnotationResolutionStatus,
|
||||||
opts: { readonly confidence: number; readonly reason?: string },
|
opts: { readonly confidence: number; readonly reason?: string },
|
||||||
): Annotation;
|
): 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(
|
export function createAnnotationService(
|
||||||
@@ -98,5 +104,26 @@ export function createAnnotationService(
|
|||||||
}
|
}
|
||||||
return stored;
|
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,
|
id: EvidenceItemId,
|
||||||
source?: EvidenceItemActivatedEvent["source"],
|
source?: EvidenceItemActivatedEvent["source"],
|
||||||
): EvidenceItem;
|
): 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(
|
export function createEvidenceService(
|
||||||
@@ -123,5 +132,11 @@ export function createEvidenceService(
|
|||||||
});
|
});
|
||||||
return existing;
|
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.
|
* EvidenceSidebar — the right pane.
|
||||||
*
|
*
|
||||||
* Lists `EvidenceItem`s scoped to the currently-active document. Each row
|
* Lists `EvidenceItem`s scoped to the active document, sorted by their
|
||||||
* shows quote + commentary + status. Clicking a row emits
|
* position in the document (first PdfRectSelector's page + y). Each row:
|
||||||
* `EvidenceItemActivated` via the engine, which T08 will translate into a
|
|
||||||
* scroll-to-passage in the viewer.
|
|
||||||
*
|
*
|
||||||
* CE-WP-0004-T04 added: a per-item Export popover (Copy as Markdown /
|
* - Click → activates the evidence item (highlights its passage in
|
||||||
* Copy as HTML), a transient toast confirming the copy, and the
|
* the viewer + thickens its border).
|
||||||
* Cmd/Ctrl+Shift+C keyboard shortcut that exports the currently-active
|
* - Edit pencil → inline form to change the citation quote and
|
||||||
* evidence as Markdown.
|
* 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 {
|
import {
|
||||||
|
Fragment,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -20,14 +29,18 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import type { Annotation } from "@shared/annotation";
|
||||||
import type { EvidenceItem } from "@shared/evidence";
|
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 {
|
import {
|
||||||
useActiveDocument,
|
useActiveDocument,
|
||||||
useEngine,
|
useEngine,
|
||||||
useEngineEventTick,
|
useEngineEventTick,
|
||||||
useEngineRevision,
|
useEngineRevision,
|
||||||
useLastActivatedEvidence,
|
useLastActivatedEvidence,
|
||||||
|
usePendingSelection,
|
||||||
useScrollToAnnotation,
|
useScrollToAnnotation,
|
||||||
} from "./EngineContext";
|
} from "./EngineContext";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +48,7 @@ import {
|
|||||||
type ExportFormat,
|
type ExportFormat,
|
||||||
type ExportResult,
|
type ExportResult,
|
||||||
} from "./useExportEvidence";
|
} from "./useExportEvidence";
|
||||||
|
import { InlineCaptureForm } from "./InlineCaptureForm";
|
||||||
|
|
||||||
const TOAST_TIMEOUT_MS = 2000;
|
const TOAST_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
@@ -45,7 +59,6 @@ export interface EvidenceSidebarProps {
|
|||||||
interface ToastState {
|
interface ToastState {
|
||||||
readonly message: string;
|
readonly message: string;
|
||||||
readonly tone: "success" | "error";
|
readonly tone: "success" | "error";
|
||||||
/** Bumps on every new toast so timers don't dismiss the *next* toast. */
|
|
||||||
readonly key: number;
|
readonly key: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,23 +80,84 @@ function describeSuccess(format: ExportFormat): string {
|
|||||||
return format === "markdown" ? "Copied as Markdown" : "Copied as HTML";
|
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) {
|
export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
||||||
const engine = useEngine();
|
const engine = useEngine();
|
||||||
const { document } = useActiveDocument();
|
const { document } = useActiveDocument();
|
||||||
const { scrollTo } = useScrollToAnnotation();
|
const { scrollTo } = useScrollToAnnotation();
|
||||||
const activeId = useLastActivatedEvidence();
|
const activeId = useLastActivatedEvidence();
|
||||||
const { exportItem } = useExportEvidence();
|
const { exportItem } = useExportEvidence();
|
||||||
|
const { pending } = usePendingSelection();
|
||||||
|
|
||||||
const createTick = useEngineEventTick("EvidenceItemCreated");
|
const createTick = useEngineEventTick("EvidenceItemCreated");
|
||||||
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
const updateTick = useEngineEventTick("EvidenceItemUpdated");
|
||||||
|
const annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
|
||||||
const revision = useEngineRevision();
|
const revision = useEngineRevision();
|
||||||
|
|
||||||
const items = useMemo<readonly EvidenceItem[]>(() => {
|
// Build the sorted view-model: each item gets its order key + the
|
||||||
if (!document) return [];
|
// first annotation up-front so the render below doesn't have to
|
||||||
return engine.evidence.listByDocument(document.id);
|
// re-resolve them inside the map.
|
||||||
}, [document, engine, createTick, updateTick, revision]);
|
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 [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 [toast, setToast] = useState<ToastState | null>(null);
|
||||||
const toastKeyRef = useRef(0);
|
const toastKeyRef = useRef(0);
|
||||||
|
|
||||||
@@ -127,6 +201,48 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
|||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [activeId, engine, runExport]);
|
}, [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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
style={{
|
style={{
|
||||||
@@ -143,138 +259,62 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
|
|||||||
{!document && (
|
{!document && (
|
||||||
<p style={{ fontSize: 12, color: "#888" }}>No document open.</p>
|
<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" }}>
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
||||||
{items.map((item) => {
|
{sortedItems.map((entry, i) => {
|
||||||
const firstAnnotationId = item.annotationIds[0];
|
const slotForCapture = pendingInsertIndex === i;
|
||||||
const annotation = firstAnnotationId
|
|
||||||
? engine.annotations.get(firstAnnotationId)
|
|
||||||
: null;
|
|
||||||
const quote = annotation?.quote ?? "(no quote)";
|
|
||||||
const isActive = activeId === item.id;
|
|
||||||
const isExportOpen = openExportFor === item.id;
|
|
||||||
return (
|
return (
|
||||||
<li key={item.id} style={{ marginBottom: 8 }}>
|
<Fragment key={entry.item.id}>
|
||||||
<div
|
{slotForCapture && (
|
||||||
style={{
|
<li>
|
||||||
position: "relative",
|
<InlineCaptureForm />
|
||||||
background: isActive ? "#e8f0ff" : "#fff8d6",
|
</li>
|
||||||
border: isActive ? "2px solid #0050b3" : "1px solid #e0c050",
|
)}
|
||||||
borderRadius: 2,
|
<li style={{ marginBottom: 8 }}>
|
||||||
}}
|
<EvidenceCard
|
||||||
>
|
item={entry.item}
|
||||||
<button
|
annotation={entry.annotation}
|
||||||
onClick={() => {
|
isActive={activeId === entry.item.id}
|
||||||
engine.evidence.activate(item.id, "sidebar");
|
isExportOpen={openExportFor === entry.item.id}
|
||||||
if (firstAnnotationId) scrollTo(firstAnnotationId);
|
isEditing={editingId === entry.item.id}
|
||||||
props.onActivate?.(item);
|
editQuote={editQuote}
|
||||||
}}
|
editCommentary={editCommentary}
|
||||||
aria-current={isActive ? "true" : undefined}
|
onActivate={() =>
|
||||||
style={{
|
activateItem(entry.item, entry.annotation?.id)
|
||||||
display: "block",
|
}
|
||||||
width: "100%",
|
onBeginEdit={() => beginEdit(entry.item, entry.annotation)}
|
||||||
textAlign: "left",
|
onChangeQuote={setEditQuote}
|
||||||
background: "transparent",
|
onChangeCommentary={setEditCommentary}
|
||||||
border: "none",
|
onSaveEdit={() => saveEdit(entry.item, entry.annotation)}
|
||||||
padding: 8,
|
onCancelEdit={cancelEdit}
|
||||||
paddingRight: 80,
|
onToggleExport={() =>
|
||||||
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();
|
|
||||||
setOpenExportFor((current) =>
|
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={{
|
onCopyHtml={async () => {
|
||||||
position: "absolute",
|
setOpenExportFor(null);
|
||||||
top: 6,
|
await runExport(entry.item, "html");
|
||||||
right: 6,
|
|
||||||
fontSize: 11,
|
|
||||||
padding: "2px 6px",
|
|
||||||
background: "white",
|
|
||||||
border: "1px solid #888",
|
|
||||||
borderRadius: 3,
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
Export ▾
|
</li>
|
||||||
</button>
|
</Fragment>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{pendingInsertIndex === sortedItems.length && (
|
||||||
|
<li>
|
||||||
|
<InlineCaptureForm />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
{toast && (
|
{toast && (
|
||||||
<div
|
<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 = {
|
const menuButtonStyle: CSSProperties = {
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
|
|||||||
@@ -1,31 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* AnnotationToolbar — wires "I selected text" into "evidence appears in
|
* `InlineCaptureForm` — the "I just selected text, let me save it as
|
||||||
* the sidebar".
|
* 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
|
* Behaviour matches the pre-iteration AnnotationToolbar (which used to
|
||||||
* captures into context, then this toolbar lets the user attach commentary
|
* live above the viewer):
|
||||||
* and commit). On Save it runs the full pipeline:
|
|
||||||
*
|
*
|
||||||
* 1. `createSelectors(capture, representation)` — anchor builds the
|
* 1. `createSelectors(capture, representation)` — anchor builds the
|
||||||
* maximal selector set against the active representation.
|
* maximal selector set against the active representation.
|
||||||
* 2. `engine.annotations.create(...)` — engine mints an Annotation +
|
* 2. `engine.annotations.create(...)` — engine mints an Annotation +
|
||||||
* emits AnnotationCreated.
|
* emits AnnotationCreated.
|
||||||
* 3. `engine.evidence.create(...)` — engine mints the EvidenceItem with
|
* 3. `engine.evidence.create(...)` — engine mints the EvidenceItem
|
||||||
* the user's commentary, emits EvidenceItemCreated.
|
* with the user's commentary, emits EvidenceItemCreated.
|
||||||
*
|
|
||||||
* The sidebar re-renders via the engine event bus, so no other glue is
|
|
||||||
* needed.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { createSelectors } from "@anchor/index";
|
import { createSelectors } from "@anchor/index";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useActiveDocument,
|
useActiveDocument,
|
||||||
useEngine,
|
useEngine,
|
||||||
usePendingSelection,
|
usePendingSelection,
|
||||||
} from "./EngineContext";
|
} from "./EngineContext";
|
||||||
|
|
||||||
export function AnnotationToolbar() {
|
export function InlineCaptureForm() {
|
||||||
const engine = useEngine();
|
const engine = useEngine();
|
||||||
const { document, representation } = useActiveDocument();
|
const { document, representation } = useActiveDocument();
|
||||||
const { pending, set } = usePendingSelection();
|
const { pending, set } = usePendingSelection();
|
||||||
@@ -60,16 +61,19 @@ export function AnnotationToolbar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-testid="inline-capture-form"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid #f0c040",
|
border: "1px dashed #b78b1c",
|
||||||
background: "#fff8d6",
|
background: "#fff8d6",
|
||||||
padding: 8,
|
padding: 8,
|
||||||
fontFamily: "system-ui, sans-serif",
|
marginBottom: 8,
|
||||||
|
borderRadius: 2,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>
|
<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>
|
||||||
<div style={{ marginBottom: 6, fontStyle: "italic", color: "#444" }}>
|
<div style={{ marginBottom: 6, fontStyle: "italic", color: "#444" }}>
|
||||||
“{shortQuote}”
|
“{shortQuote}”
|
||||||
@@ -88,10 +92,19 @@ export function AnnotationToolbar() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
<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
|
Save evidence
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleDiscard} style={{ fontSize: 12, padding: "4px 10px" }}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDiscard}
|
||||||
|
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||||
|
>
|
||||||
Discard
|
Discard
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -7,33 +7,37 @@
|
|||||||
* directly. When the PDF library is swapped (or the spike is replaced),
|
* directly. When the PDF library is swapped (or the spike is replaced),
|
||||||
* only the adapter module changes; this shell stays the same.
|
* only the adapter module changes; this shell stays the same.
|
||||||
*
|
*
|
||||||
* T06 scope: load + render the active PDF + show stored annotations. The
|
* The annotation toolbar lived here in earlier iterations; CE-WP-0005-iter4
|
||||||
* selection-capture → annotation pipeline is wired in T07; the
|
* moved it into the evidence sidebar so the capture form appears in the
|
||||||
* click-to-reopen pipeline is wired in T08.
|
* 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 { PdfSpikeViewer, type StoredAnnotation } from "@anchor/index";
|
||||||
|
import type { AnnotationId } from "@shared/ids";
|
||||||
import {
|
import {
|
||||||
useActiveDocument,
|
useActiveDocument,
|
||||||
useEngine,
|
useEngine,
|
||||||
useEngineEventTick,
|
useEngineEventTick,
|
||||||
|
useLastActivatedEvidence,
|
||||||
usePendingSelection,
|
usePendingSelection,
|
||||||
useScrollToAnnotation,
|
useScrollToAnnotation,
|
||||||
} from "./EngineContext";
|
} from "./EngineContext";
|
||||||
import { AnnotationToolbar } from "./AnnotationToolbar";
|
|
||||||
import { useDebugFlag } from "./useDebugFlags";
|
import { useDebugFlag } from "./useDebugFlags";
|
||||||
|
|
||||||
export function ViewerShell() {
|
export function ViewerShell() {
|
||||||
const engine = useEngine();
|
const engine = useEngine();
|
||||||
const { document, representation } = useActiveDocument();
|
const { document, representation } = useActiveDocument();
|
||||||
const { set: setPending } = usePendingSelection();
|
const { set: setPending } = usePendingSelection();
|
||||||
const { id: scrollToId, version: scrollVersion } = useScrollToAnnotation();
|
const { id: scrollToId, version: scrollVersion, scrollTo } = useScrollToAnnotation();
|
||||||
const [debugTextLayer] = useDebugFlag("textLayer");
|
const [debugTextLayer] = useDebugFlag("textLayer");
|
||||||
|
const activeEvidenceId = useLastActivatedEvidence();
|
||||||
|
|
||||||
// The viewer needs to re-fetch its highlight list whenever annotations
|
// 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.
|
// change. The tick is included in the memo deps so the list re-resolves.
|
||||||
const annotationTick = useEngineEventTick("AnnotationCreated");
|
const annotationTick = useEngineEventTick("AnnotationCreated");
|
||||||
|
const annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
|
||||||
|
|
||||||
const annotations = useMemo<StoredAnnotation[]>(() => {
|
const annotations = useMemo<StoredAnnotation[]>(() => {
|
||||||
if (!document) return [];
|
if (!document) return [];
|
||||||
@@ -42,21 +46,39 @@ export function ViewerShell() {
|
|||||||
text: a.quote ?? "",
|
text: a.quote ?? "",
|
||||||
selectors: a.selectors,
|
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(() => {
|
const fileUrl = useMemo(() => {
|
||||||
if (!document) return null;
|
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;
|
if (document.uri) return document.uri;
|
||||||
const titleOrId = document.title ?? document.id;
|
const titleOrId = document.title ?? document.id;
|
||||||
return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`;
|
return `/fixtures/pdfs/${encodeURIComponent(titleOrId)}`;
|
||||||
}, [document]);
|
}, [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) {
|
if (!document || !representation || !fileUrl) {
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
@@ -69,7 +91,7 @@ export function ViewerShell() {
|
|||||||
fontFamily: "system-ui, sans-serif",
|
fontFamily: "system-ui, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Pick a fixture on the left to begin.
|
Upload a PDF on the left to begin.
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -84,18 +106,17 @@ export function ViewerShell() {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AnnotationToolbar />
|
|
||||||
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
||||||
<PdfSpikeViewer
|
<PdfSpikeViewer
|
||||||
// Re-key on scrollVersion so clicking the same item twice still
|
// Re-key on scrollVersion so clicking the same item twice still
|
||||||
// triggers the viewer's mount-time scroll effect. Also re-key on
|
// triggers the viewer's mount-time scroll effect. Also re-key on
|
||||||
// debugTextLayer so toggling it remounts the viewer (the CSS
|
// debugTextLayer so toggling it remounts the viewer.
|
||||||
// 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).
|
|
||||||
key={`${document.id}#${scrollVersion}#${debugTextLayer ? "d" : "n"}`}
|
key={`${document.id}#${scrollVersion}#${debugTextLayer ? "d" : "n"}`}
|
||||||
pdfUrl={fileUrl}
|
pdfUrl={fileUrl}
|
||||||
storedAnnotations={annotations}
|
storedAnnotations={annotations}
|
||||||
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
|
{...(scrollToId ? { scrollToAnnotationId: scrollToId } : {})}
|
||||||
|
activeAnnotationId={activeAnnotationId}
|
||||||
|
onHighlightClicked={handleHighlightClicked}
|
||||||
debugTextLayer={debugTextLayer}
|
debugTextLayer={debugTextLayer}
|
||||||
onSelectionCaptured={(capture, selectors) => {
|
onSelectionCaptured={(capture, selectors) => {
|
||||||
setPending({ capture, selectors });
|
setPending({ capture, selectors });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export {
|
|||||||
type ExportFormat,
|
type ExportFormat,
|
||||||
type ExportResult,
|
type ExportResult,
|
||||||
} from "./useExportEvidence";
|
} from "./useExportEvidence";
|
||||||
export { AnnotationToolbar } from "./AnnotationToolbar";
|
export { InlineCaptureForm } from "./InlineCaptureForm";
|
||||||
export { useDebugFlag, type DebugFlag } from "./useDebugFlags";
|
export { useDebugFlag, type DebugFlag } from "./useDebugFlags";
|
||||||
export {
|
export {
|
||||||
EngineProvider,
|
EngineProvider,
|
||||||
|
|||||||
@@ -23,12 +23,11 @@ import { act, render, screen, waitFor } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 { Selector } from "@shared/selector";
|
||||||
|
|
||||||
import type { PdfSelectionCapture } from "@anchor/index";
|
import type { PdfSelectionCapture } from "@anchor/index";
|
||||||
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
|
import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" };
|
||||||
|
import { seedSessionWithDoc } from "./helpers/seed-session";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mocks
|
// Mocks
|
||||||
@@ -64,38 +63,6 @@ vi.mock("@anchor/index", async (importOriginal) => {
|
|||||||
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
|
const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!;
|
||||||
const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" ");
|
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
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -139,15 +106,14 @@ describe("CE-WP-0004-T05 — PRD scenario steps 10-11 end-to-end", () => {
|
|||||||
viewerSnapshot.pdfUrl = null;
|
viewerSnapshot.pdfUrl = null;
|
||||||
viewerSnapshot.onSelectionCaptured = null;
|
viewerSnapshot.onSelectionCaptured = null;
|
||||||
globalThis.localStorage?.clear();
|
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") {
|
if (typeof window !== "undefined") {
|
||||||
history.replaceState(null, "", window.location.pathname);
|
history.replaceState(null, "", window.location.pathname);
|
||||||
}
|
}
|
||||||
|
seedSessionWithDoc({
|
||||||
|
sessionName: "T05",
|
||||||
|
documentTitle: FIXTURE.filename,
|
||||||
|
canonicalText: SYNTHETIC_CANONICAL,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -163,8 +129,7 @@ describe("CE-WP-0004-T05 — PRD scenario steps 10-11 end-to-end", () => {
|
|||||||
installClipboardSpy();
|
installClipboardSpy();
|
||||||
|
|
||||||
// --- Steps 1-5 (recap from CE-WP-0002-T09) -------------------------
|
// --- Steps 1-5 (recap from CE-WP-0002-T09) -------------------------
|
||||||
const fixtureBtn = screen.getByRole("button", { name: new RegExp(FIXTURE.id) });
|
// CE-WP-0005: doc pre-seeded into session — skip fixture-button click.
|
||||||
await user.click(fixtureBtn);
|
|
||||||
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
|
await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull());
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
viewerSnapshot.onSelectionCaptured!(
|
viewerSnapshot.onSelectionCaptured!(
|
||||||
|
|||||||
Reference in New Issue
Block a user