/** * Active state machine + React context for the form-binding flow. * * Tracks the `(activeTarget, activeEvidenceItemId, activeAnnotationId)` * triple that the SVG visual guide and the viewer adapter both depend on. * * Transitions: * - `focusTarget(target)` — clears the active evidence, emits * `FormFieldActivated`. * - `setActiveEvidence(evidenceItemId, annotationId?)` — sets active * evidence (and optionally the active annotation derived from it), * emits `EvidenceItemActivated` with `source="form-field"`. The * binding-service helper does the same; the state machine owns the * React-facing source of truth. * - `clear()` — drops everything back to undefined. * * The state itself is a small immutable record (so React equality checks * stay simple). All mutations go through a single reducer. */ import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useReducer, useRef, type ReactNode, } from "react"; import type { EvidenceTarget } from "@shared/evidence-link"; import type { AnnotationId, EvidenceItemId } from "@shared/ids"; import type { EventBus } from "@engine/events"; export interface ActiveState { readonly activeTarget: EvidenceTarget | null; readonly activeEvidenceItemId: EvidenceItemId | null; readonly activeAnnotationId: AnnotationId | null; } export const EMPTY_ACTIVE_STATE: ActiveState = { activeTarget: null, activeEvidenceItemId: null, activeAnnotationId: null, }; type Action = | { type: "focus-target"; target: EvidenceTarget } | { type: "set-active-evidence"; evidenceItemId: EvidenceItemId; annotationId: AnnotationId | null; } | { type: "clear-active-evidence" } | { type: "clear" }; function reducer(state: ActiveState, action: Action): ActiveState { switch (action.type) { case "focus-target": // Focusing a target resets the active evidence — a different field // means a different evidence set. if ( state.activeTarget?.targetType === action.target.targetType && state.activeTarget?.targetId === action.target.targetId ) { return state; } return { activeTarget: action.target, activeEvidenceItemId: null, activeAnnotationId: null, }; case "set-active-evidence": return { activeTarget: state.activeTarget, activeEvidenceItemId: action.evidenceItemId, activeAnnotationId: action.annotationId, }; case "clear-active-evidence": return { activeTarget: state.activeTarget, activeEvidenceItemId: null, activeAnnotationId: null, }; case "clear": return EMPTY_ACTIVE_STATE; } } export interface ActiveStateApi { readonly state: ActiveState; focusTarget(target: EvidenceTarget): void; setActiveEvidence( evidenceItemId: EvidenceItemId, annotationId?: AnnotationId | null, ): void; clearActiveEvidence(): void; clear(): void; } const ActiveStateContext = createContext(null); export interface ActiveStateProviderProps { readonly bus: EventBus; readonly children: ReactNode; } /** * React provider for the binder's active-state machine. Mounts inside the * EngineProvider so it can wire `bus` from the engine. */ export function ActiveStateProvider(props: ActiveStateProviderProps) { const [state, dispatch] = useReducer(reducer, EMPTY_ACTIVE_STATE); const stateRef = useRef(state); useEffect(() => { stateRef.current = state; }, [state]); const focusTarget = useCallback( (target: EvidenceTarget) => { const previousTarget = stateRef.current.activeTarget; const samePrevious = previousTarget?.targetType === target.targetType && previousTarget?.targetId === target.targetId; if (samePrevious) return; props.bus.emit({ type: "FormFieldActivated", target, ...(previousTarget !== null ? { previousTarget } : {}), }); dispatch({ type: "focus-target", target }); }, [props.bus], ); const setActiveEvidence = useCallback( (evidenceItemId: EvidenceItemId, annotationId?: AnnotationId | null) => { props.bus.emit({ type: "EvidenceItemActivated", evidenceItemId, source: "form-field", }); dispatch({ type: "set-active-evidence", evidenceItemId, annotationId: annotationId ?? null, }); }, [props.bus], ); const clearActiveEvidence = useCallback(() => { dispatch({ type: "clear-active-evidence" }); }, []); const clear = useCallback(() => { dispatch({ type: "clear" }); }, []); const value = useMemo( () => ({ state, focusTarget, setActiveEvidence, clearActiveEvidence, clear }), [state, focusTarget, setActiveEvidence, clearActiveEvidence, clear], ); return createElement(ActiveStateContext.Provider, { value }, props.children); } export function useActiveState(): ActiveStateApi { const ctx = useContext(ActiveStateContext); if (!ctx) { throw new Error("useActiveState must be used inside "); } return ctx; } /** * Pure reducer + initial state, exported so the headless tests can verify * transitions without spinning up React. */ export const __test = { reducer, EMPTY_ACTIVE_STATE };