mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk): ws-a2 — applyDraft/commitPreview/cancelPreview → moveElement op (#1490)
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
parent
b96e8a3072
commit
53717a77f4
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user