mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk): attachSync mirrors composition edits onto a live document
Adds attachSync(comp) to PreviewAdapter/IframePreviewAdapter — does an
immediate full sync via the existing applyOverrideSet, then subscribes to
comp.on('patch', ...) and replays every future patch (forward or inverse —
undo/redo included) via the existing applyPatchesToDocument, pointed at the
iframe's live document instead of the offscreen linkedom one. No new
mutation logic; both functions already work against any
{document, wrapped, stamped}-shaped object.
Also adds a no-op attachSync stub to HeadlessPreviewAdapter, required to
keep it satisfying the widened PreviewAdapter interface.
Closes the gap that made pacific's canvas-react hand-roll its own
override-application code (applyOverrideToIframe.ts) with two separate
mechanisms (diffing for normal edits, verbatim op-replay for undo/redo) —
subscribing to the patch stream directly needs only one.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
|
||||
import type { Composition } from "../types.js";
|
||||
|
||||
/** Null PreviewAdapter for headless use (agents, CI, server-side rendering). */
|
||||
class HeadlessPreviewAdapter implements PreviewAdapter {
|
||||
@@ -17,6 +18,10 @@ class HeadlessPreviewAdapter implements PreviewAdapter {
|
||||
on(_event: "selection", _handler: (ids: string[]) => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
attachSync(_comp: Composition): () => void {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export function createHeadlessAdapter(): PreviewAdapter {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment happy-dom
|
||||
/**
|
||||
* attachSync mirrors every SDK edit (including undo/redo) onto a real live
|
||||
* document — this file needs happy-dom (the package's default vitest
|
||||
* environment is "node") because it exercises iframe.contentDocument.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createIframePreviewAdapter } from "./iframe.js";
|
||||
import { openComposition } from "../session.js";
|
||||
|
||||
const BASE_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
|
||||
<h1 data-hf-id="hf-title" style="color: #fff; font-size: 64px">Hello World</h1>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
/** A same-origin iframe seeded with the given HTML, ready for contentDocument access. */
|
||||
function mountIframe(html: string): HTMLIFrameElement {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.appendChild(iframe);
|
||||
iframe.contentDocument!.open();
|
||||
iframe.contentDocument!.write(html);
|
||||
iframe.contentDocument!.close();
|
||||
return iframe;
|
||||
}
|
||||
|
||||
describe("IframePreviewAdapter.attachSync", () => {
|
||||
it("mirrors comp.getOverrides() onto the iframe immediately on attach", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
comp.setStyle("hf-title", { color: "#f00" }); // edit BEFORE attaching
|
||||
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
|
||||
const liveTitle = iframe.contentDocument!.querySelector(
|
||||
'[data-hf-id="hf-title"]',
|
||||
) as HTMLElement;
|
||||
expect(liveTitle.style.getPropertyValue("color")).toBe("#f00");
|
||||
});
|
||||
|
||||
it("mirrors a style edit dispatched AFTER attaching", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
|
||||
comp.setStyle("hf-title", { fontSize: "96px" });
|
||||
|
||||
const liveTitle = iframe.contentDocument!.querySelector(
|
||||
'[data-hf-id="hf-title"]',
|
||||
) as HTMLElement;
|
||||
expect(liveTitle.style.getPropertyValue("font-size")).toBe("96px");
|
||||
});
|
||||
|
||||
it("mirrors setText, setAttribute, and removeElement", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
const liveDoc = iframe.contentDocument!;
|
||||
|
||||
comp.setText("hf-title", "Goodbye");
|
||||
expect(liveDoc.querySelector('[data-hf-id="hf-title"]')?.textContent).toContain("Goodbye");
|
||||
|
||||
comp.setAttribute("hf-title", "data-test", "1");
|
||||
expect(liveDoc.querySelector('[data-hf-id="hf-title"]')?.getAttribute("data-test")).toBe("1");
|
||||
|
||||
comp.removeElement("hf-title");
|
||||
expect(liveDoc.querySelector('[data-hf-id="hf-title"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("mirrors undo — restores the live DOM to the pre-edit state", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
const liveDoc = iframe.contentDocument!;
|
||||
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
expect(
|
||||
(liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
|
||||
"color",
|
||||
),
|
||||
).toBe("#f00");
|
||||
|
||||
comp.undo();
|
||||
expect(
|
||||
(liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
|
||||
"color",
|
||||
),
|
||||
).toBe("#fff");
|
||||
});
|
||||
|
||||
it("mirrors redo after undo", async () => {
|
||||
const iframe = mountIframe(BASE_HTML);
|
||||
const comp = await openComposition(BASE_HTML);
|
||||
const adapter = createIframePreviewAdapter(iframe);
|
||||
adapter.attachSync(comp);
|
||||
const liveDoc = iframe.contentDocument!;
|
||||
|
||||
comp.setStyle("hf-title", { color: "#f00" });
|
||||
comp.undo();
|
||||
comp.redo();
|
||||
expect(
|
||||
(liveDoc.querySelector('[data-hf-id="hf-title"]') as HTMLElement).style.getPropertyValue(
|
||||
"color",
|
||||
),
|
||||
).toBe("#f00");
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,8 @@ import {
|
||||
readCurrentTranslate,
|
||||
} from "@hyperframes/core/runtime/position-edits";
|
||||
import type { PreviewAdapter, ElementAtPointResult, DraftProps } from "./types.js";
|
||||
import type { EditOp } from "../types.js";
|
||||
import type { EditOp, Composition } from "../types.js";
|
||||
import { applyPatchesToDocument, applyOverrideSet } from "../engine/apply-patches.js";
|
||||
|
||||
// ─── Pure resolver (testable without a browser) ───────────────────────────────
|
||||
|
||||
@@ -511,6 +512,9 @@ class IframePreviewAdapter implements PreviewAdapter {
|
||||
*/
|
||||
private _draftPrevInlineTranslate: string | null = null;
|
||||
|
||||
/** Unsubscribe for the current attachSync subscription, if any. */
|
||||
private _syncDetach: (() => void) | null = null;
|
||||
|
||||
constructor(iframe: HTMLIFrameElement, dispatch?: (op: EditOp) => void) {
|
||||
this.iframe = iframe;
|
||||
this._dispatch = dispatch;
|
||||
@@ -740,6 +744,31 @@ class IframePreviewAdapter implements PreviewAdapter {
|
||||
const ids = [...this._selection];
|
||||
for (const h of this._handlers) h(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror `comp`'s edits onto this.iframe.contentDocument. See the
|
||||
* PreviewAdapter interface doc for the full contract.
|
||||
*/
|
||||
attachSync(comp: Composition): () => void {
|
||||
this._syncDetach?.();
|
||||
|
||||
const doc = this.iframe.contentDocument;
|
||||
if (doc) {
|
||||
applyOverrideSet({ document: doc, wrapped: false, stamped: "" }, comp.getOverrides());
|
||||
}
|
||||
|
||||
const unsubscribe = comp.on("patch", ({ patches }) => {
|
||||
const liveDoc = this.iframe.contentDocument;
|
||||
if (!liveDoc) return;
|
||||
applyPatchesToDocument(
|
||||
{ document: liveDoc, wrapped: false, stamped: "" },
|
||||
patches.filter((p) => p.path !== "/script/gsap"),
|
||||
);
|
||||
});
|
||||
|
||||
this._syncDetach = unsubscribe;
|
||||
return unsubscribe;
|
||||
}
|
||||
}
|
||||
|
||||
export function createIframePreviewAdapter(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PersistErrorEvent } from "../types.js";
|
||||
import type { PersistErrorEvent, Composition } from "../types.js";
|
||||
|
||||
// ─── PersistAdapter ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -73,4 +73,15 @@ export interface PreviewAdapter {
|
||||
// Stage 8 prep: fired when the preview host changes selection (e.g. user clicks an element).
|
||||
// Not wired up in stage 7 — callers listen to the session's own selectionchange event instead.
|
||||
on(event: "selection", handler: (ids: string[]) => void): () => void;
|
||||
|
||||
/**
|
||||
* Mirror this composition's edits onto the adapter's own live document —
|
||||
* an immediate full sync of the composition's CURRENT overrides, then a
|
||||
* subscription that replays every future patch (including undo/redo).
|
||||
* `/script/gsap` patches are never mirrored (re-executing a live <script>
|
||||
* tag doesn't work and would conflict with running GSAP state).
|
||||
* Calling this again while already attached detaches the previous
|
||||
* subscription first. Returns an unsubscribe.
|
||||
*/
|
||||
attachSync(comp: Composition): () => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user