refactor: consume @citation-evidence/work for review mode (CWORK-WP-0001 cutover)
Cut review mode over to the extracted citation-work package instead of a local src/work/ implementation. - Add @citation-evidence/work as a link: dependency - Repoint the @work alias (tsconfig + vite) to ../citation-work/src/work and allow vite fs access to the sibling - Remove the local src/work/ implementation (now owned by citation-work) Typecheck, lint, and the full test suite (51 tests) pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7b33f5f35e
commit
57d64c6416
18 changed files with 7 additions and 2143 deletions
|
|
@ -22,6 +22,7 @@
|
||||||
"@citation-evidence/engine": "link:../citation-engine",
|
"@citation-evidence/engine": "link:../citation-engine",
|
||||||
"@citation-evidence/evidence-source": "link:../evidence-source",
|
"@citation-evidence/evidence-source": "link:../evidence-source",
|
||||||
"@citation-evidence/evidence-anchor": "link:../evidence-anchor",
|
"@citation-evidence/evidence-anchor": "link:../evidence-anchor",
|
||||||
|
"@citation-evidence/work": "link:../citation-work",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"pdfjs-dist": "^4.4.168",
|
"pdfjs-dist": "^4.4.168",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -17,6 +17,9 @@ importers:
|
||||||
'@citation-evidence/evidence-source':
|
'@citation-evidence/evidence-source':
|
||||||
specifier: link:../evidence-source
|
specifier: link:../evidence-source
|
||||||
version: link:../evidence-source
|
version: link:../evidence-source
|
||||||
|
'@citation-evidence/work':
|
||||||
|
specifier: link:../citation-work
|
||||||
|
version: link:../citation-work
|
||||||
jszip:
|
jszip:
|
||||||
specifier: ^3.10.1
|
specifier: ^3.10.1
|
||||||
version: 3.10.1
|
version: 3.10.1
|
||||||
|
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
// @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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
/**
|
|
||||||
* 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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Silence "unused" warnings for the test-scope captures we kept for
|
|
||||||
// debugging — TypeScript would otherwise complain.
|
|
||||||
void lastEngine;
|
|
||||||
void activeDocumentId;
|
|
||||||
|
|
@ -1,526 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
/**
|
|
||||||
* `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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
# `src/work/` — review UI, sidebar, viewer shell
|
|
||||||
|
|
||||||
Future home: `citation-work`.
|
|
||||||
Owns: the React surfaces for the review workflow — collection list, viewer
|
|
||||||
shell, evidence sidebar, annotation create flow.
|
|
||||||
|
|
||||||
May import from: `shared/`, `engine/`, `anchor/`, `source/`
|
|
||||||
(`wiki/DependencyMap.md` §4). May NOT import from `binder/`.
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
export { CollectionList, type CollectionListProps } from "./CollectionList";
|
|
||||||
export { ViewerShell } from "./ViewerShell";
|
|
||||||
export { EvidenceSidebar, type EvidenceSidebarProps } from "./EvidenceSidebar";
|
|
||||||
export {
|
|
||||||
useExportEvidence,
|
|
||||||
type ExportEvidenceApi,
|
|
||||||
type ExportFormat,
|
|
||||||
type ExportResult,
|
|
||||||
} from "./useExportEvidence";
|
|
||||||
export { InlineCaptureForm } from "./InlineCaptureForm";
|
|
||||||
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";
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
/**
|
|
||||||
* `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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
/**
|
|
||||||
* `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 };
|
|
||||||
}
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"@shared/*": ["../citation-engine/src/shared/*"],
|
"@shared/*": ["../citation-engine/src/shared/*"],
|
||||||
"@engine/*": ["../citation-engine/src/engine/*"],
|
"@engine/*": ["../citation-engine/src/engine/*"],
|
||||||
"@source/*": ["src/source/*"],
|
"@source/*": ["src/source/*"],
|
||||||
"@work/*": ["src/work/*"],
|
"@work/*": ["../citation-work/src/work/*"],
|
||||||
"@binder/*": ["../evidence-binder/src/*"],
|
"@binder/*": ["../evidence-binder/src/*"],
|
||||||
"@app/*": ["src/app/*"]
|
"@app/*": ["src/app/*"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export default defineConfig({
|
||||||
"@engine": resolve(__dirname, "../citation-engine/src/engine"),
|
"@engine": resolve(__dirname, "../citation-engine/src/engine"),
|
||||||
"@source": resolve(__dirname, "src/source"),
|
"@source": resolve(__dirname, "src/source"),
|
||||||
"@binder": resolve(__dirname, "../evidence-binder/src"),
|
"@binder": resolve(__dirname, "../evidence-binder/src"),
|
||||||
"@work": resolve(__dirname, "src/work"),
|
"@work": resolve(__dirname, "../citation-work/src/work"),
|
||||||
"@app": resolve(__dirname, "src/app"),
|
"@app": resolve(__dirname, "src/app"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -46,6 +46,7 @@ export default defineConfig({
|
||||||
resolve(__dirname),
|
resolve(__dirname),
|
||||||
resolve(__dirname, "../citation-engine"),
|
resolve(__dirname, "../citation-engine"),
|
||||||
resolve(__dirname, "../evidence-binder"),
|
resolve(__dirname, "../evidence-binder"),
|
||||||
|
resolve(__dirname, "../citation-work"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue