feat: extract review-workspace slice into @citation-evidence/work (CWORK-WP-0001)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s

Establishes citation-work as the standalone home of the review workspace,
migrated out of the citation-evidence umbrella app.

- Package/tooling scaffold: package.json, tsconfig, vite/vitest/eslint configs
  with eslint-plugin-boundaries enforcing engine/anchor/source-only imports
- Providers/hooks: EngineProvider, SessionProvider + use* hooks (T02)
- Panes: CollectionList, ViewerShell, EvidenceSidebar (T03/T04/T05)
- Capture/edit: InlineCaptureForm, EvidenceFormBody; UploadDropzone owned here
- ReviewShell: repo-owned three-pane layout with an upload slot seam
- Tests: CollectionList, InlineCaptureForm capture flow, EvidenceSidebar
  export/edit/activation (11 tests, all green)
- Anchor consumed as @citation-evidence/evidence-anchor package; @source kept
  on the umbrella facade for its local viewer-url policy
- Docs (README/SCOPE) refreshed; deferred gaps recorded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-09 01:49:28 +02:00
parent c51ef94812
commit 0cd2ca5d04
30 changed files with 7958 additions and 342 deletions

View file

@ -0,0 +1,112 @@
// @vitest-environment happy-dom
import { cleanup, 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 { CollectionList, EngineProvider, useEngine, usePdfByteStore } from "./index";
function makeDoc(suffix: string): { document: Document; representation: DocumentRepresentation } {
const id = `doc_${suffix}` as DocumentId;
const repId = `rep_${suffix}` as RepresentationId;
return {
document: {
id,
mediaType: "application/pdf",
title: `Doc ${suffix}`,
fingerprint: `hash-${suffix}`,
createdAt: "2026-05-25T00:00:00.000Z",
updatedAt: "2026-05-25T00:00:00.000Z",
},
representation: {
id: repId,
documentId: id,
representationType: "pdf-text",
contentHash: `hash-${suffix}`,
canonicalText: `body ${suffix}`,
pageMap: [{ page: 1, width: 100, height: 100 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 6, pageLength: 6 }],
generatedAt: "2026-05-25T00:00:00.000Z",
},
};
}
function Seed() {
const engine = useEngine();
const store = usePdfByteStore();
if (engine.documents.list().length === 0) {
const a = makeDoc("alpha");
const b = makeDoc("beta");
store.put(a.document.id, new Uint8Array([1, 2]));
store.put(b.document.id, new Uint8Array([3, 4]));
engine.documents.register(a);
engine.documents.register(b);
}
return null;
}
beforeEach(() => {
globalThis.localStorage?.clear();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("CollectionList (session-scoped)", () => {
it("renders one row per registered document", async () => {
render(
<EngineProvider>
<Seed />
<CollectionList title="Demo session" />
</EngineProvider>,
);
await waitFor(() => {
expect(screen.getByText("Doc alpha")).toBeTruthy();
expect(screen.getByText("Doc beta")).toBeTruthy();
});
expect(screen.getByText("Demo session")).toBeTruthy();
});
it(
"per-row delete asks for confirmation, then removes the row and revokes the blob URL",
{ timeout: 8000 },
async () => {
let revokedUrl: string | null = null;
// Patch URL.revokeObjectURL so we can confirm the byte store fired it.
const original = URL.revokeObjectURL;
URL.revokeObjectURL = (url: string) => {
revokedUrl = url;
};
try {
render(
<EngineProvider>
<Seed />
<CollectionList />
</EngineProvider>,
);
await screen.findByText("Doc alpha");
const user = userEvent.setup();
const deleteBtn = await screen.findByTestId("collection-delete-doc_alpha");
// First click → confirm prompt
await user.click(deleteBtn);
expect(deleteBtn.textContent).toContain("Confirm");
// Second click → commit
await user.click(deleteBtn);
await waitFor(() => {
expect(screen.queryByText("Doc alpha")).toBeNull();
});
expect(revokedUrl).not.toBeNull();
expect(revokedUrl!).toMatch(/^blob:/);
} finally {
URL.revokeObjectURL = original;
}
},
);
});

166
src/work/CollectionList.tsx Normal file
View file

@ -0,0 +1,166 @@
/**
* CollectionList the left pane.
*
* CE-WP-0005 turned this into a *session-scoped* list. It shows the
* documents currently registered with the active session's engine,
* with per-row Open + Delete actions and an inline upload affordance.
*
* Fixture-driven quick-start lives in
* `src/app/sessions/SampleSessions.tsx` and is no longer the default.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import type { CSSProperties } from "react";
import type { ReactNode } from "react";
import type { DocumentId } from "@shared/ids";
import {
useActiveDocumentId,
useEngine,
useEngineEventTick,
useEngineRevision,
usePdfByteStore,
} from "./EngineContext";
export interface CollectionListProps {
/**
* Slot rendered above the list typically the upload affordance.
* Kept as a slot so this component stays in `work/` (which cannot
* import `app/`).
*/
readonly upload?: ReactNode;
/** Optional session header text — typically the active session name. */
readonly title?: string;
}
export function CollectionList({ upload, title }: CollectionListProps) {
const engine = useEngine();
const byteStore = usePdfByteStore();
const { id: activeId, setId } = useActiveDocumentId();
const importedTick = useEngineEventTick("DocumentImported");
const removedTick = useEngineEventTick("DocumentRemoved");
const revision = useEngineRevision();
const documents = useMemo(
() => engine.documents.list(),
[engine, importedTick, removedTick, revision],
);
// Confirm-on-delete UX without a modal: clicking Delete asks "Confirm?",
// a second click within ~3s commits. Esc clears the pending state.
const [pendingDeleteId, setPendingDeleteId] = useState<DocumentId | null>(null);
useEffect(() => {
if (!pendingDeleteId) return;
const t = setTimeout(() => setPendingDeleteId(null), 3000);
return () => clearTimeout(t);
}, [pendingDeleteId]);
const handleDelete = useCallback(
(id: DocumentId) => {
if (pendingDeleteId !== id) {
setPendingDeleteId(id);
return;
}
// Active doc was just deleted — clear the pointer so the viewer
// unmounts before the engine drops the record.
if (activeId === id) setId(null);
byteStore.delete(id);
engine.documents.remove(id);
setPendingDeleteId(null);
},
[activeId, byteStore, engine, pendingDeleteId, setId],
);
return (
<aside
style={{
width: 280,
borderRight: "1px solid #ddd",
padding: 12,
overflow: "auto",
flex: "0 0 280px",
}}
>
<h2 style={{ marginTop: 0, fontSize: 16 }}>
{title ?? "Collection"}
</h2>
<p style={{ fontSize: 12, color: "#555", marginTop: 0 }}>
{documents.length} document{documents.length === 1 ? "" : "s"}
</p>
{upload && <div style={{ marginBottom: 8 }}>{upload}</div>}
{documents.length === 0 && !upload && (
<p style={{ fontSize: 12, color: "#888" }}>
No documents yet. Upload a PDF to get started.
</p>
)}
<ul
data-testid="collection-list-items"
style={{ listStyle: "none", padding: 0, margin: 0 }}
>
{documents.map((doc) => {
const isActive = doc.id === activeId;
const isPending = pendingDeleteId === doc.id;
return (
<li key={doc.id} style={{ marginBottom: 6 }}>
<div
style={{
// Always-blue palette to mirror the evidence-card
// "always-yellow + thicker border when active" pattern.
border: isActive ? "3px solid #0050b3" : "1px solid #0050b3",
background: "#e8f0ff",
display: "flex",
flexDirection: "column",
fontSize: 12,
}}
data-testid={`collection-item-${doc.id}`}
data-active={isActive ? "true" : "false"}
>
<button
onClick={() => setId(doc.id)}
data-testid={`collection-open-${doc.id}`}
style={openButtonStyle}
>
<div style={{ fontWeight: 600 }}>{doc.title ?? doc.id}</div>
<div style={{ color: "#666", fontSize: 11 }}>
{doc.id}
{isActive ? " · open" : ""}
</div>
</button>
<div style={{ display: "flex", justifyContent: "flex-end", padding: 4, gap: 4 }}>
<button
type="button"
onClick={() => handleDelete(doc.id)}
data-testid={`collection-delete-${doc.id}`}
style={{
fontSize: 11,
padding: "2px 8px",
border: "1px solid #b00",
background: isPending ? "#ffe5e5" : "white",
color: "#7a0000",
cursor: "pointer",
}}
>
{isPending ? "Confirm delete?" : "Delete"}
</button>
</div>
</div>
</li>
);
})}
</ul>
</aside>
);
}
const openButtonStyle: CSSProperties = {
display: "block",
width: "100%",
textAlign: "left",
background: "transparent",
border: "none",
padding: 8,
cursor: "pointer",
fontSize: 12,
};

316
src/work/EngineContext.tsx Normal file
View file

@ -0,0 +1,316 @@
/**
* Engine + active-document React context.
*
* MVP composition root for the UI: one `Engine` instance for the lifetime of
* the SPA, plus the "what's open in the viewer right now" pointer.
* `useEngine()` returns the engine; `useActiveDocument()` returns the
* currently-loaded `{document, representation}` pair, refreshed when the
* engine emits `DocumentImported` / `DocumentRepresentationGenerated`.
*
* Replaces ad-hoc engine wiring inside each component. Per the workplan
* (T07 note), state lives in a single React context; no Zustand or Redux.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Document, DocumentRepresentation } from "@shared/document";
import type { AnnotationId, DocumentId, SessionId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import {
attachPersister,
createEngine,
engineSnapshotKey,
restoreFromStorage,
type Engine,
} from "@engine/index";
import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor";
import { createPdfByteStore, type PdfByteStore } from "@source/index";
import { useContext as useReactContext } from "react";
import { SessionInternalContext } from "./SessionContextInternal";
/**
* Legacy single-bucket storage keys, kept for any user landing on a
* build without sessions. CE-WP-0005 switched persistence to per-session
* keys (`engineSnapshotKey(sessionId)`); the unscoped keys below are
* only consulted when no `sessionId` is provided to the provider.
*/
const LEGACY_STORAGE_KEY = "citation-evidence:engine-snapshot:v1";
const ACTIVE_KEY = "citation-evidence:active-document-id:v1";
function storageKeyFor(sessionId: SessionId | null): string {
return sessionId ? engineSnapshotKey(sessionId) : LEGACY_STORAGE_KEY;
}
function activeDocumentKeyFor(sessionId: SessionId | null): string {
return sessionId
? `citation-evidence:session:${sessionId}:active-document-id:v1`
: ACTIVE_KEY;
}
/**
* The pending selection lives in context (not local component state) because
* the toolbar that consumes it is rendered above the viewer, not inside it.
* `null` means "no selection waiting for a comment".
*/
export interface PendingSelection {
readonly capture: PdfSelectionCapture;
readonly selectors: readonly Selector[];
}
interface EngineContextValue {
readonly engine: Engine;
readonly byteStore: PdfByteStore;
readonly activeDocumentId: DocumentId | null;
setActiveDocumentId(id: DocumentId | null): void;
readonly pendingSelection: PendingSelection | null;
setPendingSelection(pending: PendingSelection | null): void;
readonly scrollToAnnotationId: AnnotationId | null;
/** The version counter bumps even when the same id is set twice in a row,
* so a second click on the same evidence item still triggers a scroll. */
readonly scrollVersion: number;
scrollToAnnotation(id: AnnotationId | null): void;
/**
* Bumps each time the engine's repos are mutated outside the normal
* event-emitting service path currently only on `restoreFromStorage`.
* Consumers that cache `engine.documents.list()` via `useMemo` add this
* to their deps so the restored state is reflected on remount.
*/
readonly engineRevision: number;
}
const EngineContext = createContext<EngineContextValue | null>(null);
interface EngineProviderProps {
readonly children: ReactNode;
/** Inject a pre-built engine for tests; production uses the default. */
readonly engine?: Engine;
/**
* Active session id. Drives the per-session storage key for the engine
* snapshot and the active-document pointer. `null`/omitted falls back
* to the legacy unscoped keys for back-compat with pre-CE-WP-0005
* builds.
*
* To switch sessions, parents should *re-key* this provider
* (`<EngineProvider key={sessionId} sessionId={sessionId}>`) so React
* unmounts the subtree and a fresh engine is created.
*/
readonly sessionId?: SessionId | null;
}
export function EngineProvider({
children,
engine: injected,
sessionId = null,
}: EngineProviderProps) {
const engine = useMemo(() => injected ?? createEngine(), [injected]);
// Prefer the SessionProvider's per-session byte store registry when
// available; fall back to a provider-local store for tests that mount
// EngineProvider on its own.
const sessionCtx = useReactContext(SessionInternalContext);
const [fallbackByteStore] = useState<PdfByteStore>(() => createPdfByteStore());
const byteStore =
sessionId && sessionCtx
? sessionCtx.getOrCreateByteStore(sessionId)
: fallbackByteStore;
const [activeDocumentId, setActiveDocumentIdState] = useState<DocumentId | null>(null);
// `restoreFromStorage` writes directly to the engine's repos without
// firing engine events (by design — see persistence.ts). That means
// consuming components (CollectionList etc.) wouldn't normally
// re-render to reflect the restored state. Bumping `engineRevision`
// after restore is what consumers add to their `useMemo` deps so
// the restored state shows up on (re-)mount.
const [engineRevision, setEngineRevision] = useState(0);
const [pendingSelection, setPendingSelection] = useState<PendingSelection | null>(null);
const [scrollState, setScrollState] = useState<{ id: AnnotationId | null; version: number }>({
id: null,
version: 0,
});
const snapshotKey = storageKeyFor(sessionId);
const activeDocKey = activeDocumentKeyFor(sessionId);
// Restore from localStorage on first mount, then attach the persister.
// The injected-engine path skips persistence (tests own their lifecycle).
useEffect(() => {
if (injected) return;
if (typeof globalThis.localStorage === "undefined") return;
const result = restoreFromStorage(engine, { key: snapshotKey });
if (result.restored) {
const saved = globalThis.localStorage.getItem(activeDocKey);
if (saved && engine.documents.get(saved as DocumentId)) {
setActiveDocumentIdState(saved as DocumentId);
}
// Force a re-render so consumers see the restored repos.
setEngineRevision((n) => n + 1);
}
return attachPersister(engine, { key: snapshotKey });
}, [engine, injected, snapshotKey, activeDocKey]);
// Persist the active-document pointer alongside the engine snapshot so a
// reload lands the user back where they were.
useEffect(() => {
if (injected) return;
if (typeof globalThis.localStorage === "undefined") return;
if (activeDocumentId) {
globalThis.localStorage.setItem(activeDocKey, activeDocumentId);
} else {
globalThis.localStorage.removeItem(activeDocKey);
}
}, [activeDocumentId, injected, activeDocKey]);
// Switching the active document discards any pending selection — it
// belongs to the previous document's viewer state.
const setActiveDocumentId = useCallback((id: DocumentId | null) => {
setActiveDocumentIdState(id);
setPendingSelection(null);
setScrollState((prev) => ({ id: null, version: prev.version + 1 }));
}, []);
const scrollToAnnotation = useCallback((id: AnnotationId | null) => {
setScrollState((prev) => ({ id, version: prev.version + 1 }));
}, []);
const value = useMemo<EngineContextValue>(
() => ({
engine,
byteStore,
activeDocumentId,
setActiveDocumentId,
pendingSelection,
setPendingSelection,
scrollToAnnotationId: scrollState.id,
scrollVersion: scrollState.version,
scrollToAnnotation,
engineRevision,
}),
[
engine,
byteStore,
activeDocumentId,
setActiveDocumentId,
pendingSelection,
scrollState,
scrollToAnnotation,
engineRevision,
],
);
return <EngineContext.Provider value={value}>{children}</EngineContext.Provider>;
}
export function useEngine(): Engine {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("useEngine: missing EngineProvider");
return ctx.engine;
}
export function usePdfByteStore(): PdfByteStore {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("usePdfByteStore: missing EngineProvider");
return ctx.byteStore;
}
export function useEngineRevision(): number {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("useEngineRevision: missing EngineProvider");
return ctx.engineRevision;
}
export function useActiveDocumentId(): {
readonly id: DocumentId | null;
setId(id: DocumentId | null): void;
} {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("useActiveDocumentId: missing EngineProvider");
return { id: ctx.activeDocumentId, setId: ctx.setActiveDocumentId };
}
export function useActiveDocument(): {
readonly document: Document | null;
readonly representation: DocumentRepresentation | null;
} {
const engine = useEngine();
const { id } = useActiveDocumentId();
const [tick, setTick] = useState(0);
// Re-render when documents come and go so list views stay fresh.
useEffect(() => {
const off1 = engine.bus.on("DocumentImported", () => setTick((t) => t + 1));
const off2 = engine.bus.on("DocumentRepresentationGenerated", () => setTick((t) => t + 1));
return () => {
off1();
off2();
};
}, [engine]);
const document = id ? engine.documents.get(id) : null;
const representation = id
? engine.documents.listRepresentations(id).at(-1) ?? null
: null;
// `tick` is intentionally read to silence unused-var warnings; the dep
// chain is via useState so React handles the re-render. We don't actually
// need to consume the value.
void tick;
return { document, representation };
}
/**
* Subscribe to a single engine event type and trigger a re-render each time
* it fires. Returns the current monotonic counter pure state-marker.
*/
export function useEngineEventTick<T extends Parameters<Engine["bus"]["on"]>[0]>(
type: T,
): number {
const engine = useEngine();
const [tick, setTick] = useState(0);
const bump = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => engine.bus.on(type, bump), [engine, type, bump]);
return tick;
}
export function usePendingSelection(): {
readonly pending: PendingSelection | null;
set(pending: PendingSelection | null): void;
} {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("usePendingSelection: missing EngineProvider");
return { pending: ctx.pendingSelection, set: ctx.setPendingSelection };
}
export function useScrollToAnnotation(): {
readonly id: AnnotationId | null;
readonly version: number;
scrollTo(id: AnnotationId | null): void;
} {
const ctx = useContext(EngineContext);
if (!ctx) throw new Error("useScrollToAnnotation: missing EngineProvider");
return {
id: ctx.scrollToAnnotationId,
version: ctx.scrollVersion,
scrollTo: ctx.scrollToAnnotation,
};
}
/**
* Track the most-recent `EvidenceItemActivated` event id from the engine
* bus. Returns `null` until something is activated. UI components that
* highlight "the active evidence" subscribe via this hook so they don't
* need to import the binder's active-state machine directly.
*/
export function useLastActivatedEvidence(): import("@shared/ids").EvidenceItemId | null {
const engine = useEngine();
const [id, setId] = useState<import("@shared/ids").EvidenceItemId | null>(null);
useEffect(() => {
return engine.bus.on("EvidenceItemActivated", (e) => setId(e.evidenceItemId));
}, [engine]);
return id;
}

View file

@ -0,0 +1,104 @@
/**
* EvidenceFormBody the shared "citation + commentary" editor.
*
* One form drives both:
* - the *capture* flow (creating evidence from a fresh selection
* inside `InlineCaptureForm`), and
* - the *edit* flow (modifying an existing evidence card inside
* `EvidenceSidebar`).
*
* Keeping a single component means changes to the field layout, hint
* placement, or save-button copy land in one file and apply to both
* surfaces. Callers control the labels and any badge/helper text.
*/
import type { CSSProperties, ReactNode } from "react";
export interface EvidenceFormBodyProps {
readonly quote: string;
readonly commentary: string;
onChangeQuote(next: string): void;
onChangeCommentary(next: string): void;
onSave(): void;
onCancel(): void;
/** Save-button label, defaults to "Save". */
readonly saveLabel?: string;
/** Cancel-button label, defaults to "Cancel". */
readonly cancelLabel?: string;
/** Short caption rendered at the top of the form, e.g. a selector
* count badge or "Editing". */
readonly badge?: ReactNode;
/** Inline note shown under the buttons (e.g. "won't move the
* marked passage" when editing an existing item). */
readonly helper?: ReactNode;
/** data-testid prefix for the input + button hooks. */
readonly testidPrefix: string;
}
export function EvidenceFormBody(p: EvidenceFormBodyProps) {
const saveLabel = p.saveLabel ?? "Save";
const cancelLabel = p.cancelLabel ?? "Cancel";
return (
<div style={{ padding: 8, fontSize: 12 }}>
{p.badge && (
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
)}
<label style={labelStyle}>Citation text</label>
<textarea
value={p.quote}
onChange={(e) => p.onChangeQuote(e.target.value)}
data-testid={`${p.testidPrefix}-quote`}
rows={3}
style={{
...textareaStyle,
fontStyle: "italic",
}}
/>
<label style={labelStyle}>Commentary</label>
<textarea
value={p.commentary}
onChange={(e) => p.onChangeCommentary(e.target.value)}
data-testid={`${p.testidPrefix}-commentary`}
rows={2}
placeholder="(optional)"
style={textareaStyle}
/>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button
type="button"
onClick={p.onSave}
data-testid={`${p.testidPrefix}-save`}
style={{ fontSize: 12, padding: "4px 10px" }}
>
{saveLabel}
</button>
<button
type="button"
onClick={p.onCancel}
data-testid={`${p.testidPrefix}-cancel`}
style={{ fontSize: 12, padding: "4px 10px" }}
>
{cancelLabel}
</button>
{p.helper && (
<span style={{ fontSize: 11, color: "#888" }}>{p.helper}</span>
)}
</div>
</div>
);
}
const labelStyle: CSSProperties = {
display: "block",
color: "#666",
fontSize: 11,
marginBottom: 2,
};
const textareaStyle: CSSProperties = {
width: "100%",
boxSizing: "border-box",
fontSize: 12,
padding: 4,
marginBottom: 6,
};

View file

@ -0,0 +1,248 @@
/**
* EvidenceSidebar export-flow tests (CE-WP-0004-T04).
*
* Covers:
* - Export popover opens on click, exposes Markdown + HTML options.
* - Copy as Markdown writes the rendered card to navigator.clipboard
* and shows a success toast.
* - Copy as HTML writes the HTML card.
* - Clipboard failure surfaces an error toast.
* - Cmd/Ctrl+Shift+C exports the active evidence as Markdown.
*
* Uses a real engine (not a mock) so the renderer + service plumbing is
* exercised end-to-end up to the clipboard boundary.
*/
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEngine, type Engine } from "@engine/index";
import type { DocumentId } from "@shared/ids";
import { newId } from "@shared/ids";
import { EngineProvider, EvidenceSidebar } from "./index";
// happy-dom ships a real `navigator.clipboard.writeText` that stashes
// the text into an internal Blob. We spy on the prototype method so
// every test's clicks route through our mock without fighting the
// Navigator class's `#clipboard` private field. `mockImplementation`
// per test swaps in the success or failure behaviour.
let writeText: ReturnType<typeof vi.spyOn> &
((text: string) => Promise<void>);
let lastEngine: Engine | null = null;
let activeDocumentId: DocumentId | null = null;
function seedEngine(): { engine: Engine; documentId: DocumentId } {
const engine = createEngine();
const now = "2026-05-25T00:00:00.000Z";
const { document } = engine.documents.register({
document: {
id: newId("document"),
title: "Order from 14 Mar 2024",
mediaType: "application/pdf",
fingerprint: "test-fingerprint",
createdAt: now,
updatedAt: now,
},
representation: {
id: newId("representation"),
documentId: newId("document"),
representationType: "pdf-text",
contentHash: "test-fingerprint",
canonicalText: "Die Frist endet am 31. März 2024.",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 34, pageLength: 34 }],
generatedAt: now,
},
});
const annotation = engine.annotations.create({
documentId: document.id,
selectors: [
{ type: "TextQuoteSelector", exact: "Die Frist endet am 31. März 2024." },
],
quote: "Die Frist endet am 31. März 2024.",
});
engine.evidence.create({
annotationIds: [annotation.id],
commentary: "Deadline clause for the buyer.",
});
return { engine, documentId: document.id };
}
function Harness({ engine, documentId }: { engine: Engine; documentId: DocumentId }) {
lastEngine = engine;
activeDocumentId = documentId;
return (
<EngineProvider engine={engine}>
<ActiveDocumentSetter documentId={documentId}>
<EvidenceSidebar />
</ActiveDocumentSetter>
</EngineProvider>
);
}
import { useEffect } from "react";
import { useActiveDocumentId } from "./EngineContext";
function ActiveDocumentSetter({
documentId,
children,
}: {
documentId: DocumentId;
children: React.ReactNode;
}) {
const { setId } = useActiveDocumentId();
useEffect(() => {
setId(documentId);
}, [documentId, setId]);
return <>{children}</>;
}
function installClipboard(impl: (text: string) => Promise<void>) {
writeText = vi.fn(impl) as unknown as typeof writeText;
// happy-dom recreates its Clipboard prototype across the
// beforeEach → render boundary in some test passes, so patches
// applied earlier (e.g. in beforeEach) get silently discarded.
// Patching the *current* prototype right before the action under
// test (i.e. after render) is reliable.
const proto = Object.getPrototypeOf(navigator.clipboard);
Object.defineProperty(proto, "writeText", {
configurable: true,
writable: true,
value: writeText,
});
}
describe("EvidenceSidebar — export flow (CE-WP-0004-T04)", () => {
beforeEach(() => {
lastEngine = null;
activeDocumentId = null;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("renders an Export toggle for each evidence item", async () => {
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
await screen.findByText(/Deadline clause for the buyer/);
const toggle = await screen.findByLabelText("Export evidence item");
expect(toggle).toBeTruthy();
});
it("opens the menu and copies Markdown to the clipboard", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
await screen.findByText(/Deadline clause for the buyer/);
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const written = writeText.mock.calls[0]![0] as string;
expect(written).toContain("> Die Frist endet am 31. März 2024.");
expect(written).toContain("— *Order from 14 Mar 2024*");
expect(written).toContain("[Open source](/viewer?document=");
expect(written).toContain("Deadline clause for the buyer.");
const toast = await screen.findByTestId("export-toast");
expect(toast.getAttribute("data-tone")).toBe("success");
expect(toast.textContent).toContain("Copied as Markdown");
});
it("copies HTML when the HTML menu item is clicked", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
await screen.findByText(/Deadline clause for the buyer/);
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(await screen.findByRole("menuitem", { name: "Copy as HTML" }));
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const written = writeText.mock.calls[0]![0] as string;
expect(written).toContain('<aside class="citation-card">');
expect(written).toContain('<blockquote class="citation-card__quote">');
const toast = await screen.findByTestId("export-toast");
expect(toast.textContent).toContain("Copied as HTML");
});
it("surfaces a clipboard write failure via the error toast", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => {
throw new Error("denied");
});
await screen.findByText(/Deadline clause for the buyer/);
await user.click(await screen.findByLabelText("Export evidence item"));
await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" }));
const toast = await screen.findByTestId("export-toast");
expect(toast.getAttribute("data-tone")).toBe("error");
expect(toast.textContent).toMatch(/clipboard write was rejected|Copy failed/);
});
it("Cmd+Shift+C exports the active evidence as Markdown", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
// Activate the item first by clicking the card body. The card body is
// the button that contains the quote/commentary, not the Export toggle.
const itemButton = await screen.findByText(/Deadline clause for the buyer/);
await user.click(itemButton);
expect(writeText).not.toHaveBeenCalled();
await user.keyboard("{Control>}{Shift>}c{/Shift}{/Control}");
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
const written = writeText.mock.calls[0]![0] as string;
expect(written).toContain("> Die Frist endet am 31. März 2024.");
const toast = await screen.findByTestId("export-toast");
expect(toast.textContent).toContain("Copied as Markdown");
});
it("edits an evidence item's quote and commentary in place", async () => {
const user = userEvent.setup();
const { engine, documentId } = seedEngine();
render(<Harness engine={engine} documentId={documentId} />);
installClipboard(async () => undefined);
await screen.findByText(/Deadline clause for the buyer/);
const itemId = engine.evidence.listByDocument(documentId)[0]!.id;
// Open the inline edit form for the (single) evidence card.
await user.click(screen.getByLabelText("Edit citation and commentary"));
const commentary = await screen.findByTestId(
`evidence-edit-${itemId}-commentary`,
);
await user.clear(commentary);
await user.type(commentary, "Revised deadline note.");
const quote = screen.getByTestId(`evidence-edit-${itemId}-quote`);
await user.clear(quote);
await user.type(quote, "Neue Frist: 30. April 2024.");
await user.click(screen.getByTestId(`evidence-edit-${itemId}-save`));
// Edit form closes and the engine reflects the new quote + commentary.
await waitFor(() => {
expect(
screen.queryByTestId(`evidence-edit-${itemId}-commentary`),
).toBeNull();
});
const item = engine.evidence.listByDocument(documentId)[0]!;
expect(item.commentary).toBe("Revised deadline note.");
const annotationId = item.annotationIds[0]!;
const annotation = engine.annotations.get(annotationId);
expect(annotation?.quote).toBe("Neue Frist: 30. April 2024.");
await screen.findByText(/Revised deadline note/);
});
});
// Silence "unused" warnings for the test-scope captures we kept for
// debugging — TypeScript would otherwise complain.
void lastEngine;
void activeDocumentId;

