/** * FormsApp (Capture mode) — evidence-backed form layout (CE-WP-0003/0006/0007). * * Layout (CE-WP-0007): * * ┌────────────┬─────────────────┬─────────────┐ * │ Collection │ ViewerShell │ FormPane │ * ├────────────┴─────────────────┴─────────────┤ * │ EvidenceStrip (bottom) │ * └────────────────────────────────────────────┘ * * Linking: field must have focus; clicking evidence links directly. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EvidenceItem } from "@shared/evidence"; import type { EvidenceLink } from "@shared/evidence-link"; import type { EvidenceItemId } from "@shared/ids"; import { Overlay, useActiveState, useBinder, useRegisterRect, } from "@binder/index"; import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer"; import { CollectionList, ViewerShell, useActiveDocument, useEngine, useEngineEventTick, useScrollToAnnotation, } from "@work/index"; import { FormRenderer } from "@binder/FormRenderer"; import { DEMO_SCHEMA } from "./demo-schema"; import { HighlightRectBridge } from "./HighlightRectBridge"; export type EvidenceStripFilter = "all" | "attached"; const STRIP_FILTER_EVENT = "citation-evidence:strip-filter"; function publishStripFilter(mode: EvidenceStripFilter) { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(STRIP_FILTER_EVENT, { detail: mode })); } function quotePreview(text: string, max = 80): string { const t = text.trim(); return t.length > max ? `${t.slice(0, max)}…` : t; } export function FormsApp() { const [schema, setSchema] = useState(() => ({ ...DEMO_SCHEMA, fields: [...DEMO_SCHEMA.fields], })); const fieldLabels = useMemo( () => new Map(schema.fields.map((f) => [f.id, f.label] as const)), [schema], ); const addField = useCallback(() => { setSchema((prev) => { const n = prev.fields.length + 1; const field: FormFieldSchema = { type: "text", id: `field_${n}`, label: `New field ${n}`, }; return { ...prev, fields: [...prev.fields, field] }; }); }, []); return (
); } function ScrollBridge() { const { state } = useActiveState(); const { scrollTo } = useScrollToAnnotation(); useEffect(() => { if (state.activeAnnotationId) { scrollTo(state.activeAnnotationId); } }, [state.activeAnnotationId, scrollTo]); return null; } function FormPane({ schema, onAddField, }: { schema: FormSchema; onAddField: () => void; }) { const { document } = useActiveDocument(); const { bindings } = useBinder(); const engine = useEngine(); const linkTick = useEngineEventTick("EvidenceLinkCreated"); const unlinkTick = useEngineEventTick("EvidenceLinkRemoved"); const { state: activeState, setActiveEvidence } = useActiveState(); useEffect(() => { return engine.bus.on("FormFieldActivated", () => { publishStripFilter("attached"); }); }, [engine]); useEffect(() => { const target = activeState.activeTarget; if (!target || activeState.activeEvidenceItemId) return; const links = bindings.listEvidenceForTarget(target); if (links.length === 0) return; const item = engine.evidence.get(links[0]!.evidenceItemId); if (!item) return; setActiveEvidence(item.id, item.annotationIds[0] ?? null); }, [ activeState.activeTarget, activeState.activeEvidenceItemId, bindings, engine, linkTick, unlinkTick, setActiveEvidence, ]); const linkCounts = useMemo>(() => { const out: Record = {}; for (const field of schema.fields) { out[field.id] = bindings.listEvidenceForTarget({ targetType: "form-field", targetId: field.id, }).length; } void linkTick; void unlinkTick; return out; }, [schema.fields, bindings, linkTick, unlinkTick]); const linkHints = useMemo>(() => { const out: Record = {}; for (const field of schema.fields) { const links = bindings.listEvidenceForTarget({ targetType: "form-field", targetId: field.id, }); if (links.length === 0) continue; const item = engine.evidence.get(links[0]!.evidenceItemId); const ann = item?.annotationIds[0] ? engine.annotations.get(item.annotationIds[0]) : null; const quote = ann?.quote ?? item?.commentary ?? ""; if (quote) out[field.id] = quotePreview(quote); } void linkTick; void unlinkTick; return out; }, [schema.fields, bindings, engine, linkTick, unlinkTick]); return (
{document ? ( Add field } /> ) : ( )}
); } function EmptyHint() { return (

Pick a document from the collection to start capturing evidence links.

); } function EvidenceStrip({ fieldLabels, }: { fieldLabels: ReadonlyMap; }) { const engine = useEngine(); const { bindings } = useBinder(); const { document } = useActiveDocument(); const createTick = useEngineEventTick("EvidenceItemCreated"); const updateTick = useEngineEventTick("EvidenceItemUpdated"); const linkTick = useEngineEventTick("EvidenceLinkCreated"); const unlinkTick = useEngineEventTick("EvidenceLinkRemoved"); const { state: activeState, setActiveEvidence, clearActiveEvidence } = useActiveState(); const [userFilter, setUserFilter] = useState("all"); const [sessionFilter, setSessionFilter] = useState( null, ); const effectiveFilter = sessionFilter ?? userFilter; useEffect(() => { const handler = (e: Event) => { setSessionFilter((e as CustomEvent).detail); }; window.addEventListener(STRIP_FILTER_EVENT, handler); return () => window.removeEventListener(STRIP_FILTER_EVENT, handler); }, []); useEffect(() => { if (!activeState.activeTarget) { setSessionFilter(null); } }, [activeState.activeTarget]); const allItems = useMemo(() => { if (!document) return []; void createTick; void updateTick; void linkTick; void unlinkTick; return engine.evidence.listByDocument(document.id); }, [document, engine, createTick, updateTick, linkTick, unlinkTick]); const items = useMemo(() => { if (effectiveFilter !== "attached" || !activeState.activeTarget) { return allItems; } const links = bindings.listEvidenceForTarget(activeState.activeTarget); const ids = new Set(links.map((l) => l.evidenceItemId)); const attached = allItems.filter((item) => ids.has(item.id)); return attached.length > 0 ? attached : allItems; }, [ allItems, effectiveFilter, activeState.activeTarget, bindings, linkTick, unlinkTick, ]); const tryLink = useCallback( (evidenceItemId: EvidenceItemId, fieldId: string): boolean => { const existing = bindings .listEvidenceForTarget({ targetType: "form-field", targetId: fieldId }) .some((l) => l.evidenceItemId === evidenceItemId); if (existing) return false; bindings.linkEvidenceToTarget({ evidenceItemId, target: { targetType: "form-field", targetId: fieldId }, }); return true; }, [bindings], ); const handleCardClick = useCallback( (item: EvidenceItem) => { const annId = item.annotationIds[0] ?? null; setActiveEvidence(item.id, annId); const target = activeState.activeTarget; if (target?.targetType === "form-field") { tryLink(item.id, target.targetId); } }, [activeState.activeTarget, setActiveEvidence, tryLink], ); const handleUnlink = useCallback( (link: EvidenceLink) => { bindings.unlinkEvidence(link.id); if ( activeState.activeEvidenceItemId === link.evidenceItemId && activeState.activeTarget?.targetType === link.targetType && activeState.activeTarget?.targetId === link.targetId ) { clearActiveEvidence(); } }, [bindings, activeState, clearActiveEvidence], ); if (!document) return null; return (
Show: { setUserFilter("all"); setSessionFilter(null); }} /> { setUserFilter("attached"); setSessionFilter(null); }} />
{items.length === 0 && (

{effectiveFilter === "attached" ? "No evidence linked to the active field." : "No evidence yet. Switch to Review mode to capture a passage."}

)} {items.map((item) => ( handleCardClick(item)} onUnlink={handleUnlink} /> ))}
); } function FilterToggle({ label, active, onClick, }: { label: string; active: boolean; onClick: () => void; }) { return ( ); } function EvidenceStripCard({ item, isActive, links, fieldLabels, onClick, onUnlink, }: { item: EvidenceItem; isActive: boolean; links: readonly EvidenceLink[]; fieldLabels: ReadonlyMap; onClick: () => void; onUnlink: (link: EvidenceLink) => void; }) { const engine = useEngine(); const ref = useRef(null); useRegisterRect("evidence-card", item.id, ref); const firstAnn = item.annotationIds[0] ? engine.annotations.get(item.annotationIds[0]) : null; const quote = firstAnn?.quote ?? "(no quote)"; const formLinks = links.filter((l) => l.targetType === "form-field"); return (
{formLinks.length > 0 && (
{formLinks.map((link) => { const label = fieldLabels.get(link.targetId) ?? link.targetId; return ( ); })}
)}
); }