Extract binder package from citation-evidence (EBIND-WP-0001)
Extracts citation-evidence/src/binder/ into this repo as the standalone @citation-evidence/evidence-binder package: headless binding service + in-memory link repo, active-state machine, the SharedContracts §7 rect-registry contract (registry, change pumps, hooks, SVG overlay), and the target-neutral reference FormRenderer. - toolchain mirrors sibling extracted repos (pnpm/tsc/vitest/eslint); imports rewritten from @shared/@engine aliases to the engine's @citation-evidence/engine package specifiers - dependency boundary (engine + anchor only; no source/work/umbrella) enforced via eslint no-restricted-imports - docs: extraction inventory + contract deltas, ADR-0001 (reference UI kept as supported exports), refreshed README/SCOPE/INTENT, populated capabilities index - typecheck + lint green, 37 tests passing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
87d4eb3006
commit
a10f080f12
36 changed files with 6643 additions and 170 deletions
150
src/visual-guide/Overlay.dom.test.tsx
Normal file
150
src/visual-guide/Overlay.dom.test.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Overlay unit test (CE-WP-0003-T07).
|
||||
*
|
||||
* Verifies the SVG renders the right number of paths given the active
|
||||
* triple state and registered rects. Curve geometry is not asserted —
|
||||
* the bezier helper is intentionally simple and changes will be caught
|
||||
* by visual review, not test maintenance.
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createEventBus } from "@citation-evidence/engine";
|
||||
|
||||
import { Overlay } from "./Overlay";
|
||||
import { ActiveStateProvider, useActiveState } from "../state/active";
|
||||
import {
|
||||
RectRegistryProvider,
|
||||
createRectRegistryContextValue,
|
||||
type RectRegistryContextValue,
|
||||
} from "./react-hooks";
|
||||
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
|
||||
import type { AnnotationId, EvidenceItemId } from "@citation-evidence/engine/shared";
|
||||
|
||||
function fakeRect(x: number, y: number, w: number, h: number): DOMRect {
|
||||
return {
|
||||
x, y, width: w, height: h,
|
||||
top: y, left: x, right: x + w, bottom: y + h,
|
||||
toJSON() { return { x, y, width: w, height: h }; },
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
const FIELD: EvidenceTarget = { targetType: "form-field", targetId: "summary" };
|
||||
const EV_ID = "ev_one" as EvidenceItemId;
|
||||
const ANN_ID = "ann_one" as AnnotationId;
|
||||
|
||||
// Tiny harness to drive the binder's active-state from outside the
|
||||
// provider tree (so the test can stage state without a long click path).
|
||||
function Driver({ onActive }: { onActive: (api: ReturnType<typeof useActiveState>) => void }) {
|
||||
const api = useActiveState();
|
||||
onActive(api);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("Overlay (CE-WP-0003-T07)", () => {
|
||||
let ctx: RectRegistryContextValue;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createRectRegistryContextValue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctx.observer.disconnect();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders nothing when no triple is active", () => {
|
||||
const bus = createEventBus();
|
||||
const { container } = render(
|
||||
<RectRegistryProvider value={ctx}>
|
||||
<ActiveStateProvider bus={bus}>
|
||||
<Overlay />
|
||||
</ActiveStateProvider>
|
||||
</RectRegistryProvider>,
|
||||
);
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws one path when only field + card rects are registered", async () => {
|
||||
const bus = createEventBus();
|
||||
let api: ReturnType<typeof useActiveState> | null = null;
|
||||
render(
|
||||
<RectRegistryProvider value={ctx}>
|
||||
<ActiveStateProvider bus={bus}>
|
||||
<Driver onActive={(a) => (api = a)} />
|
||||
<Overlay />
|
||||
</ActiveStateProvider>
|
||||
</RectRegistryProvider>,
|
||||
);
|
||||
|
||||
// Register the two known rects.
|
||||
ctx.registry.register("field", FIELD.targetId, () => fakeRect(10, 10, 100, 30));
|
||||
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(400, 200, 150, 60));
|
||||
|
||||
// Activate the triple. annotationId left null so no highlight is queried.
|
||||
await act(async () => {
|
||||
api!.focusTarget(FIELD);
|
||||
api!.setActiveEvidence(EV_ID, null);
|
||||
});
|
||||
|
||||
const svg = document.querySelector('[data-testid="visual-guide-overlay"]')!;
|
||||
expect(svg).not.toBeNull();
|
||||
expect(svg.getAttribute("data-path-count")).toBe("1");
|
||||
});
|
||||
|
||||
it("draws two paths when field + card + highlight rects are all registered", async () => {
|
||||
const bus = createEventBus();
|
||||
let api: ReturnType<typeof useActiveState> | null = null;
|
||||
render(
|
||||
<RectRegistryProvider value={ctx}>
|
||||
<ActiveStateProvider bus={bus}>
|
||||
<Driver onActive={(a) => (api = a)} />
|
||||
<Overlay />
|
||||
</ActiveStateProvider>
|
||||
</RectRegistryProvider>,
|
||||
);
|
||||
|
||||
ctx.registry.register("field", FIELD.targetId, () => fakeRect(10, 10, 100, 30));
|
||||
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(400, 200, 150, 60));
|
||||
ctx.registry.register("highlight", ANN_ID, () => fakeRect(700, 400, 200, 20));
|
||||
|
||||
await act(async () => {
|
||||
api!.focusTarget(FIELD);
|
||||
api!.setActiveEvidence(EV_ID, ANN_ID);
|
||||
});
|
||||
|
||||
const svg = document.querySelector('[data-testid="visual-guide-overlay"]')!;
|
||||
expect(svg.getAttribute("data-path-count")).toBe("2");
|
||||
expect(svg.querySelectorAll("path").length).toBe(2);
|
||||
});
|
||||
|
||||
it("re-renders when the registry invalidates after rect changes", async () => {
|
||||
const bus = createEventBus();
|
||||
let api: ReturnType<typeof useActiveState> | null = null;
|
||||
render(
|
||||
<RectRegistryProvider value={ctx}>
|
||||
<ActiveStateProvider bus={bus}>
|
||||
<Driver onActive={(a) => (api = a)} />
|
||||
<Overlay />
|
||||
</ActiveStateProvider>
|
||||
</RectRegistryProvider>,
|
||||
);
|
||||
ctx.registry.register("field", FIELD.targetId, () => fakeRect(0, 0, 10, 10));
|
||||
ctx.registry.register("evidence-card", EV_ID, () => fakeRect(100, 100, 10, 10));
|
||||
await act(async () => {
|
||||
api!.focusTarget(FIELD);
|
||||
api!.setActiveEvidence(EV_ID, null);
|
||||
});
|
||||
const d1 = document.querySelector('[data-testid="visual-guide-overlay"] path')!.getAttribute("d");
|
||||
// Mutate one of the getters' results, then invalidate.
|
||||
ctx.registry.register("field", FIELD.targetId, () => fakeRect(500, 500, 10, 10));
|
||||
await act(async () => {
|
||||
ctx.registry.invalidate();
|
||||
});
|
||||
const d2 = document.querySelector('[data-testid="visual-guide-overlay"] path')!.getAttribute("d");
|
||||
expect(d1).not.toBe(d2);
|
||||
});
|
||||
});
|
||||
123
src/visual-guide/Overlay.tsx
Normal file
123
src/visual-guide/Overlay.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Visual-guide overlay — draws curves between the active triple.
|
||||
*
|
||||
* Subscribes to the rect registry + active-state machine and redraws a
|
||||
* pair of bezier curves on every rect-change event:
|
||||
*
|
||||
* field ──► evidence-card ──► highlight
|
||||
*
|
||||
* 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: only the currently active triple is drawn. If any leg's
|
||||
* rect is missing (e.g. the viewer hasn't reported a highlight rect for
|
||||
* the active annotation yet), that leg is omitted but the other one
|
||||
* still renders.
|
||||
*
|
||||
* 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 { useActiveState } from "../state/active";
|
||||
import {
|
||||
useRectRegistryContext,
|
||||
useRectRegistryVersion,
|
||||
} from "./react-hooks";
|
||||
|
||||
function rectCenter(rect: DOMRect): { x: number; y: number } {
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
}
|
||||
|
||||
function rectBottomCenter(rect: DOMRect): { x: number; y: number } {
|
||||
return { x: rect.left + rect.width / 2, y: rect.bottom };
|
||||
}
|
||||
|
||||
function rectTopCenter(rect: DOMRect): { x: number; y: number } {
|
||||
return { x: rect.left + rect.width / 2, y: rect.top };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function bezierPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
|
||||
const dx = b.x - a.x;
|
||||
const cpx = a.x + dx / 2;
|
||||
return `M ${a.x} ${a.y} Q ${cpx} ${a.y} ${(a.x + b.x) / 2} ${(a.y + b.y) / 2} T ${b.x} ${b.y}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function Overlay({
|
||||
strokeColor = "#999",
|
||||
strokeWidth = 1,
|
||||
className,
|
||||
}: OverlayProps = {}) {
|
||||
const { state } = useActiveState();
|
||||
const { registry } = useRectRegistryContext();
|
||||
const version = useRectRegistryVersion();
|
||||
|
||||
const paths = useMemo<readonly string[]>(() => {
|
||||
if (!state.activeTarget || !state.activeEvidenceItemId) return [];
|
||||
const fieldRect = registry.getRect("field", state.activeTarget.targetId);
|
||||
const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
|
||||
const highlightRect = state.activeAnnotationId
|
||||
? registry.getRect("highlight", state.activeAnnotationId)
|
||||
: null;
|
||||
const out: string[] = [];
|
||||
if (fieldRect && cardRect) {
|
||||
out.push(bezierPath(rectBottomCenter(fieldRect), rectTopCenter(cardRect)));
|
||||
}
|
||||
if (cardRect && highlightRect) {
|
||||
out.push(bezierPath(rectTopCenter(cardRect), rectCenter(highlightRect)));
|
||||
}
|
||||
void version; // memo invalidator
|
||||
return out;
|
||||
}, [state, registry, version]);
|
||||
|
||||
if (paths.length === 0) return null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
data-testid="visual-guide-overlay"
|
||||
data-active-target={state.activeTarget?.targetId ?? ""}
|
||||
data-active-evidence={state.activeEvidenceItemId ?? ""}
|
||||
data-path-count={String(paths.length)}
|
||||
className={className}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
pointerEvents: "none",
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
{paths.map((d, i) => (
|
||||
<path
|
||||
key={i}
|
||||
d={d}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
118
src/visual-guide/events.ts
Normal file
118
src/visual-guide/events.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Browser-level rect-change pumps.
|
||||
*
|
||||
* The rect registry holds `getRect` callbacks but doesn't observe the DOM
|
||||
* itself. This module wires the four global change sources from
|
||||
* `wiki/SharedContracts.md` §7 ("scroll, resize, focus, and
|
||||
* active-evidence change") into a single `registry.invalidate()` call.
|
||||
*
|
||||
* Active-evidence change is fired imperatively by the binder service when
|
||||
* it calls `setActiveEvidence` — see `services/bindings.ts`.
|
||||
*
|
||||
* SSR-safe: every API checks `typeof window !== "undefined"` and is a
|
||||
* no-op when the DOM isn't available, so tests that import this module
|
||||
* under Node never crash.
|
||||
*/
|
||||
|
||||
import type { RectRegistry } from "./rect-registry";
|
||||
|
||||
export interface RectChangeObserverOptions {
|
||||
/**
|
||||
* Throttle invalidations to a single requestAnimationFrame; otherwise a
|
||||
* fast scroll event burst causes the overlay to redraw on every pixel.
|
||||
* Defaults to true. Tests pass `false` for deterministic synchronous
|
||||
* behaviour.
|
||||
*/
|
||||
readonly throttle?: boolean;
|
||||
}
|
||||
|
||||
export interface RectChangeObserverHandle {
|
||||
/**
|
||||
* Begin watching a DOM element. The registry is notified of any
|
||||
* scroll/resize/focus event that bubbles to the ancestor chain or fires
|
||||
* on the element itself. Returns a cleanup that stops watching.
|
||||
*/
|
||||
observe(element: Element): () => void;
|
||||
/** Tear down all observers + global listeners. */
|
||||
disconnect(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach scroll/resize/focus pumps to the given registry. Returns an
|
||||
* observer handle so per-element ResizeObservers can be cleaned up by
|
||||
* the components that registered them.
|
||||
*/
|
||||
export function attachRectChangePumps(
|
||||
registry: RectRegistry,
|
||||
options: RectChangeObserverOptions = {},
|
||||
): RectChangeObserverHandle {
|
||||
const throttle = options.throttle ?? true;
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
observe: () => () => {},
|
||||
disconnect: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
let pending = false;
|
||||
|
||||
function invalidate() {
|
||||
if (!throttle) {
|
||||
registry.invalidate();
|
||||
return;
|
||||
}
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
requestAnimationFrame(() => {
|
||||
pending = false;
|
||||
registry.invalidate();
|
||||
});
|
||||
}
|
||||
|
||||
const onScroll = invalidate;
|
||||
const onResize = invalidate;
|
||||
const onFocusIn = invalidate;
|
||||
|
||||
// capture-phase scroll catches scrolling in any nested scroll container,
|
||||
// not just the document — needed for the PDF viewer's inner scroller.
|
||||
window.addEventListener("scroll", onScroll, { passive: true, capture: true });
|
||||
window.addEventListener("resize", onResize, { passive: true });
|
||||
document.addEventListener("focusin", onFocusIn);
|
||||
|
||||
// One global ResizeObserver shared across observed elements is cheaper
|
||||
// than per-element observers but loses the per-element resolution; we
|
||||
// don't need per-element resolution because invalidations are global.
|
||||
const ro: ResizeObserver | null =
|
||||
typeof ResizeObserver !== "undefined" ? new ResizeObserver(invalidate) : null;
|
||||
|
||||
// IntersectionObserver fires when an element moves into/out of the
|
||||
// viewport — useful for the highlight which may scroll off-screen.
|
||||
const io: IntersectionObserver | null =
|
||||
typeof IntersectionObserver !== "undefined"
|
||||
? new IntersectionObserver(invalidate, { threshold: [0, 1] })
|
||||
: null;
|
||||
|
||||
const observedElements = new Set<Element>();
|
||||
|
||||
return {
|
||||
observe(element) {
|
||||
observedElements.add(element);
|
||||
ro?.observe(element);
|
||||
io?.observe(element);
|
||||
return () => {
|
||||
observedElements.delete(element);
|
||||
ro?.unobserve(element);
|
||||
io?.unobserve(element);
|
||||
};
|
||||
},
|
||||
disconnect() {
|
||||
window.removeEventListener("scroll", onScroll, { capture: true } as EventListenerOptions);
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener("focusin", onFocusIn);
|
||||
ro?.disconnect();
|
||||
io?.disconnect();
|
||||
observedElements.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
4
src/visual-guide/index.ts
Normal file
4
src/visual-guide/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./rect-registry";
|
||||
export * from "./events";
|
||||
export * from "./react-hooks";
|
||||
export { Overlay, type OverlayProps } from "./Overlay";
|
||||
152
src/visual-guide/react-hooks.dom.test.tsx
Normal file
152
src/visual-guide/react-hooks.dom.test.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* happy-dom-level test for the rect registry React hooks.
|
||||
*
|
||||
* Verifies the full §7 contract under realistic conditions:
|
||||
* - useRegisterRect attaches a getRect callback bound to the
|
||||
* element's getBoundingClientRect
|
||||
* - mutating the element's rect produces fresh values via getRect
|
||||
* - scroll/resize events on window fan out to a registry invalidate
|
||||
* - useRectRegistryVersion bumps each time the registry emits
|
||||
*/
|
||||
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
RectRegistryProvider,
|
||||
createRectRegistryContextValue,
|
||||
useRectRegistryContext,
|
||||
useRectRegistryVersion,
|
||||
useRegisterRect,
|
||||
} from "./react-hooks";
|
||||
import type { RectRegistryEvent } from "./rect-registry";
|
||||
|
||||
function FieldUnderTest({
|
||||
id,
|
||||
onVersion,
|
||||
}: {
|
||||
id: string;
|
||||
onVersion?: (v: number) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useRegisterRect("field", id, ref);
|
||||
const version = useRectRegistryVersion();
|
||||
onVersion?.(version);
|
||||
return <div ref={ref} data-testid={`f-${id}`} />;
|
||||
}
|
||||
|
||||
function CtxSpy({ onCtx }: { onCtx: (registry: ReturnType<typeof useRectRegistryContext>) => void }) {
|
||||
const ctx = useRectRegistryContext();
|
||||
onCtx(ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useRegisterRect (happy-dom)", () => {
|
||||
let ctxValue: ReturnType<typeof createRectRegistryContextValue>;
|
||||
|
||||
beforeEach(() => {
|
||||
ctxValue = createRectRegistryContextValue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctxValue.observer.disconnect();
|
||||
});
|
||||
|
||||
it("registers the element's getBoundingClientRect and unregisters on unmount", () => {
|
||||
const events: RectRegistryEvent[] = [];
|
||||
ctxValue.registry.subscribe((e) => events.push(e));
|
||||
|
||||
const { unmount } = render(
|
||||
<RectRegistryProvider value={ctxValue}>
|
||||
<FieldUnderTest id="summary" />
|
||||
</RectRegistryProvider>,
|
||||
);
|
||||
|
||||
expect(ctxValue.registry.getRect("field", "summary")).not.toBeNull();
|
||||
expect(ctxValue.registry.list()).toEqual([{ kind: "field", id: "summary" }]);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(ctxValue.registry.getRect("field", "summary")).toBeNull();
|
||||
expect(events.map((e) => e.type)).toContain("unregistered");
|
||||
});
|
||||
|
||||
it("getRect reflects mutated bounding rects", () => {
|
||||
let getter: () => DOMRect | null = () => null;
|
||||
// Spy on the registered callback by hijacking register
|
||||
const realRegister = ctxValue.registry.register;
|
||||
ctxValue.registry.register = (kind, id, fn) => {
|
||||
getter = fn;
|
||||
return realRegister.call(ctxValue.registry, kind, id, fn);
|
||||
};
|
||||
|
||||
render(
|
||||
<RectRegistryProvider value={ctxValue}>
|
||||
<FieldUnderTest id="amount" />
|
||||
</RectRegistryProvider>,
|
||||
);
|
||||
|
||||
// happy-dom returns a DOMRect with all zeros by default. Patch the
|
||||
// element's getBoundingClientRect and verify the registered callback
|
||||
// forwards the new rect.
|
||||
const el = document.querySelector('[data-testid="f-amount"]') as HTMLDivElement;
|
||||
el.getBoundingClientRect = () => ({
|
||||
x: 11,
|
||||
y: 22,
|
||||
width: 33,
|
||||
height: 44,
|
||||
top: 22,
|
||||
left: 11,
|
||||
right: 11 + 33,
|
||||
bottom: 22 + 44,
|
||||
toJSON() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
const rect = getter();
|
||||
expect(rect).not.toBeNull();
|
||||
expect(rect!.x).toBe(11);
|
||||
expect(rect!.width).toBe(33);
|
||||
});
|
||||
|
||||
it("useRectRegistryVersion bumps on register and on invalidate", async () => {
|
||||
const seen: number[] = [];
|
||||
const renderResult = render(
|
||||
<RectRegistryProvider value={ctxValue}>
|
||||
<FieldUnderTest
|
||||
id="bumpy"
|
||||
onVersion={(v) => seen.push(v)}
|
||||
/>
|
||||
</RectRegistryProvider>,
|
||||
);
|
||||
|
||||
// Wait one microtask for effects to flush.
|
||||
await act(async () => {});
|
||||
|
||||
const beforeInvalidate = seen[seen.length - 1]!;
|
||||
await act(async () => {
|
||||
ctxValue.registry.invalidate();
|
||||
});
|
||||
const afterInvalidate = seen[seen.length - 1]!;
|
||||
expect(afterInvalidate).toBeGreaterThan(beforeInvalidate);
|
||||
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
it("exposes the same registry across consumers in the provider subtree", () => {
|
||||
let firstCtx: ReturnType<typeof useRectRegistryContext> | undefined;
|
||||
let secondCtx: ReturnType<typeof useRectRegistryContext> | undefined;
|
||||
render(
|
||||
<RectRegistryProvider value={ctxValue}>
|
||||
<CtxSpy onCtx={(c) => (firstCtx = c)} />
|
||||
<CtxSpy onCtx={(c) => (secondCtx = c)} />
|
||||
</RectRegistryProvider>,
|
||||
);
|
||||
expect(firstCtx).toBe(secondCtx);
|
||||
expect(firstCtx?.registry).toBe(ctxValue.registry);
|
||||
});
|
||||
});
|
||||
98
src/visual-guide/react-hooks.ts
Normal file
98
src/visual-guide/react-hooks.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* React hooks for the rect registry.
|
||||
*
|
||||
* Components mount, get a ref to a DOM node, and ask the registry to
|
||||
* track it via `useRegisterRect(kind, id, ref)`. Unmount/ref-change
|
||||
* unregisters automatically.
|
||||
*
|
||||
* The registry itself lives behind a React context so multiple subtrees
|
||||
* can share one registry (the overlay sees what every renderer publishes).
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useSyncExternalStore,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
createRectRegistry,
|
||||
type RectKind,
|
||||
type RectRegistry,
|
||||
} from "./rect-registry";
|
||||
import { attachRectChangePumps, type RectChangeObserverHandle } from "./events";
|
||||
|
||||
export interface RectRegistryContextValue {
|
||||
readonly registry: RectRegistry;
|
||||
readonly observer: RectChangeObserverHandle;
|
||||
}
|
||||
|
||||
const RectRegistryContext = createContext<RectRegistryContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Create an isolated registry + change pump pair for tests or app
|
||||
* composition roots that wire their own provider.
|
||||
*/
|
||||
export function createRectRegistryContextValue(): RectRegistryContextValue {
|
||||
const registry = createRectRegistry();
|
||||
const observer = attachRectChangePumps(registry);
|
||||
return { registry, observer };
|
||||
}
|
||||
|
||||
export function useRectRegistryContext(): RectRegistryContextValue {
|
||||
const ctx = useContext(RectRegistryContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useRectRegistryContext must be used inside <RectRegistryProvider />",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export const RectRegistryProvider = RectRegistryContext.Provider;
|
||||
|
||||
/**
|
||||
* Register a DOM ref's bounding rect with the registry.
|
||||
*
|
||||
* Re-runs when `kind`/`id`/`ref.current` change. The observer also starts
|
||||
* watching the element for scroll/resize so the overlay can re-query
|
||||
* without polling.
|
||||
*/
|
||||
export function useRegisterRect(
|
||||
kind: RectKind,
|
||||
id: string,
|
||||
ref: RefObject<Element | null>,
|
||||
): void {
|
||||
const { registry, observer } = useRectRegistryContext();
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const unregister = registry.register(kind, id, () => el.getBoundingClientRect());
|
||||
const unobserve = observer.observe(el);
|
||||
return () => {
|
||||
unobserve();
|
||||
unregister();
|
||||
};
|
||||
}, [kind, id, ref, registry, observer]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to registry change events from inside React. Returns a
|
||||
* monotonically-increasing version number that bumps on every event, so
|
||||
* `useMemo`/`useEffect` deps can include it to re-derive cached values.
|
||||
*
|
||||
* Implementation: leans on `registry.getVersion()` for the snapshot so
|
||||
* `useSyncExternalStore` doesn't accumulate per-render subscribers.
|
||||
*/
|
||||
export function useRectRegistryVersion(): number {
|
||||
const { registry } = useRectRegistryContext();
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => registry.subscribe(callback),
|
||||
[registry],
|
||||
);
|
||||
const getSnapshot = useCallback(() => registry.getVersion(), [registry]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => 0);
|
||||
}
|
||||
151
src/visual-guide/rect-registry.test.ts
Normal file
151
src/visual-guide/rect-registry.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Rect registry unit tests — exercise every public surface plus the
|
||||
* §7-contract guarantees:
|
||||
* - register/unregister fire subscriber events
|
||||
* - getRect returns the live result of the registered callback
|
||||
* - invalidate fires a global `rect-changed` event
|
||||
* - version bumps on every emit
|
||||
* - re-registering the same (kind,id) supersedes the prior callback;
|
||||
* the stale unregister cleanup does not delete the new entry.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createRectRegistry,
|
||||
type RectRegistryEvent,
|
||||
} from "./rect-registry";
|
||||
|
||||
function fakeRect(x: number, y: number, w: number, h: number): DOMRect {
|
||||
// happy-dom/jsdom isn't loaded for this test — synth a DOMRect-shaped
|
||||
// object. The registry contract only reads these properties.
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
top: y,
|
||||
left: x,
|
||||
right: x + w,
|
||||
bottom: y + h,
|
||||
toJSON() {
|
||||
return { x, y, width: w, height: h };
|
||||
},
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
describe("createRectRegistry", () => {
|
||||
it("returns null for unknown rects", () => {
|
||||
const r = createRectRegistry();
|
||||
expect(r.getRect("field", "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("register/getRect roundtrip", () => {
|
||||
const r = createRectRegistry();
|
||||
r.register("field", "f1", () => fakeRect(1, 2, 3, 4));
|
||||
const rect = r.getRect("field", "f1");
|
||||
expect(rect).not.toBeNull();
|
||||
expect(rect!.x).toBe(1);
|
||||
expect(rect!.width).toBe(3);
|
||||
});
|
||||
|
||||
it("getRect reflects live callback results", () => {
|
||||
const r = createRectRegistry();
|
||||
let xPos = 10;
|
||||
r.register("highlight", "h1", () => fakeRect(xPos, 0, 5, 5));
|
||||
expect(r.getRect("highlight", "h1")!.x).toBe(10);
|
||||
xPos = 200;
|
||||
expect(r.getRect("highlight", "h1")!.x).toBe(200);
|
||||
});
|
||||
|
||||
it("returns null when the callback throws", () => {
|
||||
const r = createRectRegistry();
|
||||
r.register("field", "boom", () => {
|
||||
throw new Error("nope");
|
||||
});
|
||||
expect(r.getRect("field", "boom")).toBeNull();
|
||||
});
|
||||
|
||||
it("emits registered + unregistered events", () => {
|
||||
const r = createRectRegistry();
|
||||
const events: RectRegistryEvent[] = [];
|
||||
r.subscribe((e) => events.push(e));
|
||||
const unregister = r.register("evidence-card", "ev1", () => fakeRect(0, 0, 1, 1));
|
||||
unregister();
|
||||
expect(events).toEqual([
|
||||
{ type: "registered", kind: "evidence-card", id: "ev1" },
|
||||
{ type: "unregistered", kind: "evidence-card", id: "ev1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("invalidate emits a global rect-changed event and bumps version", () => {
|
||||
const r = createRectRegistry();
|
||||
const events: RectRegistryEvent[] = [];
|
||||
r.subscribe((e) => events.push(e));
|
||||
const before = r.getVersion();
|
||||
r.invalidate();
|
||||
expect(events).toEqual([{ type: "rect-changed" }]);
|
||||
expect(r.getVersion()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("re-registering the same (kind,id) supersedes; stale cleanup is a no-op", () => {
|
||||
const r = createRectRegistry();
|
||||
const events: RectRegistryEvent[] = [];
|
||||
r.subscribe((e) => events.push(e));
|
||||
|
||||
const firstGetRect = () => fakeRect(1, 1, 1, 1);
|
||||
const secondGetRect = () => fakeRect(9, 9, 9, 9);
|
||||
|
||||
const cleanup1 = r.register("highlight", "x", firstGetRect);
|
||||
r.register("highlight", "x", secondGetRect); // supersede
|
||||
|
||||
// The stale cleanup must not remove the new registration.
|
||||
cleanup1();
|
||||
|
||||
expect(r.getRect("highlight", "x")!.x).toBe(9);
|
||||
// Two `registered` events, no `unregistered` event — the second
|
||||
// register overwrote without an explicit unregister, and the stale
|
||||
// cleanup detected the (kind,id) holds a different callback.
|
||||
expect(events.filter((e) => e.type === "unregistered")).toHaveLength(0);
|
||||
expect(events.filter((e) => e.type === "registered")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("subscribe returns an unsubscribe that detaches the listener", () => {
|
||||
const r = createRectRegistry();
|
||||
let count = 0;
|
||||
const off = r.subscribe(() => count++);
|
||||
r.invalidate();
|
||||
off();
|
||||
r.invalidate();
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("listener errors do not break sibling listeners", () => {
|
||||
const r = createRectRegistry();
|
||||
let okCount = 0;
|
||||
r.subscribe(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
r.subscribe(() => {
|
||||
okCount++;
|
||||
});
|
||||
r.invalidate();
|
||||
expect(okCount).toBe(1);
|
||||
});
|
||||
|
||||
it("list enumerates current registrations", () => {
|
||||
const r = createRectRegistry();
|
||||
r.register("field", "f1", () => null);
|
||||
r.register("evidence-card", "ev1", () => null);
|
||||
r.register("highlight", "h1", () => null);
|
||||
const list = r.list();
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "field", id: "f1" },
|
||||
{ kind: "evidence-card", id: "ev1" },
|
||||
{ kind: "highlight", id: "h1" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
BIN
src/visual-guide/rect-registry.ts
Normal file
BIN
src/visual-guide/rect-registry.ts
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue