n:m attribute–evidence guide lines from card right to field left.
Overlay draws all links for the selected attribute or evidence; optional binder context preserves unit tests without BinderProvider.
This commit is contained in:
parent
32c5a2ede4
commit
9dc4880ec1
4 changed files with 105 additions and 83 deletions
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-field-button"
|
||||
onClick={() => {
|
||||
setAddLabel(`New attribute ${schema.fields.length + 1}`);
|
||||
setAddType("text");
|
||||
onRequestAddField?.();
|
||||
}}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "4px 10px",
|
||||
border: "1px solid #888",
|
||||
borderRadius: 4,
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Add attribute
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddFieldForm && (
|
||||
<FieldDefinitionForm
|
||||
label={addLabel}
|
||||
type={addType}
|
||||
onChangeLabel={setAddLabel}
|
||||
onChangeType={setAddType}
|
||||
onSave={() =>
|
||||
onConfirmAddField?.({
|
||||
label: addLabel.trim(),
|
||||
type: addType,
|
||||
})
|
||||
}
|
||||
onCancel={() => onCancelAddField?.()}
|
||||
saveLabel="Add attribute"
|
||||
badge="New attribute"
|
||||
testidPrefix="field-add"
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleFields.length === 0 && schema.fields.length > 0 && (
|
||||
<p
|
||||
style={{ fontSize: 12, color: "#888", margin: "8px 0" }}
|
||||
|
|
@ -386,6 +346,48 @@ export function FormRenderer({
|
|||
onCancelEdit={() => onCancelFieldEdit?.()}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add control lives below the list so existing attributes stay primary */}
|
||||
{showAddFieldForm ? (
|
||||
<FieldDefinitionForm
|
||||
label={addLabel}
|
||||
type={addType}
|
||||
onChangeLabel={setAddLabel}
|
||||
onChangeType={setAddType}
|
||||
onSave={() =>
|
||||
onConfirmAddField?.({
|
||||
label: addLabel.trim(),
|
||||
type: addType,
|
||||
})
|
||||
}
|
||||
onCancel={() => onCancelAddField?.()}
|
||||
saveLabel="Add attribute"
|
||||
badge="New attribute"
|
||||
testidPrefix="field-add"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-field-button"
|
||||
onClick={() => {
|
||||
setAddLabel(`New attribute ${schema.fields.length + 1}`);
|
||||
setAddType("text");
|
||||
onRequestAddField?.();
|
||||
}}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "6px 10px",
|
||||
border: "1px dashed #888",
|
||||
borderRadius: 4,
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
Add attribute
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<readonly string[]>(() => {
|
||||
// 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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue