mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(sdk): ws-a1 — iframe preview adapter (hit-test + selection) (#1489)
* feat(studio): stage 7 step 3c — sdk cutover for inline-style ops Introduces sdkCutoverPersist(): when STUDIO_SDK_CUTOVER_ENABLED is set, inline-style PatchOps are routed through the SDK session's in-memory document model instead of the server patch-element API. The SDK serialize() result is written back through the same writeProjectFile + editHistory.recordEdit path, so the on-disk output is identical to the legacy route. - packages/studio/src/utils/sdkCutover.ts (new): sdkCutoverPersist() + shouldUseSdkCutover() guard; domEditSaveTimestampRef.current is stamped on each write to suppress the echo file-change reload. - packages/studio/src/components/editor/manualEditingAvailability.ts: adds STUDIO_SDK_CUTOVER_ENABLED flag (default false); changes STUDIO_SDK_SHADOW_ENABLED default to false now that cutover is available. - packages/studio/src/hooks/useSdkSession.ts: adds optional domEditSaveTimestampRef param; self-write suppress window (SELF_WRITE_SUPPRESS_MS) gates file-change reloads so SDK writes don't echo back as external edits. - packages/studio/src/App.tsx: passes domEditSaveTimestampRef to useSdkSession so the suppress window can gate reloads triggered by SDK cutover writes. - Test coverage: sdkCutover.test.ts (new, 141 lines) + useDomEditSession.test.ts (new, 50 lines) — guard function + happy-path assertions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(studio): force-reload sdk session after undo/redo bypasses suppress window writeHistoryFile arms the 2 s self-write suppress window, so the file-change event for an undo/redo write is swallowed and the SDK in-memory doc stays on pre-undo content. Expose forceReload() from useSdkSession (s7.4) and call it in useAppHotkeys after a successful undo/redo that touched the active composition path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (dark launch) Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts + sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow* call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts. KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false, enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover stays flag-gated. The stack can merge with zero behavior change; cutover is validated by flipping the flag, not by removing it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(studio): wire onTrySdkPersist to sdkCutoverPersist (cutover was unwired) Stage 7 s7.5 removed the feature flag and declared cutover 'always-on', but onTrySdkPersist was never actually passed to useDomEditCommits — the sdkCutoverPersist function was dead code in production. Thread sdkSession through useDomEditSession params, build the onTrySdkPersist closure there (all CutoverDeps are already in scope), and pass sdkSession from App.tsx. Style/text/attribute/html-attribute commits now route through SDK dispatch instead of the server patch path. Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): route element delete through SDK removeElement (§3.1) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): route timeline trim/move through SDK setTiming (§3.2) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * chore(studio): document CSS-path position cut-over, GSAP-path intentionally deferred (§3.3) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): route GSAP tween add/update/delete through SDK (§3.5 PR1) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(studio): route GSAP keyframe add through SDK (§3.5 PR2) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(studio,core): resolve SDK-cutover review findings Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * feat(sdk): ws-a1 — iframe preview adapter (hit-test + selection) Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
Claude Sonnet 4.6
parent
377b0368bd
commit
b96e8a3072
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Unit tests for resolveNearestHfElement (pure resolver — 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).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { resolveNearestHfElement } from "./iframe.js";
|
||||
import type { ElementAtPointResult } from "./types.js";
|
||||
|
||||
// ─── Minimal fake element ────────────────────────────────────────────────────
|
||||
|
||||
interface FakeEl {
|
||||
attrs: Record<string, string>;
|
||||
tagName: string;
|
||||
parentElement: FakeEl | null;
|
||||
getAttribute(name: string): string | null;
|
||||
hasAttribute(name: string): boolean;
|
||||
}
|
||||
|
||||
function fakeEl(
|
||||
attrs: Record<string, string>,
|
||||
tagName: string,
|
||||
parent: FakeEl | null = null,
|
||||
): FakeEl {
|
||||
return {
|
||||
attrs,
|
||||
tagName,
|
||||
parentElement: parent,
|
||||
getAttribute(name) {
|
||||
return Object.prototype.hasOwnProperty.call(this.attrs, name) ? this.attrs[name] : null;
|
||||
},
|
||||
hasAttribute(name) {
|
||||
return Object.prototype.hasOwnProperty.call(this.attrs, name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const visible = () => true;
|
||||
const invisible = () => false;
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveNearestHfElement", () => {
|
||||
it("returns null for a null input", () => {
|
||||
expect(resolveNearestHfElement(null, visible)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the element itself when it carries data-hf-id", () => {
|
||||
const el = fakeEl({ "data-hf-id": "hf-abc" }, "div");
|
||||
const result = resolveNearestHfElement(el as unknown as Element, visible);
|
||||
expect(result).toEqual<ElementAtPointResult>({ id: "hf-abc", tag: "div" });
|
||||
});
|
||||
|
||||
it("walks up to a parent that carries data-hf-id", () => {
|
||||
const parent = fakeEl({ "data-hf-id": "hf-parent" }, "section");
|
||||
const child = fakeEl({}, "span", parent);
|
||||
const result = resolveNearestHfElement(child as unknown as Element, visible);
|
||||
expect(result).toEqual<ElementAtPointResult>({ id: "hf-parent", tag: "section" });
|
||||
});
|
||||
|
||||
it("returns null when the nearest data-hf-id node is data-hf-root", () => {
|
||||
const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div");
|
||||
const child = fakeEl({}, "p", root);
|
||||
expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the element itself is data-hf-root", () => {
|
||||
const root = fakeEl({ "data-hf-id": "hf-stage", "data-hf-root": "" }, "div");
|
||||
expect(resolveNearestHfElement(root as unknown as Element, visible)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when isVisible returns false for the matching element", () => {
|
||||
const el = fakeEl({ "data-hf-id": "hf-abc" }, "div");
|
||||
expect(resolveNearestHfElement(el as unknown as Element, invisible)).toBeNull();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("returns null when no data-hf-id found in any ancestor", () => {
|
||||
const grandparent = fakeEl({}, "body");
|
||||
const parent = fakeEl({}, "div", grandparent);
|
||||
const child = fakeEl({}, "span", parent);
|
||||
expect(resolveNearestHfElement(child as unknown as Element, visible)).toBeNull();
|
||||
});
|
||||
|
||||
it("tag is lowercased", () => {
|
||||
const el = fakeEl({ "data-hf-id": "hf-xyz" }, "DIV");
|
||||
const result = resolveNearestHfElement(el as unknown as Element, visible);
|
||||
expect(result?.tag).toBe("div");
|
||||
});
|
||||
|
||||
it("stops at the nearest ancestor — does not continue past first data-hf-id", () => {
|
||||
const outer = fakeEl({ "data-hf-id": "hf-outer" }, "section");
|
||||
const inner = fakeEl({ "data-hf-id": "hf-inner" }, "div", outer);
|
||||
const child = fakeEl({}, "span", inner);
|
||||
const result = resolveNearestHfElement(child as unknown as Element, visible);
|
||||
expect(result?.id).toBe("hf-inner");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── select + on('selection') wiring ─────────────────────────────────────────
|
||||
// These cover the adapter-level selection state without needing a real iframe.
|
||||
// We import createIframePreviewAdapter and pass a stub iframe.
|
||||
|
||||
import { createIframePreviewAdapter } from "./iframe.js";
|
||||
|
||||
function stubIframe() {
|
||||
return {} as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
describe("IframePreviewAdapter selection", () => {
|
||||
it("on('selection') fires when select() is called", () => {
|
||||
const adapter = createIframePreviewAdapter(stubIframe());
|
||||
const cb = vi.fn();
|
||||
adapter.on("selection", cb);
|
||||
adapter.select(["hf-abc"]);
|
||||
expect(cb).toHaveBeenCalledWith(["hf-abc"]);
|
||||
});
|
||||
|
||||
it("off unsubscribes the handler", () => {
|
||||
const adapter = createIframePreviewAdapter(stubIframe());
|
||||
const cb = vi.fn();
|
||||
const off = adapter.on("selection", cb);
|
||||
off();
|
||||
adapter.select(["hf-abc"]);
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("additive select merges with prior selection", () => {
|
||||
const adapter = createIframePreviewAdapter(stubIframe());
|
||||
const cb = vi.fn();
|
||||
adapter.on("selection", cb);
|
||||
adapter.select(["hf-a"]);
|
||||
adapter.select(["hf-b"], { additive: true });
|
||||
expect(cb).toHaveBeenLastCalledWith(expect.arrayContaining(["hf-a", "hf-b"]));
|
||||
});
|
||||
|
||||
it("non-additive select replaces prior selection", () => {
|
||||
const adapter = createIframePreviewAdapter(stubIframe());
|
||||
const cb = vi.fn();
|
||||
adapter.on("selection", cb);
|
||||
adapter.select(["hf-a"]);
|
||||
adapter.select(["hf-b"]);
|
||||
expect(cb).toHaveBeenLastCalledWith(["hf-b"]);
|
||||
});
|
||||
|
||||
it("multiple handlers all fire", () => {
|
||||
const adapter = createIframePreviewAdapter(stubIframe());
|
||||
const cb1 = vi.fn();
|
||||
const cb2 = vi.fn();
|
||||
adapter.on("selection", cb1);
|
||||
adapter.on("selection", cb2);
|
||||
adapter.select(["hf-abc"]);
|
||||
expect(cb1).toHaveBeenCalledOnce();
|
||||
expect(cb2).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Same-origin iframe PreviewAdapter — WS-A1 (hit-test + selection).
|
||||
*
|
||||
* 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";
|
||||
|
||||
// ─── Pure resolver (testable without a browser) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk from `el` upward through parentElement, looking for the nearest node
|
||||
* that carries `[data-hf-id]` and is NOT `[data-hf-root]`.
|
||||
*
|
||||
* Returns null when:
|
||||
* - The walk exits the tree without finding `[data-hf-id]`
|
||||
* - The matching node is `[data-hf-root]` (transparent to hit-testing)
|
||||
* - `isVisible(node)` returns false for the matching node
|
||||
*
|
||||
* Keeping this a pure function (no elementFromPoint, no window access) makes
|
||||
* it unit-testable in a plain Node environment.
|
||||
*/
|
||||
export function resolveNearestHfElement(
|
||||
el: Element | null,
|
||||
isVisible: (el: Element) => boolean,
|
||||
): ElementAtPointResult | null {
|
||||
let node = el;
|
||||
while (node !== null) {
|
||||
const id = node.getAttribute("data-hf-id");
|
||||
if (id !== null) {
|
||||
if (node.hasAttribute("data-hf-root")) return null;
|
||||
if (!isVisible(node)) return null;
|
||||
return { id, tag: node.tagName.toLowerCase() };
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Visibility check ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true when no element in the ancestor chain (inclusive) has
|
||||
* computed opacity === 0. Checks ancestors because a parent at opacity:0
|
||||
* makes the child invisible even if the child's own opacity is 1.
|
||||
*
|
||||
* This reflects the current GSAP timeline state (whatever the player has
|
||||
* seeked to). For atTime values matching the live playhead this is always
|
||||
* accurate. For speculative times this is NOT seeked — WS-A1 does not mutate
|
||||
* the timeline; accurate out-of-band opacity queries are WS-G follow-on.
|
||||
*/
|
||||
function isOpacityVisible(el: Element, win: Window & typeof globalThis): boolean {
|
||||
let node: Element | null = el;
|
||||
while (node !== null) {
|
||||
const style = win.getComputedStyle(node);
|
||||
if (parseFloat(style.opacity) === 0) return false;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── IframePreviewAdapter ─────────────────────────────────────────────────────
|
||||
|
||||
type SelectionHandler = (ids: string[]) => void;
|
||||
|
||||
class IframePreviewAdapter implements PreviewAdapter {
|
||||
private readonly iframe: HTMLIFrameElement;
|
||||
private _selection: string[] = [];
|
||||
private _handlers: SelectionHandler[] = [];
|
||||
|
||||
constructor(iframe: HTMLIFrameElement) {
|
||||
this.iframe = iframe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous hit-test. Returns the nearest `[data-hf-id]` element under
|
||||
* (x, y) in the iframe's coordinate space, or null for a transparent hit
|
||||
* (root, opacity-0, or nothing at all).
|
||||
*
|
||||
* atTime: reflects the GSAP state at the playhead when this is called.
|
||||
* Seeking to a different time to check visibility is WS-G scope.
|
||||
*/
|
||||
elementAtPoint(x: number, y: number, _opts?: { atTime?: number }): ElementAtPointResult | null {
|
||||
const doc = this.iframe.contentDocument;
|
||||
if (!doc) return null;
|
||||
const win = this.iframe.contentWindow as (Window & typeof globalThis) | null;
|
||||
if (!win) return null;
|
||||
|
||||
const hit = doc.elementFromPoint(x, y);
|
||||
return resolveNearestHfElement(hit, (el) => isOpacityVisible(el, win));
|
||||
}
|
||||
|
||||
// WS-A2 stubs — commitPreview / applyDraft derive the moveElement op --------
|
||||
|
||||
applyDraft(_id: string, _props: DraftProps): void {}
|
||||
|
||||
commitPreview(): void {}
|
||||
|
||||
cancelPreview(): void {}
|
||||
|
||||
// Selection -----------------------------------------------------------------
|
||||
|
||||
select(ids: string[], opts?: { additive?: boolean }): void {
|
||||
if (opts?.additive) {
|
||||
const merged = new Set([...this._selection, ...ids]);
|
||||
this._selection = [...merged];
|
||||
} else {
|
||||
this._selection = [...ids];
|
||||
}
|
||||
this._emit();
|
||||
}
|
||||
|
||||
on(event: "selection", handler: SelectionHandler): () => void {
|
||||
if (event !== "selection") return () => {};
|
||||
this._handlers.push(handler);
|
||||
return () => {
|
||||
this._handlers = this._handlers.filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
private _emit(): void {
|
||||
const ids = [...this._selection];
|
||||
for (const h of this._handlers) h(ids);
|
||||
}
|
||||
}
|
||||
|
||||
export function createIframePreviewAdapter(iframe: HTMLIFrameElement): PreviewAdapter {
|
||||
return new IframePreviewAdapter(iframe);
|
||||
}
|
||||
@@ -39,3 +39,4 @@ export { createMemoryAdapter } from "./adapters/memory.js";
|
||||
export { createHeadlessAdapter } from "./adapters/headless.js";
|
||||
export { createHttpAdapter } from "./adapters/http.js";
|
||||
export type { HttpAdapterOptions } from "./adapters/http.js";
|
||||
export { createIframePreviewAdapter, resolveNearestHfElement } from "./adapters/iframe.js";
|
||||
|
||||
Reference in New Issue
Block a user