Skip to content

Commit f95bf7a

Browse files
feat(sdk): ws-a1 — iframe preview adapter (hit-test + selection)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent b0ac9a2 commit f95bf7a

3 files changed

Lines changed: 306 additions & 0 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Unit tests for resolveNearestHfElement (pure resolver — no browser needed).
3+
*
4+
* elementFromPoint itself requires a real browser layout engine. The adapter's
5+
* elementAtPoint() method is therefore NOT tested here; cover it with an
6+
* integration test that mounts a real same-origin iframe (WS-A1 follow-on).
7+
*/
8+
9+
import { describe, it, expect, vi } from "vitest";
10+
import { resolveNearestHfElement } from "./iframe.js";
11+
import type { ElementAtPointResult } from "./types.js";
12+
13+
// ─── Minimal fake element ────────────────────────────────────────────────────
14+
15+
interface FakeEl {
16+
attrs: Record<string, string>;
17+
tagName: string;
18+
parentElement: FakeEl | null;
19+
getAttribute(name: string): string | null;
20+
hasAttribute(name: string): boolean;
21+
}
22+
23+
function fakeEl(
24+
attrs: Record<string, string>,
25+
tagName: string,
26+
parent: FakeEl | null = null,
27+
): FakeEl {
28+
return {
29+
attrs,
30+
tagName,
31+
parentElement: parent,
32+
getAttribute(name) {
33+
return Object.prototype.hasOwnProperty.call(this.attrs, name) ? this.attrs[name] : null;
34+
},
35+
hasAttribute(name) {
36+
return Object.prototype.hasOwnProperty.call(this.attrs, name);
37+
},
38+
};
39+
}
40+
41+
const visible = () => true;
42+
const invisible = () => false;
43+
44+
// ─── Tests ────────────────────────────────────────────────────────────────────
45+
46+
describe("resolveNearestHfElement", () => {
47+
it("returns null for a null input", () => {
48+
expect(resolveNearestHfElement(null, visible)).toBeNull();
49+
});
50+
51+
it("returns the element itself when it carries data-hf-id", () => {
52+
const el = fakeEl({ "data-hf-id": "hf-abc" }, "div");
53+
const result = resolveNearestHfElement(el as unknown as Element, visible);
54+
expect(result).toEqual<ElementAtPointResult>({ id: "hf-abc", tag: "div" });
55+
});
56+
57+
it("walks up to a parent that carries data-hf-id", () => {
58+
const parent = fakeEl({ "data-hf-id": "hf-parent" }, "section");
59+
const child = fakeEl({}, "span", parent);
60+
const result = resolveNearestHfElement(child as unknown as Element, visible);
61+
expect(result).toEqual<ElementAtPointResult>({ id: "hf-parent", tag: "section" });
62+
});
63+
64+
it("returns null when the nearest data-hf-id node is data-hf-root", () => {
65+
const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div");
66+
const child = fakeEl({}, "p", root);
67+
expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull();
68+
});
69+
70+
it("returns null when the element itself is data-hf-root", () => {
71+
const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div");
72+
expect(resolveNearestHfElement(root as unknown as Element, visible)).toBeNull();
73+
});
74+
75+
it("returns null when isVisible returns false for the matching element", () => {
76+
const el = fakeEl({ "data-hf-id": "hf-abc" }, "div");
77+
expect(resolveNearestHfElement(el as unknown as Element, invisible)).toBeNull();
78+
});
79+
80+
it("skips an opacity-0 element and returns null (isVisible called on the resolved node)", () => {
81+
// isVisible is only checked on the RESOLVED node, not intermediary nodes.
82+
const parent = fakeEl({ "data-hf-id": "hf-parent" }, "div");
83+
const child = fakeEl({}, "span", parent);
84+
// Make parent invisible
85+
const isVisible = vi.fn((el: Element) => {
86+
const fe = el as unknown as FakeEl;
87+
return fe.attrs["data-hf-id"] !== "hf-parent";
88+
});
89+
expect(resolveNearestHfElement(child as unknown as Element, isVisible)).toBeNull();
90+
// isVisible was called once (on the resolved parent node)
91+
expect(isVisible).toHaveBeenCalledTimes(1);
92+
});
93+
94+
it("returns null when no data-hf-id found in any ancestor", () => {
95+
const grandparent = fakeEl({}, "body");
96+
const parent = fakeEl({}, "div", grandparent);
97+
const child = fakeEl({}, "span", parent);
98+
expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull();
99+
});
100+
101+
it("tag is lowercased", () => {
102+
const el = fakeEl({ "data-hf-id": "hf-xyz" }, "DIV");
103+
const result = resolveNearestHfElement(el as unknown as Element, visible);
104+
expect(result?.tag).toBe("div");
105+
});
106+
107+
it("stops at the nearest ancestor — does not continue past first data-hf-id", () => {
108+
const outer = fakeEl({ "data-hf-id": "hf-outer" }, "section");
109+
const inner = fakeEl({ "data-hf-id": "hf-inner" }, "div", outer);
110+
const child = fakeEl({}, "span", inner);
111+
const result = resolveNearestHfElement(child as unknown as Element, visible);
112+
expect(result?.id).toBe("hf-inner");
113+
});
114+
});
115+
116+
// ─── select + on('selection') wiring ─────────────────────────────────────────
117+
// These cover the adapter-level selection state without needing a real iframe.
118+
// We import createIframePreviewAdapter and pass a stub iframe.
119+
120+
import { createIframePreviewAdapter } from "./iframe.js";
121+
122+
function stubIframe() {
123+
return {} as HTMLIFrameElement;
124+
}
125+
126+
describe("IframePreviewAdapter selection", () => {
127+
it("on('selection') fires when select() is called", () => {
128+
const adapter = createIframePreviewAdapter(stubIframe());
129+
const cb = vi.fn();
130+
adapter.on("selection", cb);
131+
adapter.select(["hf-abc"]);
132+
expect(cb).toHaveBeenCalledWith(["hf-abc"]);
133+
});
134+
135+
it("off unsubscribes the handler", () => {
136+
const adapter = createIframePreviewAdapter(stubIframe());
137+
const cb = vi.fn();
138+
const off = adapter.on("selection", cb);
139+
off();
140+
adapter.select(["hf-abc"]);
141+
expect(cb).not.toHaveBeenCalled();
142+
});
143+
144+
it("additive select merges with prior selection", () => {
145+
const adapter = createIframePreviewAdapter(stubIframe());
146+
const cb = vi.fn();
147+
adapter.on("selection", cb);
148+
adapter.select(["hf-a"]);
149+
adapter.select(["hf-b"], { additive: true });
150+
expect(cb).toHaveBeenLastCalledWith(expect.arrayContaining(["hf-a", "hf-b"]));
151+
});
152+
153+
it("non-additive select replaces prior selection", () => {
154+
const adapter = createIframePreviewAdapter(stubIframe());
155+
const cb = vi.fn();
156+
adapter.on("selection", cb);
157+
adapter.select(["hf-a"]);
158+
adapter.select(["hf-b"]);
159+
expect(cb).toHaveBeenLastCalledWith(["hf-b"]);
160+
});
161+
162+
it("multiple handlers all fire", () => {
163+
const adapter = createIframePreviewAdapter(stubIframe());
164+
const cb1 = vi.fn();
165+
const cb2 = vi.fn();
166+
adapter.on("selection", cb1);
167+
adapter.on("selection", cb2);
168+
adapter.select(["hf-abc"]);
169+
expect(cb1).toHaveBeenCalledOnce();
170+
expect(cb2).toHaveBeenCalledOnce();
171+
});
172+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection).
3+
*
4+
* Requirements:
5+
* - The iframe MUST be same-origin (srcdoc / blob URL). Cross-origin access to
6+
* contentDocument throws a DOMException; this adapter does not guard that —
7+
* the caller is responsible for ensuring same-origin.
8+
* - applyDraft / commitPreview / cancelPreview are WS-A2 scope — stubbed here.
9+
*/
10+
11+
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
12+
13+
// ─── Pure resolver (testable without a browser) ───────────────────────────────
14+
15+
/**
16+
* Walk from `el` upward through parentElement, looking for the nearest node
17+
* that carries `[data-hf-id]` and is NOT `[data-hf-root]`.
18+
*
19+
* Returns null when:
20+
* - The walk exits the tree without finding `[data-hf-id]`
21+
* - The matching node is `[data-hf-root]` (transparent to hit-testing)
22+
* - `isVisible(node)` returns false for the matching node
23+
*
24+
* Keeping this a pure function (no elementFromPoint, no window access) makes
25+
* it unit-testable in a plain Node environment.
26+
*/
27+
export function resolveNearestHfElement(
28+
el: Element | null,
29+
isVisible: (el: Element) => boolean,
30+
): ElementAtPointResult | null {
31+
let node = el;
32+
while (node !== null) {
33+
const id = node.getAttribute("data-hf-id");
34+
if (id !== null) {
35+
if (node.hasAttribute("data-hf-root")) return null;
36+
if (!isVisible(node)) return null;
37+
return { id, tag: node.tagName.toLowerCase() };
38+
}
39+
node = node.parentElement;
40+
}
41+
return null;
42+
}
43+
44+
// ─── Visibility check ─────────────────────────────────────────────────────────
45+
46+
/**
47+
* Returns true when no element in the ancestor chain (inclusive) has
48+
* computed opacity === 0. Checks ancestors because a parent at opacity:0
49+
* makes the child invisible even if the child's own opacity is 1.
50+
*
51+
* This reflects the current GSAP timeline state (whatever the player has
52+
* seeked to). For atTime values matching the live playhead this is always
53+
* accurate. For speculative times this is NOT seeked — WS-A1 does not mutate
54+
* the timeline; accurate out-of-band opacity queries are WS-G follow-on.
55+
*/
56+
function isOpacityVisible(el: Element, win: Window & typeof globalThis): boolean {
57+
let node: Element | null = el;
58+
while (node !== null) {
59+
const style = win.getComputedStyle(node);
60+
if (parseFloat(style.opacity) === 0) return false;
61+
node = node.parentElement;
62+
}
63+
return true;
64+
}
65+
66+
// ─── IframePreviewAdapter ─────────────────────────────────────────────────────
67+
68+
type SelectionHandler = (ids: string[]) => void;
69+
70+
class IframePreviewAdapter implements PreviewAdapter {
71+
private readonly iframe: HTMLIFrameElement;
72+
private _selection: string[] = [];
73+
private _handlers: SelectionHandler[] = [];
74+
75+
constructor(iframe: HTMLIFrameElement) {
76+
this.iframe = iframe;
77+
}
78+
79+
/**
80+
* Synchronous hit-test. Returns the nearest `[data-hf-id]` element under
81+
* (x, y) in the iframe's coordinate space, or null for a transparent hit
82+
* (root, opacity-0, or nothing at all).
83+
*
84+
* atTime: reflects the GSAP state at the playhead when this is called.
85+
* Seeking to a different time to check visibility is WS-G scope.
86+
*/
87+
elementAtPoint(x: number, y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
88+
const doc = this.iframe.contentDocument;
89+
if (!doc) return null;
90+
const win = this.iframe.contentWindow as (Window & typeof globalThis) | null;
91+
if (!win) return null;
92+
93+
const hit = doc.elementFromPoint(x, y);
94+
return resolveNearestHfElement(hit, (el) => isOpacityVisible(el, win));
95+
}
96+
97+
// WS-A2 stubs — commitPreview / applyDraft derive the moveElement op --------
98+
99+
applyDraft(_id: string, _props: DraftProps): void {}
100+
101+
commitPreview(): void {}
102+
103+
cancelPreview(): void {}
104+
105+
// Selection -----------------------------------------------------------------
106+
107+
select(ids: string[], opts?: { additive?: boolean }): void {
108+
if (opts?.additive) {
109+
const merged = new Set([...this._selection, ...ids]);
110+
this._selection = [...merged];
111+
} else {
112+
this._selection = [...ids];
113+
}
114+
this._emit();
115+
}
116+
117+
on(event: "selection", handler: SelectionHandler): () => void {
118+
if (event !== "selection") return () => {};
119+
this._handlers.push(handler);
120+
return () => {
121+
this._handlers = this._handlers.filter((h) => h !== handler);
122+
};
123+
}
124+
125+
private _emit(): void {
126+
const ids = [...this._selection];
127+
for (const h of this._handlers) h(ids);
128+
}
129+
}
130+
131+
export function createIframePreviewAdapter(iframe: HTMLIFrameElement): PreviewAdapter {
132+
return new IframePreviewAdapter(iframe);
133+
}

packages/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,4 @@ export { createMemoryAdapter } from "./adapters/memory.js";
3939
export { createHeadlessAdapter } from "./adapters/headless.js";
4040
export { createHttpAdapter } from "./adapters/http.js";
4141
export type { HttpAdapterOptions } from "./adapters/http.js";
42+
export { createIframePreviewAdapter, resolveNearestHfElement } from "./adapters/iframe.js";

0 commit comments

Comments
 (0)