View file

@ -0,0 +1,526 @@
/**
* EvidenceSidebar the right pane.
*
* Lists `EvidenceItem`s scoped to the active document, sorted by their
* position in the document (first PdfRectSelector's page + y). Each row:
*
* - 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,
useRef,
useState,
type CSSProperties,
} from "react";
import type { Annotation } from "@shared/annotation";
import type { EvidenceItem } from "@shared/evidence";
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 {
useExportEvidence,
type ExportFormat,
type ExportResult,
} from "./useExportEvidence";
import { EvidenceFormBody } from "./EvidenceFormBody";
import { InlineCaptureForm } from "./InlineCaptureForm";
const TOAST_TIMEOUT_MS = 2000;
export interface EvidenceSidebarProps {
onActivate?(item: EvidenceItem): void;
}
interface ToastState {
readonly message: string;
readonly tone: "success" | "error";
readonly key: number;
}
function describeError(result: Extract<ExportResult, { ok: false }>): string {
switch (result.reason) {
case "no-annotation":
case "annotation-missing":
return "Cannot export: no source annotation.";
case "document-missing":
return "Cannot export: source document missing.";
case "clipboard-unavailable":
return "Clipboard not available in this browser.";
case "clipboard-write-failed":
return "Copy failed — clipboard write was rejected.";
}
}
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();
// 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);
const showToast = useCallback((message: string, tone: "success" | "error") => {
toastKeyRef.current += 1;
const key = toastKeyRef.current;
setToast({ message, tone, key });
}, []);
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => {
setToast((current) => (current && current.key === toast.key ? null : current));
}, TOAST_TIMEOUT_MS);
return () => clearTimeout(t);
}, [toast]);
const runExport = useCallback(
async (item: EvidenceItem, format: ExportFormat) => {
const result = await exportItem(item, format);
if (result.ok) showToast(describeSuccess(result.format), "success");
else showToast(describeError(result), "error");
},
[exportItem, showToast],
);
// Cmd/Ctrl+Shift+C: export the active evidence as Markdown.
useEffect(() => {
if (typeof window === "undefined") return;
const handler = (e: KeyboardEvent) => {
const modifier = e.metaKey || e.ctrlKey;
if (!modifier || !e.shiftKey) return;
if (e.key !== "C" && e.key !== "c") return;
if (!activeId) return;
const item = engine.evidence.get(activeId);
if (!item) return;
e.preventDefault();
void runExport(item, "markdown");
};
window.addEventListener("keydown", handler);
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={{
width: 320,
borderLeft: "1px solid #ddd",
padding: 12,
overflow: "auto",
flex: "0 0 320px",
fontFamily: "system-ui, sans-serif",
position: "relative",
}}
>
<h2 style={{ marginTop: 0, fontSize: 16 }}>Evidence</h2>
{!document && (
<p style={{ fontSize: 12, color: "#888" }}>No document open.</p>
)}
{document && sortedItems.length === 0 && !pending && (
<p style={{ fontSize: 12, color: "#888" }}>
No evidence yet. Drag-select a passage in the viewer to start a
capture.
</p>
)}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{sortedItems.map((entry, i) => {
const slotForCapture = pendingInsertIndex === i;
return (
<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 === entry.item.id ? null : entry.item.id,
)
}
onCopyMarkdown={async () => {
setOpenExportFor(null);
await runExport(entry.item, "markdown");
}}
onCopyHtml={async () => {
setOpenExportFor(null);
await runExport(entry.item, "html");
}}
/>
</li>
</Fragment>
);
})}
{pendingInsertIndex === sortedItems.length && (
<li>
<InlineCaptureForm />
</li>
)}
</ul>
{toast && (
<div
role="status"
aria-live="polite"
data-testid="export-toast"
data-tone={toast.tone}
style={{
position: "absolute",
left: 12,
right: 12,
bottom: 12,
padding: 8,
fontSize: 12,
background: toast.tone === "success" ? "#d6f0d6" : "#f9d6d6",
color: toast.tone === "success" ? "#0a5a0a" : "#7a0000",
border: `1px solid ${toast.tone === "success" ? "#0a5a0a" : "#7a0000"}`,
borderRadius: 3,
}}
>
{toast.message}
</div>
)}
</aside>
);
}
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 ? (
<EvidenceFormBody
quote={p.editQuote}
commentary={p.editCommentary}
onChangeQuote={p.onChangeQuote}
onChangeCommentary={p.onChangeCommentary}
onSave={p.onSaveEdit}
onCancel={p.onCancelEdit}
saveLabel="Save"
cancelLabel="Cancel"
helper="The marked passage in the document stays the same."
testidPrefix={`evidence-edit-${p.item.id}`}
/>
) : (
<>
<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",
border: "none",
padding: "4px 8px",
cursor: "pointer",
fontSize: 12,
};

View file

@ -0,0 +1,213 @@
/**
* InlineCaptureForm capture-flow tests (CWORK-WP-0001 T04).
*
* Covers the selection evidence pipeline without mounting the heavy
* PDF viewer:
* - a pending selection seeds the form's citation text,
* - Save mints an annotation + evidence item on the real engine and
* clears the pending selection,
* - Discard clears the pending selection without creating evidence,
* - switching the active document resets the pending selection (the
* capture belonged to the previous document's viewer state).
*
* Uses a real engine so the anchor `createSelectors` + engine services
* are exercised end-to-end.
*/
// @vitest-environment happy-dom
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createEngine, type Engine } from "@engine/index";
import type { DocumentId } from "@shared/ids";
import { newId } from "@shared/ids";
import type { PdfSelectionCapture } from "@citation-evidence/evidence-anchor";
import { EngineProvider, InlineCaptureForm } from "./index";
import { useActiveDocumentId, usePendingSelection } from "./EngineContext";
const NOW = "2026-05-25T00:00:00.000Z";
function seedEngine(): { engine: Engine; documentId: DocumentId } {
const engine = createEngine();
const documentId = newId("document") as DocumentId;
engine.documents.register({
document: {
id: documentId,
title: "Contract",
mediaType: "application/pdf",
fingerprint: "fp-contract",
createdAt: NOW,
updatedAt: NOW,
},
representation: {
id: newId("representation"),
documentId,
representationType: "pdf-text",
contentHash: "fp-contract",
canonicalText: "Die Frist endet am 31. März 2024.",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 34, pageLength: 34 }],
generatedAt: NOW,
},
});
// A second document to switch to (exercises the pending reset).
const otherId = newId("document") as DocumentId;
engine.documents.register({
document: {
id: otherId,
title: "Appendix",
mediaType: "application/pdf",
fingerprint: "fp-appendix",
createdAt: NOW,
updatedAt: NOW,
},
representation: {
id: newId("representation"),
documentId: otherId,
representationType: "pdf-text",
contentHash: "fp-appendix",
canonicalText: "Appendix body.",
pageMap: [{ page: 1, width: 595, height: 842 }],
offsetMap: [{ page: 1, globalStart: 0, globalEnd: 14, pageLength: 14 }],
generatedAt: NOW,
},
});
return { engine, documentId };
}
const CAPTURE: PdfSelectionCapture = {
kind: "pdf",
text: "Die Frist endet am 31. März 2024.",
page: 1,
rects: [{ x: 0.1, y: 0.1, width: 0.5, height: 0.02 }],
};
/** Test controls: activate a document + arm/switch the pending selection. */
function Controls({
documentId,
otherLabel,
}: {
readonly documentId: DocumentId;
readonly otherLabel: string;
}) {
const { id, setId } = useActiveDocumentId();
const { set } = usePendingSelection();
return (
<div>
<button data-testid="activate" onClick={() => setId(documentId)}>
activate
</button>
<button
data-testid="arm-pending"
onClick={() => set({ capture: CAPTURE, selectors: [] })}
>
arm
</button>
<button
data-testid="switch-doc"
onClick={() => {
const other = otherLabel as DocumentId;
setId(other);
}}
>
switch
</button>
<span data-testid="active-id">{id ?? "none"}</span>
</div>
);
}
function Harness({ engine, documentId, otherId }: {
readonly engine: Engine;
readonly documentId: DocumentId;
readonly otherId: DocumentId;
}) {
return (
<EngineProvider engine={engine}>
<Controls documentId={documentId} otherLabel={otherId} />
<InlineCaptureForm />
</EngineProvider>
);
}
beforeEach(() => {
globalThis.localStorage?.clear();
});
afterEach(() => {
cleanup();
});
describe("InlineCaptureForm capture flow", () => {
it("seeds the form from the pending selection and saves evidence", async () => {
const { engine, documentId } = seedEngine();
const otherId = engine.documents.list().find((d) => d.id !== documentId)!.id;
const user = userEvent.setup();
render(<Harness engine={engine} documentId={documentId} otherId={otherId} />);
await user.click(screen.getByTestId("activate"));
await user.click(screen.getByTestId("arm-pending"));
const quote = await screen.findByTestId("inline-capture-quote");
expect((quote as HTMLTextAreaElement).value).toBe(
"Die Frist endet am 31. März 2024.",
);
await user.type(
screen.getByTestId("inline-capture-commentary"),
"Deadline clause",
);
await user.click(screen.getByTestId("inline-capture-save"));
await waitFor(() => {
expect(screen.queryByTestId("inline-capture-form")).toBeNull();
});
// One annotation + one evidence item were minted on the real engine.
expect(engine.annotations.listByDocument(documentId)).toHaveLength(1);
expect(engine.evidence.listByDocument(documentId)).toHaveLength(1);
expect(engine.evidence.listByDocument(documentId)[0]!.commentary).toBe("Deadline clause");
});
it("discards the pending selection without creating evidence", async () => {
const { engine, documentId } = seedEngine();
const otherId = engine.documents.list().find((d) => d.id !== documentId)!.id;
const user = userEvent.setup();
render(<Harness engine={engine} documentId={documentId} otherId={otherId} />);
await user.click(screen.getByTestId("activate"));
await user.click(screen.getByTestId("arm-pending"));
await screen.findByTestId("inline-capture-form");
await user.click(screen.getByTestId("inline-capture-cancel"));
await waitFor(() => {
expect(screen.queryByTestId("inline-capture-form")).toBeNull();
});
expect(engine.evidence.listByDocument(documentId)).toHaveLength(0);
});
it("resets the pending selection when the active document changes", async () => {
const { engine, documentId } = seedEngine();
const otherId = engine.documents.list().find((d) => d.id !== documentId)!.id;
const user = userEvent.setup();
render(<Harness engine={engine} documentId={documentId} otherId={otherId} />);
await user.click(screen.getByTestId("activate"));
await user.click(screen.getByTestId("arm-pending"));
await screen.findByTestId("inline-capture-form");
// Switching documents drops the pending capture from the previous doc.
await user.click(screen.getByTestId("switch-doc"));
await waitFor(() => {
expect(screen.queryByTestId("inline-capture-form")).toBeNull();
});
expect(engine.evidence.listByDocument(documentId)).toHaveLength(0);
});
});

View file

@ -0,0 +1,98 @@
/**
* `InlineCaptureForm` the "I just selected text, let me save it as
* evidence" form. Renders only when a `pendingSelection` is set;
* `EvidenceSidebar` slots it into the right position in document
* order so the new capture appears between the cards that bracket
* it.
*
* Uses the shared `EvidenceFormBody` so the field layout matches the
* edit form on an existing card. The user can refine the
* auto-captured citation text before saving (handy when the
* underlying text layer captured fragments they can paste in the
* correct quote without re-selecting).
*
* Save pipeline:
* 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.
*/
import { useEffect, useState } from "react";
import { createSelectors } from "@citation-evidence/evidence-anchor";
import {
useActiveDocument,
useEngine,
usePendingSelection,
} from "./EngineContext";
import { EvidenceFormBody } from "./EvidenceFormBody";
export function InlineCaptureForm() {
const engine = useEngine();
const { document, representation } = useActiveDocument();
const { pending, set } = usePendingSelection();
const [quote, setQuote] = useState("");
const [commentary, setCommentary] = useState("");
// Re-seed the form whenever a fresh selection arrives.
useEffect(() => {
setQuote(pending?.capture.text ?? "");
setCommentary("");
}, [pending]);
if (!pending || !document || !representation) return null;
const handleSave = () => {
const selectors = createSelectors(pending.capture, representation);
const annotation = engine.annotations.create({
documentId: document.id,
representationId: representation.id,
selectors,
quote: quote.trim().length > 0 ? quote : pending.capture.text,
});
engine.evidence.create({
annotationIds: [annotation.id],
...(commentary.trim().length > 0 ? { commentary: commentary.trim() } : {}),
});
set(null);
};
const handleDiscard = () => set(null);
const selectorCount = pending.selectors.length;
return (
<div
data-testid="inline-capture-form"
style={{
border: "1px dashed #b78b1c",
background: "#fff8d6",
marginBottom: 8,
borderRadius: 2,
}}
>
<EvidenceFormBody
quote={quote}
commentary={commentary}
onChangeQuote={setQuote}
onChangeCommentary={setCommentary}
onSave={handleSave}
onCancel={handleDiscard}
saveLabel="Save evidence"
cancelLabel="Discard"
badge={
<>
New evidence (
{selectorCount} selector{selectorCount === 1 ? "" : "s"}
) refine the citation if needed
</>
}
testidPrefix="inline-capture"
/>
</div>
);
}

18
src/work/README.md Normal file
View file

@ -0,0 +1,18 @@
# `src/work/` — review workspace package surface
This directory is the extracted home of the review workflow UI:
collection pane, viewer shell, evidence sidebar, and annotation capture flow.
It is published as the `@citation-evidence/work` package (barrel: `index.ts`)
and consumed by the `citation-evidence` umbrella app's review mode.
Extraction completed under `CWORK-WP-0001`; the umbrella app no longer carries a
local `src/work/` copy.
Imports from `work/` may depend on:
- `@shared/*`, `@engine/*` (from `@citation-evidence/engine`)
- `@citation-evidence/evidence-anchor`
- `@source/*` (PDF ingestion + local `viewer-url` policy)
They may not import from binder- or umbrella-app-owned code. These edges are
enforced by `eslint-plugin-boundaries`.

56
src/work/ReviewShell.tsx Normal file
View file

@ -0,0 +1,56 @@
/**
* ReviewShell the repo-owned three-pane review layout.
*
*
* Collection Document Viewer Evidence
* List Sidebar
*
*
* This is the extracted successor to the umbrella app's `ReviewLayout`
* (CWORK-WP-0001 T03/T05). The umbrella app now composes review mode by
* mounting `<ReviewShell>` inside its `SessionProvider`/`EngineProvider`
* rather than owning the pane layout itself.
*
* Upload seam (CWORK-WP-0001 T03 decision): the left pane's upload
* affordance is a slot. When a consumer passes `upload`, that node is
* used; otherwise the package's own `UploadDropzone` is rendered, so the
* shell is fully functional without any umbrella-local imports.
*/
import type { ReactNode } from "react";
import { CollectionList } from "./CollectionList";
import { EvidenceSidebar } from "./EvidenceSidebar";
import { UploadDropzone } from "./UploadDropzone";
import { ViewerShell } from "./ViewerShell";
import { useActiveSession } from "./SessionContext";
export interface ReviewShellProps {
/**
* Optional upload affordance rendered above the collection list. When
* omitted, the package's default `UploadDropzone` is used.
*/
readonly upload?: ReactNode;
/** Optional collection-pane title; defaults to the active session name. */
readonly title?: string;
}
export function ReviewShell({ upload, title }: ReviewShellProps) {
const session = useActiveSession();
return (
<div
style={{
display: "flex",
height: "100%",
fontFamily: "system-ui, sans-serif",
}}
>
<CollectionList
upload={upload ?? <UploadDropzone />}
title={title ?? session?.name ?? "Collection"}
/>
<ViewerShell />
<EvidenceSidebar />
</div>
);
}

241
src/work/SessionContext.tsx Normal file
View file

@ -0,0 +1,241 @@
/**
* SessionProvider owns the cross-session services.
*
* Layers above the per-session `EngineProvider`. Responsibilities:
*
* - hold the `SessionService` + its own bus instance
* - hydrate sessions from `localStorage` on first mount
* - expose `useSessionService()`, `useActiveSession()`, hooks to
* subscribe to session bus events
*
* Switching sessions is a side effect of calling
* `useSessionService().setActive(...)`. The hook tracks the active id
* via the bus's `SessionActivated` event so the value stays a single
* source of truth.
*
* NB: this module does *not* mount the `EngineProvider`. T04 wires the
* top-level App so the EngineProvider is keyed by the active session id
* (`<EngineProvider key={activeId} sessionId={activeId} />`). Keeping
* the two providers separate lets tests target one without the other.
*/
import {
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { SessionId } from "@shared/ids";
import type { Session } from "@shared/session";
import {
attachSessionPersister,
createEventBus,
createInMemorySessionRepository,
createSessionService,
restoreSessionsFromStorage,
type EventBus,
type SessionService,
} from "@engine/index";
import { createPdfByteStore, type PdfByteStore } from "@source/index";
import {
SessionInternalContext,
type SessionInternalContextValue,
} from "./SessionContextInternal";
const SessionContext = SessionInternalContext;
type SessionContextValue = SessionInternalContextValue;
interface SessionProviderProps {
readonly children: ReactNode;
/** Inject a pre-built service for tests; production uses the default. */
readonly service?: SessionService;
readonly bus?: EventBus;
}
export function SessionProvider({
children,
service: injectedService,
bus: injectedBus,
}: SessionProviderProps) {
const bus = useMemo(() => injectedBus ?? createEventBus(), [injectedBus]);
const [repo] = useState(() => createInMemorySessionRepository());
const service = useMemo(
() => injectedService ?? createSessionService(repo, bus),
[injectedService, repo, bus],
);
const [activeId, setActiveId] = useState<SessionId | null>(null);
const [hydrated, setHydrated] = useState<boolean>(false);
const [byteStores] = useState<Map<SessionId, PdfByteStore>>(() => new Map());
const [sessionVersions, setSessionVersions] = useState<ReadonlyMap<SessionId, number>>(
() => new Map(),
);
const getOrCreateByteStore = useCallback(
(sessionId: SessionId) => {
let store = byteStores.get(sessionId);
if (!store) {
store = createPdfByteStore();
byteStores.set(sessionId, store);
}
return store;
},
[byteStores],
);
const getSessionVersion = useCallback(
(sessionId: SessionId) => sessionVersions.get(sessionId) ?? 0,
[sessionVersions],
);
const bumpSessionVersion = useCallback((sessionId: SessionId) => {
setSessionVersions((prev) => {
const next = new Map(prev);
next.set(sessionId, (prev.get(sessionId) ?? 0) + 1);
return next;
});
}, []);
// Hydrate from storage, then attach the persister.
useEffect(() => {
if (injectedService) {
setHydrated(true);
return;
}
if (typeof globalThis.localStorage === "undefined") {
setHydrated(true);
return;
}
const result = restoreSessionsFromStorage(repo, service);
if (result.restored && result.activeSessionId) {
setActiveId(result.activeSessionId);
}
setHydrated(true);
return attachSessionPersister(service, bus);
}, [bus, injectedService, repo, service]);
// Keep the active-id mirror in sync with the bus.
useEffect(() => {
return bus.on("SessionActivated", (e) => {
setActiveId(e.sessionId);
});
}, [bus]);
// Drop byte stores for sessions that get deleted (revoking blob URLs).
useEffect(() => {
return bus.on("SessionDeleted", (e) => {
const store = byteStores.get(e.sessionId);
if (store) {
store.clear();
byteStores.delete(e.sessionId);
}
});
}, [bus, byteStores]);
const value = useMemo<SessionContextValue>(
() => ({
service,
bus,
activeId,
hydrated,
getOrCreateByteStore,
getSessionVersion,
bumpSessionVersion,
}),
[
service,
bus,
activeId,
hydrated,
getOrCreateByteStore,
getSessionVersion,
bumpSessionVersion,
],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}
export function useSessionService(): SessionService {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionService: missing SessionProvider");
return ctx.service;
}
export function useSessionBus(): EventBus {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionBus: missing SessionProvider");
return ctx.bus;
}
export function useActiveSessionId(): SessionId | null {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useActiveSessionId: missing SessionProvider");
return ctx.activeId;
}
export function useActiveSession(): Session | null {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useActiveSession: missing SessionProvider");
return ctx.activeId ? ctx.service.get(ctx.activeId) : null;
}
export function useSessionsHydrated(): boolean {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionsHydrated: missing SessionProvider");
return ctx.hydrated;
}
export function useSessionByteStore(sessionId: SessionId): PdfByteStore {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionByteStore: missing SessionProvider");
return ctx.getOrCreateByteStore(sessionId);
}
export function useSessionVersionBumper(): (sessionId: SessionId) => void {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionVersionBumper: missing SessionProvider");
return ctx.bumpSessionVersion;
}
export function useSessionVersion(sessionId: SessionId): number {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionVersion: missing SessionProvider");
return ctx.getSessionVersion(sessionId);
}
export function useSessionByteStoreRegistry(): {
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
} {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionByteStoreRegistry: missing SessionProvider");
return { getOrCreateByteStore: ctx.getOrCreateByteStore };
}
/**
* Re-render whenever the session list mutates. Returns a tick counter
* that callers can use as a `useMemo`/`useEffect` dependency to read
* `service.list()` lazily.
*/
export function useSessionListTick(): number {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSessionListTick: missing SessionProvider");
const [tick, setTick] = useState(0);
useEffect(() => {
const bump = () => setTick((t) => t + 1);
const offs = [
ctx.bus.on("SessionCreated", bump),
ctx.bus.on("SessionRenamed", bump),
ctx.bus.on("SessionDeleted", bump),
];
return () => {
for (const off of offs) off();
};
}, [ctx.bus]);
return tick;
}

View file

@ -0,0 +1,27 @@
/**
* Internal: the React Context object backing `SessionProvider`.
*
* Lives in its own module so `EngineContext.tsx` can subscribe without
* importing the full `SessionContext.tsx` (which would re-export the
* EngineProvider via the same `@work` barrel and create a circular
* dependency at module-init time).
*/
import { createContext } from "react";
import type { SessionId } from "@shared/ids";
import type { EventBus, SessionService } from "@engine/index";
import type { PdfByteStore } from "@source/index";
export interface SessionInternalContextValue {
readonly service: SessionService;
readonly bus: EventBus;
readonly activeId: SessionId | null;
readonly hydrated: boolean;
getOrCreateByteStore(sessionId: SessionId): PdfByteStore;
getSessionVersion(sessionId: SessionId): number;
bumpSessionVersion(sessionId: SessionId): void;
}
export const SessionInternalContext = createContext<SessionInternalContextValue | null>(null);

198
src/work/UploadDropzone.tsx Normal file
View file

@ -0,0 +1,198 @@
/**
* UploadDropzone drag-drop + file-picker for uploading PDFs into the
* active session.
*
* On every successful drop:
* 1. read each File as bytes,
* 2. run the source-layer `ingestPdfFromFile` (mints the blob URL
* via the session's `PdfByteStore`),
* 3. register the resulting `{document, representation}` with the
* engine,
* 4. activate the most-recently-uploaded document.
*
* Failures (non-PDFs, ingest errors) are surfaced inline above the
* dropzone; the caller doesn't need a separate toast for them.
*
* Extraction decision (CWORK-WP-0001 T03)
* This component lives in `citation-work` rather than the umbrella app.
* Its only dependencies are `@source` (PDF ingestion) and the local
* engine/session hooks both inside the review-workspace boundary so
* hosting it here lets the package render a complete review shell,
* including the upload affordance, without any umbrella-local imports.
* `ReviewShell` still exposes an `upload` slot so a consumer may swap in
* its own affordance; when the slot is omitted, this default is used.
*/
import { useCallback, useRef, useState } from "react";
import { ingestPdfFromFile } from "@source/index";
import {
useActiveDocumentId,
useEngine,
usePdfByteStore,
} from "./EngineContext";
interface UploadEntry {
readonly file: File;
status: "queued" | "uploading" | "done" | "error";
error?: string;
}
export interface UploadDropzoneProps {
/** Optional callback fired after each successful upload. */
readonly onUploaded?: (documentId: import("@shared/ids").DocumentId) => void;
}
export function UploadDropzone({ onUploaded }: UploadDropzoneProps) {
const engine = useEngine();
const byteStore = usePdfByteStore();
const { setId } = useActiveDocumentId();
const [entries, setEntries] = useState<readonly UploadEntry[]>([]);
const [isOver, setIsOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const processFiles = useCallback(
async (files: readonly File[]) => {
if (files.length === 0) return;
const initial: UploadEntry[] = files.map((file) => {
const isPdf =
file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
if (isPdf) return { file, status: "queued" };
return {
file,
status: "error",
error: "Not a PDF (only application/pdf accepted)",
};
});
setEntries((prev) => [...prev, ...initial]);
let lastDocumentId: import("@shared/ids").DocumentId | null = null;
for (const entry of initial) {
if (entry.status === "error") continue;
entry.status = "uploading";
setEntries((prev) => [...prev]);
try {
const { document, representation } = await ingestPdfFromFile(
entry.file,
byteStore,
);
engine.documents.register({ document, representation });
entry.status = "done";
lastDocumentId = document.id;
onUploaded?.(document.id);
} catch (err) {
entry.status = "error";
entry.error = err instanceof Error ? err.message : String(err);
}
setEntries((prev) => [...prev]);
}
if (lastDocumentId) setId(lastDocumentId);
},
[byteStore, engine, onUploaded, setId],
);
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsOver(false);
const files = Array.from(e.dataTransfer.files);
void processFiles(files);
},
[processFiles],
);
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsOver(true);
}, []);
const onDragLeave = useCallback(() => {
setIsOver(false);
}, []);
const openPicker = useCallback(() => {
fileInputRef.current?.click();
}, []);
const onPicked = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : [];
void processFiles(files);
// Reset so the same filename can be picked again.
e.target.value = "";
},
[processFiles],
);
return (
<div data-testid="upload-dropzone">
<div
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
role="region"
aria-label="PDF upload"
style={{
border: `2px dashed ${isOver ? "#0050b3" : "#bbb"}`,
background: isOver ? "#e8f0ff" : "#fafafa",
padding: 16,
textAlign: "center",
fontSize: 12,
color: "#555",
borderRadius: 4,
}}
>
<div>Drop PDF files here</div>
<div style={{ margin: "6px 0", color: "#888" }}>or</div>
<button
type="button"
onClick={openPicker}
data-testid="upload-pick-button"
style={{
fontSize: 12,
padding: "4px 10px",
border: "1px solid #888",
background: "white",
cursor: "pointer",
}}
>
Choose PDF
</button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf,.pdf"
multiple
onChange={onPicked}
style={{ display: "none" }}
data-testid="upload-file-input"
/>
</div>
{entries.length > 0 && (
<ul
data-testid="upload-progress"
style={{ listStyle: "none", padding: 0, margin: "8px 0 0", fontSize: 11 }}
>
{entries.map((entry, i) => (
<li
key={`${entry.file.name}-${i}`}
data-status={entry.status}
style={{
padding: "2px 4px",
color:
entry.status === "error"
? "#7a0000"
: entry.status === "done"
? "#0a5a0a"
: "#333",
}}
>
{entry.file.name} {entry.status}
{entry.error ? `: ${entry.error}` : ""}
</li>
))}
</ul>
)}
</div>
);
}

147
src/work/ViewerShell.tsx Normal file
View file

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

45
src/work/index.ts Normal file
View file

@ -0,0 +1,45 @@
// Public surface of the review-workspace package.
//
// Extraction is incremental (CWORK-WP-0001): T02 lands the provider/hook
// foundation below; T03T05 append the collection pane, viewer/capture flow,
// and evidence sidebar exports.
export { ReviewShell, type ReviewShellProps } from "./ReviewShell";
export { CollectionList, type CollectionListProps } from "./CollectionList";
export { UploadDropzone, type UploadDropzoneProps } from "./UploadDropzone";
export { ViewerShell } from "./ViewerShell";
export { InlineCaptureForm } from "./InlineCaptureForm";
export { EvidenceSidebar, type EvidenceSidebarProps } from "./EvidenceSidebar";
export {
useExportEvidence,
type ExportEvidenceApi,
type ExportFormat,
type ExportResult,
} from "./useExportEvidence";
export { useDebugFlag, type DebugFlag } from "./useDebugFlags";
export {
EngineProvider,
useEngine,
useActiveDocument,
useActiveDocumentId,
useEngineEventTick,
useEngineRevision,
useLastActivatedEvidence,
usePdfByteStore,
usePendingSelection,
useScrollToAnnotation,
type PendingSelection,
} from "./EngineContext";
export {
SessionProvider,
useActiveSession,
useActiveSessionId,
useSessionBus,
useSessionByteStore,
useSessionByteStoreRegistry,
useSessionListTick,
useSessionService,
useSessionsHydrated,
useSessionVersion,
useSessionVersionBumper,
} from "./SessionContext";

