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;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Null when outside BinderProvider (e.g. Overlay unit tests). */
|
||||||
|
export function useBinderOptional(): BinderServices | null {
|
||||||
|
return useContext(BinderServicesContext);
|
||||||
|
}
|
||||||
|
|
||||||
export interface BinderProviderProps {
|
export interface BinderProviderProps {
|
||||||
readonly children: ReactNode;
|
readonly children: ReactNode;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -277,7 +277,6 @@ export function FormRenderer({
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 8,
|
gap: 8,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
|
|
@ -303,53 +302,14 @@ export function FormRenderer({
|
||||||
style={{
|
style={{
|
||||||
flex: "1 1 100px",
|
flex: "1 1 100px",
|
||||||
minWidth: 80,
|
minWidth: 80,
|
||||||
maxWidth: 160,
|
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
padding: "4px 6px",
|
padding: "4px 6px",
|
||||||
border: "1px solid #ccc",
|
border: "1px solid #ccc",
|
||||||
borderRadius: 4,
|
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>
|
</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 && (
|
{visibleFields.length === 0 && schema.fields.length > 0 && (
|
||||||
<p
|
<p
|
||||||
style={{ fontSize: 12, color: "#888", margin: "8px 0" }}
|
style={{ fontSize: 12, color: "#888", margin: "8px 0" }}
|
||||||
|
|
@ -386,6 +346,48 @@ export function FormRenderer({
|
||||||
onCancelEdit={() => onCancelFieldEdit?.()}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,5 @@ export type {
|
||||||
FormRendererProps,
|
FormRendererProps,
|
||||||
FormSchema,
|
FormSchema,
|
||||||
} from "./FormRenderer";
|
} from "./FormRenderer";
|
||||||
export { BinderProvider, useBinder } from "./BinderProvider";
|
export { BinderProvider, useBinder, useBinderOptional } from "./BinderProvider";
|
||||||
export type { BinderServices, BinderProviderProps } 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
|
* Geometry (Capture layout: Evidence column left of Attributes):
|
||||||
* pair of bezier curves on every rect-change event:
|
* 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
|
* Throttling: `attachRectChangePumps` coalesces scroll/resize into one
|
||||||
* bursts into one `rect-changed` per animation frame. The overlay's
|
* `rect-changed` per animation frame; `useRectRegistryVersion` re-renders.
|
||||||
* `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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { useBinderOptional } from "../BinderProvider";
|
||||||
import { useActiveState } from "../state/active";
|
import { useActiveState } from "../state/active";
|
||||||
import {
|
import {
|
||||||
useRectRegistryContext,
|
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 };
|
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
|
* Quadratic bezier with horizontal bulge — suited to side-by-side columns.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
|
function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
|
||||||
const dx = b.x - a.x;
|
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 {
|
export interface OverlayProps {
|
||||||
/** Curve stroke colour. Defaults to the engine's accent blue. */
|
|
||||||
readonly strokeColor?: string;
|
readonly strokeColor?: string;
|
||||||
/** Curve stroke width. Defaults to 2px. */
|
|
||||||
readonly strokeWidth?: number;
|
readonly strokeWidth?: number;
|
||||||
/** Optional className for styling hooks; the inline styles cover layout. */
|
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,32 +55,52 @@ export function Overlay({
|
||||||
className,
|
className,
|
||||||
}: OverlayProps = {}) {
|
}: OverlayProps = {}) {
|
||||||
const { state } = useActiveState();
|
const { state } = useActiveState();
|
||||||
|
const binder = useBinderOptional();
|
||||||
const { registry } = useRectRegistryContext();
|
const { registry } = useRectRegistryContext();
|
||||||
const version = useRectRegistryVersion();
|
const version = useRectRegistryVersion();
|
||||||
|
|
||||||
const paths = useMemo<readonly string[]>(() => {
|
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[] = [];
|
const out: string[] = [];
|
||||||
if (fieldRect && cardRect) {
|
const bindings = binder?.bindings;
|
||||||
// Horizontal layout (Evidence | Attributes): centre-to-centre reads better
|
|
||||||
// than the old bottom-strip geometry.
|
const pushCardField = (evidenceItemId: string, fieldId: string) => {
|
||||||
out.push(bezierPath(rectCenter(fieldRect), rectCenter(cardRect)));
|
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;
|
return out;
|
||||||
}, [state, registry, version]);
|
}, [state, registry, version, binder]);
|
||||||
|
|
||||||
if (paths.length === 0) return null;
|
if (paths.length === 0) return null;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue