feat(sdk): ws-a2 — applyDraft/commitPreview/cancelPreview → moveElement op (#1490)

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:46:04 -07:00
committed by GitHub
co-authored by Miguel Ángel
parent b96e8a3072
commit 53717a77f4
2 changed files with 297 additions and 22 deletions
+194 -13
View File
@@ -1,14 +1,23 @@
/**
* Unit tests for resolveNearestHfElement (pure resolver — no browser needed).
* Unit tests for the pure functions in iframe.ts (no browser needed).
*
* elementFromPoint itself requires a real browser layout engine. The adapter's
* elementAtPoint() method is therefore NOT tested here; cover it with an
* integration test that mounts a real same-origin iframe (WS-A1 follow-on).
* elementFromPoint requires a real layout engine — the adapter's elementAtPoint()
* is NOT tested here. Cover it with an integration test mounting a same-origin
* iframe (WS-A1 follow-on).
*
* applyDraft / commitPreview / cancelPreview require HTMLElement.style + querySelector
* which are also browser-only. They are tested via a lightweight fake-DOM helper
* that simulates style.setProperty / getAttribute / removeProperty.
*/
import { describe, it, expect, vi } from "vitest";
import { resolveNearestHfElement } from "./iframe.js";
import {
resolveNearestHfElement,
computeDraftPosition,
createIframePreviewAdapter,
} from "./iframe.js";
import type { ElementAtPointResult } from "./types.js";
import type { EditOp } from "../types.js";
// ─── Minimal fake element ────────────────────────────────────────────────────
@@ -41,7 +50,7 @@ function fakeEl(
const visible = () => true;
const invisible = () => false;
// ─── Tests ────────────────────────────────────────────────────────────────────
// ─── resolveNearestHfElement ──────────────────────────────────────────────────
describe("resolveNearestHfElement", () => {
it("returns null for a null input", () => {
@@ -78,16 +87,13 @@ describe("resolveNearestHfElement", () => {
});
it("skips an opacity-0 element and returns null (isVisible called on the resolved node)", () => {
// isVisible is only checked on the RESOLVED node, not intermediary nodes.
const parent = fakeEl({ "data-hf-id": "hf-parent" }, "div");
const child = fakeEl({}, "span", parent);
// Make parent invisible
const isVisible = vi.fn((el: Element) => {
const fe = el as unknown as FakeEl;
return fe.attrs["data-hf-id"] !== "hf-parent";
});
expect(resolveNearestHfElement(child as unknown as Element, isVisible)).toBeNull();
// isVisible was called once (on the resolved parent node)
expect(isVisible).toHaveBeenCalledTimes(1);
});
@@ -113,11 +119,31 @@ describe("resolveNearestHfElement", () => {
});
});
// ─── select + on('selection') wiring ─────────────────────────────────────────
// These cover the adapter-level selection state without needing a real iframe.
// We import createIframePreviewAdapter and pass a stub iframe.
// ─── computeDraftPosition ─────────────────────────────────────────────────────
import { createIframePreviewAdapter } from "./iframe.js";
describe("computeDraftPosition", () => {
it("applies delta to base data-x/data-y", () => {
expect(computeDraftPosition("100", "200", 30, -10)).toEqual({ x: 130, y: 190 });
});
it("defaults missing data-x/data-y to 0", () => {
expect(computeDraftPosition(null, null, 50, 25)).toEqual({ x: 50, y: 25 });
});
it("defaults non-numeric data-x/data-y to 0", () => {
expect(computeDraftPosition("abc", "xyz", 10, 5)).toEqual({ x: 10, y: 5 });
});
it("works with zero delta (no-move commit)", () => {
expect(computeDraftPosition("40", "80", 0, 0)).toEqual({ x: 40, y: 80 });
});
it("handles negative base positions", () => {
expect(computeDraftPosition("-20", "0", 5, 10)).toEqual({ x: -15, y: 10 });
});
});
// ─── IframePreviewAdapter selection ──────────────────────────────────────────
function stubIframe() {
return {} as HTMLIFrameElement;
@@ -170,3 +196,158 @@ describe("IframePreviewAdapter selection", () => {
expect(cb2).toHaveBeenCalledOnce();
});
});
// ─── applyDraft / commitPreview / cancelPreview ───────────────────────────────
// Tests use a fake iframe+element because HTMLElement.style requires a browser.
interface FakeStyle {
_props: Record<string, string>;
setProperty(name: string, value: string): void;
getPropertyValue(name: string): string;
removeProperty(name: string): void;
}
interface FakeDomEl {
"data-hf-id": string;
"data-x": string | null;
"data-y": string | null;
style: FakeStyle;
getAttribute(name: string): string | null;
querySelector(sel: string): FakeDomEl | null;
}
function fakeDomEl(id: string, dataX: string | null, dataY: string | null): FakeDomEl {
const style: FakeStyle = {
_props: {},
setProperty(name, value) {
this._props[name] = value;
},
getPropertyValue(name) {
return this._props[name] ?? "";
},
removeProperty(name) {
delete this._props[name];
},
};
const el: FakeDomEl = {
"data-hf-id": id,
"data-x": dataX,
"data-y": dataY,
style,
getAttribute(name) {
if (name === "data-x") return this["data-x"];
if (name === "data-y") return this["data-y"];
if (name === "data-hf-id") return this["data-hf-id"];
return null;
},
querySelector(_sel: string) {
return null;
},
};
return el;
}
function fakeIframe(el: FakeDomEl | null): HTMLIFrameElement {
return {
contentDocument: {
querySelector(_sel: string) {
return el;
},
},
} as unknown as HTMLIFrameElement;
}
describe("IframePreviewAdapter draft / commit / cancel", () => {
it("commitPreview without applyDraft is a no-op", () => {
const dispatch = vi.fn();
const adapter = createIframePreviewAdapter(stubIframe(), dispatch);
adapter.commitPreview();
expect(dispatch).not.toHaveBeenCalled();
});
it("cancelPreview without applyDraft is a no-op", () => {
const dispatch = vi.fn();
const adapter = createIframePreviewAdapter(stubIframe(), dispatch);
adapter.cancelPreview();
expect(dispatch).not.toHaveBeenCalled();
});
it("commitPreview dispatches moveElement with correct absolute position", () => {
const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", "100", "200");
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 30, dy: -20 });
adapter.commitPreview();
expect(dispatch).toHaveBeenCalledWith<[EditOp]>({
type: "moveElement",
target: "hf-abc",
x: 130,
y: 180,
});
});
it("commitPreview with missing data-x/data-y defaults base to 0", () => {
const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", null, null);
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 50, dy: 25 });
adapter.commitPreview();
expect(dispatch).toHaveBeenCalledWith<[EditOp]>({
type: "moveElement",
target: "hf-abc",
x: 50,
y: 25,
});
});
it("commitPreview without a dispatch callback is a no-op", () => {
const el = fakeDomEl("hf-abc", "0", "0");
const adapter = createIframePreviewAdapter(fakeIframe(el));
adapter.applyDraft("hf-abc", { dx: 10, dy: 10 });
// should not throw
adapter.commitPreview();
});
it("cancelPreview clears draft vars without dispatching", () => {
const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", "100", "200");
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 30, dy: 20 });
adapter.cancelPreview();
expect(dispatch).not.toHaveBeenCalled();
// CSS vars cleared
expect(el.style.getPropertyValue("--hf-studio-dx")).toBe("");
expect(el.style.getPropertyValue("--hf-studio-dy")).toBe("");
});
it("commitPreview clears draft vars after dispatching", () => {
const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", "0", "0");
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 10, dy: 5 });
adapter.commitPreview();
expect(el.style.getPropertyValue("--hf-studio-dx")).toBe("");
expect(el.style.getPropertyValue("--hf-studio-dy")).toBe("");
});
it("second commitPreview after first is a no-op (draft cleared)", () => {
const dispatch = vi.fn();
const el = fakeDomEl("hf-abc", "0", "0");
const adapter = createIframePreviewAdapter(fakeIframe(el), dispatch);
adapter.applyDraft("hf-abc", { dx: 10, dy: 5 });
adapter.commitPreview();
adapter.commitPreview();
expect(dispatch).toHaveBeenCalledTimes(1);
});
});
+103 -9
View File
@@ -1,14 +1,20 @@
/**
* Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection).
* Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection) +
* WS-A2 (applyDraft / commitPreview / cancelPreview → moveElement).
*
* Requirements:
* - The iframe MUST be same-origin (srcdoc / blob URL). Cross-origin access to
* contentDocument throws a DOMException; this adapter does not guard that —
* the caller is responsible for ensuring same-origin.
* - applyDraft / commitPreview / cancelPreview are WS-A2 scope — stubbed here.
*/
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
import type { EditOp } from "../types.js";
// ─── CSS var names written onto elements during drag ─────────────────────────
const VAR_DX = "--hf-studio-dx";
const VAR_DY = "--hf-studio-dy";
// ─── Pure resolver (testable without a browser) ───────────────────────────────
@@ -41,6 +47,26 @@ export function resolveNearestHfElement(
return null;
}
// ─── Draft position math (pure — testable without a browser) ─────────────────
/**
* Compute the new absolute x/y for a moveElement op given:
* - the element's current `data-x` / `data-y` string values (may be null)
* - the accumulated drag delta (dx, dy) from applyDraft calls
*
* `data-x` / `data-y` default to 0 when absent or non-numeric.
*/
export function computeDraftPosition(
dataX: string | null,
dataY: string | null,
dx: number,
dy: number,
): { x: number; y: number } {
const baseX = parseFloat(dataX ?? "0") || 0;
const baseY = parseFloat(dataY ?? "0") || 0;
return { x: baseX + dx, y: baseY + dy };
}
// ─── Visibility check ─────────────────────────────────────────────────────────
/**
@@ -69,11 +95,18 @@ type SelectionHandler = (ids: string[]) => void;
class IframePreviewAdapter implements PreviewAdapter {
private readonly iframe: HTMLIFrameElement;
private readonly _dispatch: ((op: EditOp) => void) | undefined;
private _selection: string[] = [];
private _handlers: SelectionHandler[] = [];
constructor(iframe: HTMLIFrameElement) {
/** Tracked id and element for the in-progress drag. */
private _draftId: string | null = null;
private _draftEl: Element | null = null;
constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) {
this.iframe = iframe;
this._dispatch = dispatch;
}
/**
@@ -94,13 +127,71 @@ class IframePreviewAdapter implements PreviewAdapter {
return resolveNearestHfElement(hit, (el) => isOpacityVisible(el, win));
}
// WS-A2 stubs — commitPreview / applyDraft derive the moveElement op --------
/**
* Write draft CSS custom properties (`--hf-studio-dx`, `--hf-studio-dy`) onto
* the target element inside the iframe at 60fps. The composition's CSS uses
* these vars to visually translate the element without touching the model.
*
* Calling applyDraft with a new id replaces the tracked element (does not
* cancel the prior draft — call cancelPreview first if switching targets).
*
* width/height in DraftProps are not yet wired (resize → setStyle, future op).
*/
applyDraft(id: string, props: DraftProps): void {
const doc = this.iframe.contentDocument;
if (!doc) return;
applyDraft(_id: string, _props: DraftProps): void {}
const el = doc.querySelector<HTMLElement>(
`[data-hf-id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`,
);
if (!el) return;
commitPreview(): void {}
this._draftId = id;
this._draftEl = el;
cancelPreview(): void {}
if (props.dx !== undefined) el.style.setProperty(VAR_DX, String(props.dx));
if (props.dy !== undefined) el.style.setProperty(VAR_DY, String(props.dy));
}
/**
* Read the accumulated draft deltas, derive a moveElement op, dispatch it,
* then clear the CSS vars and draft state.
*
* No-ops when:
* - No applyDraft was called (nothing to commit)
* - No dispatch callback was provided at construction
*/
commitPreview(): void {
if (!this._draftId || !this._draftEl || !this._dispatch) {
this._clearDraft();
return;
}
const el = this._draftEl as HTMLElement;
const dx = parseFloat(el.style.getPropertyValue(VAR_DX) || "0") || 0;
const dy = parseFloat(el.style.getPropertyValue(VAR_DY) || "0") || 0;
const dataX = (this._draftEl as Element).getAttribute("data-x");
const dataY = (this._draftEl as Element).getAttribute("data-y");
const { x, y } = computeDraftPosition(dataX, dataY, dx, dy);
this._dispatch({ type: "moveElement", target: this._draftId, x, y });
this._clearDraft();
}
/** Revert draft CSS vars without dispatching any op. */
cancelPreview(): void {
this._clearDraft();
}
private _clearDraft(): void {
if (this._draftEl) {
const el = this._draftEl as HTMLElement;
el.style.removeProperty(VAR_DX);
el.style.removeProperty(VAR_DY);
}
this._draftId = null;
this._draftEl = null;
}
// Selection -----------------------------------------------------------------
@@ -128,6 +219,9 @@ class IframePreviewAdapter implements PreviewAdapter {
}
}
export function createIframePreviewAdapter(iframe: HTMLIFrameElement): PreviewAdapter {
return new IframePreviewAdapter(iframe);
export function createIframePreviewAdapter(
iframe: HTMLIFrameElement,
dispatch?: (op: EditOp) => void,
): PreviewAdapter {
return new IframePreviewAdapter(iframe, dispatch);
}