Files
citation-evidence/src/binder/state/active.ts
tegwick 2fd085b65e CE-WP-0006/0007: Capture view polish, workplans, and UX refinements
- Blob URL stability, scroll centre, strip-only visual guide
- Focus-gated linking, unlink clears overlay, field badge tooltips
- Capture layout (viewer centre), grey guide lines, Add field button
- Workplans CE-WP-0006 (done) and CE-WP-0007 (T01-T09 done, T10-T12 todo)
- Integration tests and viewer-url helpers
2026-06-08 00:37:34 +02:00

184 lines
5.3 KiB
TypeScript

/**
* 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<ActiveStateApi | null>(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<ActiveStateApi>(
() => ({ 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 <ActiveStateProvider />");
}
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 };