60
src/work/useDebugFlags.ts Normal file
View file

@ -0,0 +1,60 @@
/**
* `useDebugFlags` read/write the small set of developer-facing
* toggles. Persisted in `localStorage` under
* `citation-evidence:debug:<flag>` so a reload preserves them.
*
* Used by the SessionMenu (to render a checkbox) and by the viewer
* shell (to decide whether to paint the PDF text layer + log
* selection events). Kept in `work/` so both the app layer (toggle UI)
* and the anchor consumer (viewer adapter prop) reach it via the
* existing boundary chain.
*/
import { useCallback, useEffect, useState } from "react";
const KEY_PREFIX = "citation-evidence:debug:";
const STORAGE_EVENT = "ce-debug-flag-change";
export type DebugFlag =
| "textLayer"
| "hideCanvas"
| "hideTextLayer"
| "hideAnnotationLayer"
| "hideXfaLayer";
function storageKey(flag: DebugFlag): string {
return `${KEY_PREFIX}${flag}`;
}
function read(flag: DebugFlag): boolean {
if (typeof localStorage === "undefined") return false;
return localStorage.getItem(storageKey(flag)) === "1";
}
export function useDebugFlag(flag: DebugFlag): readonly [boolean, (next: boolean) => void] {
const [value, setValue] = useState<boolean>(() => read(flag));
useEffect(() => {
if (typeof window === "undefined") return;
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ flag: DebugFlag }>).detail;
if (!detail || detail.flag !== flag) return;
setValue(read(flag));
};
window.addEventListener(STORAGE_EVENT, handler);
return () => window.removeEventListener(STORAGE_EVENT, handler);
}, [flag]);
const setter = useCallback(
(next: boolean) => {
if (typeof localStorage === "undefined") return;
if (next) localStorage.setItem(storageKey(flag), "1");
else localStorage.removeItem(storageKey(flag));
setValue(next);
window.dispatchEvent(new CustomEvent(STORAGE_EVENT, { detail: { flag } }));
},
[flag],
);
return [value, setter] as const;
}

