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:
Vance Ingalls
2026-07-09 11:52:13 -07:00
parent bcda11aa7c
commit 91ddf9e196
4 changed files with 158 additions and 2 deletions
+5
View File
@@ -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");
});
});
+30 -1
View File
@@ -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(
+12 -1
View File
@@ -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;
}