/** * FormsApp — the evidence-backed form mode for CE-WP-0003. * * Layout: * * ┌────────────┬─────────────────┬─────────────┐ * │ Collection │ FormRenderer │ ViewerShell │ * │ │ (left) │ (right) │ * ├────────────┴─────────────────┴─────────────┤ * │ EvidenceStrip (bottom) │ * └────────────────────────────────────────────┘ * * Linking interaction (T05): * 1. User clicks an evidence card in the strip → it becomes "selected * for linking" (highlighted; banner appears in the form pane). * 2. User then clicks a form field. The field's `FormFieldActivated` * event triggers `bindings.linkEvidenceToTarget(selected, field)`. * 3. The selected-for-linking state clears; the field's link count * chip increments. * * Active-evidence cycling (T06) and the visual guide overlay (T07) build * on this composition without changing the link interaction. */ import { useEffect, useMemo, useState } from "react"; import type { EvidenceItem } from "@shared/evidence"; import type { AnnotationId, EvidenceItemId } from "@shared/ids"; import { Overlay, useActiveState, useBinder } from "@binder/index"; import { CollectionList, ViewerShell, useActiveDocument, useEngine, useEngineEventTick, useScrollToAnnotation, } from "@work/index"; import { FormRenderer } from "@binder/FormRenderer"; import { ActiveEvidenceChips, type ActiveEvidenceChipsItem } from "./ActiveEvidenceChips"; import { DEMO_SCHEMA } from "./demo-schema"; import { HighlightRectBridge } from "./HighlightRectBridge"; export function FormsApp() { return (
); } /** * Bridge: the binder's active-state machine doesn't know how to scroll * the viewer; the work-level `useScrollToAnnotation` does. This subscriber * reads `activeAnnotationId` from the binder and calls `scrollTo` whenever * it changes. Lives in app/ because that's where both subsystems meet. */ function ScrollBridge() { const { state } = useActiveState(); const { scrollTo } = useScrollToAnnotation(); useEffect(() => { if (state.activeAnnotationId) { scrollTo(state.activeAnnotationId); } }, [state.activeAnnotationId, scrollTo]); return null; } function FormPane() { const { document } = useActiveDocument(); const { bindings } = useBinder(); const engine = useEngine(); const linkTick = useEngineEventTick("EvidenceLinkCreated"); const { state: activeState } = useActiveState(); const [selectedForLinking, setSelectedForLinking] = useState( null, ); // Compute per-field link counts. Re-derives on link create. const linkCounts = useMemo>(() => { const out: Record = {}; for (const field of DEMO_SCHEMA.fields) { out[field.id] = bindings.listEvidenceForTarget({ targetType: "form-field", targetId: field.id, }).length; } void linkTick; return out; }, [bindings, linkTick]); // Compute chip items for the currently-active target. const activeChipItems = useMemo(() => { if (!activeState.activeTarget) return []; const links = bindings.listEvidenceForTarget(activeState.activeTarget); return links .map((link): ActiveEvidenceChipsItem | null => { const item = engine.evidence.get(link.evidenceItemId); if (!item) return null; const annotationId: AnnotationId | null = item.annotationIds[0] ?? null; const annotation = annotationId ? engine.annotations.get(annotationId) : null; return { evidenceItemId: link.evidenceItemId, annotationId, quote: annotation?.quote ?? "(no quote)", ...(item.commentary ? { commentary: item.commentary } : {}), }; }) .filter((c): c is ActiveEvidenceChipsItem => c !== null); // linkTick is included so newly created links populate the chips // without an explicit refresh. }, [activeState.activeTarget, bindings, engine, linkTick]); // Listen for FormFieldActivated and, if an evidence is staged, create // the link. The state machine reduction (focus-target) happens in // parallel via ActiveStateProvider's own handler. useEffect(() => { return engine.bus.on("FormFieldActivated", (event) => { if (!selectedForLinking) return; bindings.linkEvidenceToTarget({ evidenceItemId: selectedForLinking, target: event.target, }); setSelectedForLinking(null); }); }, [engine, bindings, selectedForLinking]); return (
setSelectedForLinking(null)} /> {document ? ( <> ) : ( )}
); } function EmptyHint() { return (

Pick a fixture from the collection list to start binding evidence.

); } function SelectedBanner({ selectedForLinking, onClear, }: { selectedForLinking: EvidenceItemId | null; onClear: () => void; }) { if (!selectedForLinking) return null; return (
Evidence staged for linking. Click a form field to link it, or{" "}
); } /** * Bridges the strip's "stage this evidence" callback to the FormPane's * local state. The strip lives in a sibling DOM subtree; rather than * lifting `selectedForLinking` all the way up to FormsApp, we publish a * setter into a module-scoped event target. * * Simpler than another context for one local handshake. */ const STAGED_EVENT = "citation-evidence:staged-for-linking"; function SelectionContext({ setSelected, }: { setSelected: (id: EvidenceItemId | null) => void; }) { useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; setSelected(detail); }; window.addEventListener(STAGED_EVENT, handler); return () => window.removeEventListener(STAGED_EVENT, handler); }, [setSelected]); return null; } export function publishStagedForLinking(id: EvidenceItemId | null) { if (typeof window === "undefined") return; window.dispatchEvent(new CustomEvent(STAGED_EVENT, { detail: id })); } function EvidenceStrip() { const engine = useEngine(); const { document } = useActiveDocument(); const createTick = useEngineEventTick("EvidenceItemCreated"); const updateTick = useEngineEventTick("EvidenceItemUpdated"); const linkTick = useEngineEventTick("EvidenceLinkCreated"); const { state: activeState } = useActiveState(); const [stagedId, setStagedId] = useState(null); const items = useMemo(() => { if (!document) return []; void createTick; void updateTick; void linkTick; return engine.evidence.listByDocument(document.id); }, [document, engine, createTick, updateTick, linkTick]); const handleStage = (id: EvidenceItemId) => { const next = stagedId === id ? null : id; setStagedId(next); publishStagedForLinking(next); }; if (!document) return null; return (
{items.length === 0 && (

No evidence yet. Switch to Review mode to capture a passage.

)} {items.map((item) => { const firstAnn = item.annotationIds[0] ? engine.annotations.get(item.annotationIds[0]) : null; const quote = firstAnn?.quote ?? "(no quote)"; const isStaged = stagedId === item.id; const isActive = activeState.activeEvidenceItemId === item.id; return ( ); })}
); }