View file

@ -0,0 +1,89 @@
/**
* `useExportEvidence` wires engine + renderers + clipboard for the
* sidebar export affordance (CE-WP-0004-T04).
*
* Renderers live in `@engine/rendering`; the hook resolves the
* (`EvidenceItem`, `Document`, first `Annotation`) triple from the engine
* for a given item id, renders it, and writes the result to
* `navigator.clipboard`.
*
* Returns `{ ok: true }` on success and `{ ok: false, reason }` on any
* failure callers (the sidebar toast) decide what to render. Errors
* are swallowed at the boundary instead of throwing so a clipboard
* permission denial doesn't crash the UI tree.
*/
import { useCallback } from "react";
import {
renderCitationCardHtml,
renderCitationCardMarkdown,
} from "@engine/rendering";
import type { EvidenceItem } from "@shared/evidence";
import { useEngine } from "./EngineContext";
export type ExportFormat = "markdown" | "html";
export type ExportResult =
| { readonly ok: true; readonly format: ExportFormat; readonly content: string }
| {
readonly ok: false;
readonly reason:
| "no-annotation"
| "annotation-missing"
| "document-missing"
| "clipboard-unavailable"
| "clipboard-write-failed";
};
export interface ExportEvidenceApi {
/**
* Render the given item as `format` and write it to the clipboard.
* Resolves with the result; rejection never propagates.
*/
exportItem(item: EvidenceItem, format: ExportFormat): Promise<ExportResult>;
}
/**
* Inline export: build the content from item+document+annotation but skip
* the clipboard step. Useful for tests and for surfaces (toasts, future
* "Export as file" dialogs) that want the rendered string.
*/
export function useExportEvidence(): ExportEvidenceApi {
const engine = useEngine();
const exportItem = useCallback<ExportEvidenceApi["exportItem"]>(
async (item, format) => {
const annotationId = item.annotationIds[0];
if (!annotationId) return { ok: false, reason: "no-annotation" };
const annotation = engine.annotations.get(annotationId);
if (!annotation) return { ok: false, reason: "annotation-missing" };
const document = engine.documents.get(annotation.documentId);
if (!document) return { ok: false, reason: "document-missing" };
const content =
format === "markdown"
? renderCitationCardMarkdown({ evidenceItem: item, document, annotation })
: renderCitationCardHtml({ evidenceItem: item, document, annotation });
const clipboard =
typeof navigator !== "undefined" ? navigator.clipboard : undefined;
if (!clipboard || typeof clipboard.writeText !== "function") {
return { ok: false, reason: "clipboard-unavailable" };
}
try {
await clipboard.writeText(content);
return { ok: true, format, content };
} catch {
return { ok: false, reason: "clipboard-write-failed" };
}
},
[engine],
);
return { exportItem };
}