feat: extract review-workspace slice into @citation-evidence/work (CWORK-WP-0001)
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:
parent
c51ef94812
commit
0cd2ca5d04
30 changed files with 7958 additions and 342 deletions
112
src/work/CollectionList.dom.test.tsx
Normal file
112
src/work/CollectionList.dom.test.tsx
Normal 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
166
src/work/CollectionList.tsx
Normal 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
316
src/work/EngineContext.tsx
Normal 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;
|
||||
}
|
||||
104
src/work/EvidenceFormBody.tsx
Normal file
104
src/work/EvidenceFormBody.tsx
Normal 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,
|
||||
};
|
||||
248
src/work/EvidenceSidebar.dom.test.tsx
Normal file
248
src/work/EvidenceSidebar.dom.test.tsx
Normal 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;
|
||||
526
src/work/EvidenceSidebar.tsx
Normal file
526
src/work/EvidenceSidebar.tsx
Normal 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 }}>
|
||||
“{quote.slice(0, 140)}
|
||||
{quote.length > 140 ? "…" : ""}”
|
||||
</div>
|
||||
{p.item.commentary && (
|
||||
<div style={{ color: "#333", marginBottom: 4 }}>
|
||||
{p.item.commentary}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: "#666", fontSize: 11 }}>
|
||||
status: {p.item.status}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
right: 6,
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Edit citation and commentary"
|
||||
data-testid={`evidence-edit-toggle-${p.item.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
p.onBeginEdit();
|
||||
}}
|
||||
title="Edit citation and commentary"
|
||||
style={iconButtonStyle}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={p.isExportOpen}
|
||||
aria-label="Export evidence item"
|
||||
data-testid={`export-toggle-${p.item.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
p.onToggleExport();
|
||||
}}
|
||||
style={iconButtonStyle}
|
||||
>
|
||||
Export ▾
|
||||
</button>
|
||||
</div>
|
||||
{p.isExportOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
data-testid={`export-menu-${p.item.id}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 28,
|
||||
right: 6,
|
||||
zIndex: 10,
|
||||
background: "white",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 3,
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.15)",
|
||||
padding: 4,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
minWidth: 160,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await p.onCopyMarkdown();
|
||||
}}
|
||||
style={menuButtonStyle}
|
||||
>
|
||||
Copy as Markdown
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await p.onCopyHtml();
|
||||
}}
|
||||
style={menuButtonStyle}
|
||||
>
|
||||
Copy as HTML
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const iconButtonStyle: CSSProperties = {
|
||||
fontSize: 11,
|
||||
padding: "2px 6px",
|
||||
background: "white",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 3,
|
||||
cursor: "pointer",
|
||||
lineHeight: 1,
|
||||
};
|
||||
|
||||
const menuButtonStyle: CSSProperties = {
|
||||
textAlign: "left",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
padding: "4px 8px",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
};
|
||||
213
src/work/InlineCaptureForm.dom.test.tsx
Normal file
213
src/work/InlineCaptureForm.dom.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
98
src/work/InlineCaptureForm.tsx
Normal file
98
src/work/InlineCaptureForm.tsx
Normal 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
18
src/work/README.md
Normal 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
56
src/work/ReviewShell.tsx
Normal 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
241
src/work/SessionContext.tsx
Normal 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;
|
||||
}
|
||||
27
src/work/SessionContextInternal.ts
Normal file
27
src/work/SessionContextInternal.ts
Normal 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
198
src/work/UploadDropzone.tsx
Normal 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
147
src/work/ViewerShell.tsx
Normal 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
45
src/work/index.ts
Normal 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; T03–T05 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
60
src/work/useDebugFlags.ts
Normal 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;
|
||||
}
|
||||
89
src/work/useExportEvidence.ts
Normal file
89
src/work/useExportEvidence.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue