diff --git a/packages/sdk/src/adapters/headless.ts b/packages/sdk/src/adapters/headless.ts index ed1f09692..300c15f33 100644 --- a/packages/sdk/src/adapters/headless.ts +++ b/packages/sdk/src/adapters/headless.ts @@ -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 { diff --git a/packages/sdk/src/adapters/iframe.sync.test.ts b/packages/sdk/src/adapters/iframe.sync.test.ts new file mode 100644 index 000000000..5d577aac8 --- /dev/null +++ b/packages/sdk/src/adapters/iframe.sync.test.ts @@ -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 = ` +
+

Hello World

+
+`.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"); + }); +}); diff --git a/packages/sdk/src/adapters/iframe.ts b/packages/sdk/src/adapters/iframe.ts index 9143e1746..f5c1595f2 100644 --- a/packages/sdk/src/adapters/iframe.ts +++ b/packages/sdk/src/adapters/iframe.ts @@ -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( diff --git a/packages/sdk/src/adapters/types.ts b/packages/sdk/src/adapters/types.ts index 07ece0683..2159ffdf7 100644 --- a/packages/sdk/src/adapters/types.ts +++ b/packages/sdk/src/adapters/types.ts @@ -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