diff --git a/src/BinderProvider.tsx b/src/BinderProvider.tsx index 994366f..6742dcf 100644 --- a/src/BinderProvider.tsx +++ b/src/BinderProvider.tsx @@ -56,6 +56,11 @@ export function useBinder(): BinderServices { return ctx; } +/** Null when outside BinderProvider (e.g. Overlay unit tests). */ +export function useBinderOptional(): BinderServices | null { + return useContext(BinderServicesContext); +} + export interface BinderProviderProps { readonly children: ReactNode; /** diff --git a/src/FormRenderer.tsx b/src/FormRenderer.tsx index f27e1d4..579ee72 100644 --- a/src/FormRenderer.tsx +++ b/src/FormRenderer.tsx @@ -277,7 +277,6 @@ export function FormRenderer({ style={{ display: "flex", alignItems: "center", - justifyContent: "space-between", gap: 8, marginBottom: 8, flexWrap: "wrap", @@ -303,53 +302,14 @@ export function FormRenderer({ style={{ flex: "1 1 100px", minWidth: 80, - maxWidth: 160, fontSize: 12, padding: "4px 6px", border: "1px solid #ccc", borderRadius: 4, }} /> - - {showAddFieldForm && ( - - onConfirmAddField?.({ - label: addLabel.trim(), - type: addType, - }) - } - onCancel={() => onCancelAddField?.()} - saveLabel="Add attribute" - badge="New attribute" - testidPrefix="field-add" - /> - )} - {visibleFields.length === 0 && schema.fields.length > 0 && (

onCancelFieldEdit?.()} /> ))} + + {/* Add control lives below the list so existing attributes stay primary */} + {showAddFieldForm ? ( + + onConfirmAddField?.({ + label: addLabel.trim(), + type: addType, + }) + } + onCancel={() => onCancelAddField?.()} + saveLabel="Add attribute" + badge="New attribute" + testidPrefix="field-add" + /> + ) : ( + + )} ); } diff --git a/src/index.ts b/src/index.ts index a0c7754..b1df542 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,5 +9,5 @@ export type { FormRendererProps, FormSchema, } from "./FormRenderer"; -export { BinderProvider, useBinder } from "./BinderProvider"; +export { BinderProvider, useBinder, useBinderOptional } from "./BinderProvider"; export type { BinderServices, BinderProviderProps } from "./BinderProvider"; diff --git a/src/visual-guide/Overlay.tsx b/src/visual-guide/Overlay.tsx index e0fa2ad..dddb05c 100644 --- a/src/visual-guide/Overlay.tsx +++ b/src/visual-guide/Overlay.tsx @@ -1,28 +1,21 @@ /** - * Visual-guide overlay — draws curves between the active triple. + * Visual-guide overlay — draws curves between evidence and attributes. * - * Subscribes to the rect registry + active-state machine and redraws a - * pair of bezier curves on every rect-change event: + * Geometry (Capture layout: Evidence column left of Attributes): + * evidence-card right edge ──► attribute left edge * - * field ──► evidence-card ──► highlight + * Relation is n:m: + * - Active attribute → lines to every linked evidence card + * - Active evidence (no attribute focus) → lines to every linked attribute + * - Active evidence → also card → highlight in the viewer * - * Throttling: `attachRectChangePumps` already coalesces scroll/resize - * bursts into one `rect-changed` per animation frame. The overlay's - * `useSyncExternalStore` subscription via `useRectRegistryVersion` picks - * up that single tick and React re-renders once per frame. - * - * Active-only: draws legs for the active evidence card. Attribute→card - * when a Capture attribute is focused; card→highlight whenever the - * highlight rect is available (Annotate or Capture). Missing legs are - * omitted independently. - * - * MVP-sufficient. Future polish: easing the curve direction by source - * type, animating the transition between active states, dimming - * non-active rects rather than hiding them. + * Throttling: `attachRectChangePumps` coalesces scroll/resize into one + * `rect-changed` per animation frame; `useRectRegistryVersion` re-renders. */ import { useMemo } from "react"; +import { useBinderOptional } from "../BinderProvider"; import { useActiveState } from "../state/active"; import { useRectRegistryContext, @@ -33,11 +26,16 @@ function rectCenter(rect: DOMRect): { x: number; y: number } { return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; } +function rectRightCenter(rect: DOMRect): { x: number; y: number } { + return { x: rect.right, y: rect.top + rect.height / 2 }; +} + +function rectLeftCenter(rect: DOMRect): { x: number; y: number } { + return { x: rect.left, y: rect.top + rect.height / 2 }; +} + /** - * Build a quadratic bezier from `a` to `b` whose control point bulges - * horizontally between them. The horizontal-bulge style is right for a - * left-pane→centre-pane→right-pane layout; vertical-bulge can be added - * later when we have a layout that needs it. + * Quadratic bezier with horizontal bulge — suited to side-by-side columns. */ function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string { const dx = b.x - a.x; @@ -46,11 +44,8 @@ function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): s } export interface OverlayProps { - /** Curve stroke colour. Defaults to the engine's accent blue. */ readonly strokeColor?: string; - /** Curve stroke width. Defaults to 2px. */ readonly strokeWidth?: number; - /** Optional className for styling hooks; the inline styles cover layout. */ readonly className?: string; } @@ -60,32 +55,52 @@ export function Overlay({ className, }: OverlayProps = {}) { const { state } = useActiveState(); + const binder = useBinderOptional(); const { registry } = useRectRegistryContext(); const version = useRectRegistryVersion(); const paths = useMemo(() => { - // CE-WP-0010: card → highlight is enough for Annotate mode (no attribute - // target). Field → card still draws when Capture has an active target. - if (!state.activeEvidenceItemId) return []; - const fieldRect = state.activeTarget - ? registry.getRect("field", state.activeTarget.targetId) - : null; - const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId); - const highlightRect = state.activeAnnotationId - ? registry.getRect("highlight", state.activeAnnotationId) - : null; const out: string[] = []; - if (fieldRect && cardRect) { - // Horizontal layout (Evidence | Attributes): centre-to-centre reads better - // than the old bottom-strip geometry. - out.push(bezierPath(rectCenter(fieldRect), rectCenter(cardRect))); + const bindings = binder?.bindings; + + const pushCardField = (evidenceItemId: string, fieldId: string) => { + const cardRect = registry.getRect("evidence-card", evidenceItemId); + const fieldRect = registry.getRect("field", fieldId); + if (!cardRect || !fieldRect) return; + // Evidence (left) right edge → attribute (right) left edge + out.push(bezierPath(rectRightCenter(cardRect), rectLeftCenter(fieldRect))); + }; + + if (bindings && state.activeTarget?.targetType === "form-field") { + // Attribute selected: all linked evidence cards + const links = bindings.listEvidenceForTarget(state.activeTarget); + for (const link of links) { + pushCardField(link.evidenceItemId, state.activeTarget.targetId); + } + } else if (bindings && state.activeEvidenceItemId) { + // Evidence selected: all linked attributes + const links = bindings.listTargetsForEvidence(state.activeEvidenceItemId); + for (const link of links) { + if (link.targetType !== "form-field") continue; + pushCardField(state.activeEvidenceItemId, link.targetId); + } + } else if (state.activeTarget && state.activeEvidenceItemId) { + // Fallback without binder (unit tests): single active pair + pushCardField(state.activeEvidenceItemId, state.activeTarget.targetId); } - if (cardRect && highlightRect) { - out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect))); + + // Evidence → citation highlight (Annotate + Capture) + if (state.activeEvidenceItemId && state.activeAnnotationId) { + const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId); + const highlightRect = registry.getRect("highlight", state.activeAnnotationId); + if (cardRect && highlightRect) { + out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect))); + } } - void version; // memo invalidator + + void version; return out; - }, [state, registry, version]); + }, [state, registry, version, binder]); if (paths.length === 0) return null;