diff --git a/docs/sdk/guides/embedded-override-mode.mdx b/docs/sdk/guides/embedded-override-mode.mdx index def6fdee25..f120c01e34 100644 --- a/docs/sdk/guides/embedded-override-mode.mdx +++ b/docs/sdk/guides/embedded-override-mode.mdx @@ -9,7 +9,7 @@ This is exactly how a canvas embedder (for example, AI Studio) persists in-compo ## Opening with overrides -Pass the stored delta as `overrides` when you call `openComposition`. The SDK replays the override set onto the base template in one pass, so the session exposes the user's exact edited state immediately: +Pass the stored delta as `overrides` when you call `openComposition`. The SDK applies the saved changes to the base template, so the session exposes the user's exact edited state immediately: ```typescript import { openComposition } from "@hyperframes/sdk"; @@ -31,7 +31,7 @@ After `openComposition` returns, `comp.getElements()` reflects the overridden st ## Reading the current delta -Call `getOverrides()` to retrieve the current delta for storage. It returns a shallow copy of the internal override set — safe to serialize and stash: +Call `getOverrides()` to retrieve the current delta for storage. It returns a copy ready to serialize and save: ```typescript comp.setText("hf-cta", "Get started free"); @@ -45,6 +45,26 @@ await db.saveOverrides(sessionId, nextOverrides); On the next session open, pass `nextOverrides` as `overrides` again — the user sees their exact state. +Elements inserted with `addElement()` are stored in the delta too, including their +parent and sibling position. Later text, style, or attribute edits replay onto the +restored element: + +```typescript +const id = comp.addElement("hf-stage", 1, "

Available today

"); +comp.setText(id, "Available worldwide"); + +const reopened = await openComposition(templateHtml, { + overrides: comp.getOverrides(), +}); +reopened.getElement(id)?.text; // "Available worldwide" +``` + +`getOverrides()` saves each added or restored element's current position among +all child nodes, including surrounding text. Removing a temporary sibling does +not shift the element when reopened. +Store the returned set without sorting its keys. The SDK keeps the latest edit +order so later child edits apply after their parent's snapshot. + ## Override set key format Keys follow the pattern `hfId.prop.path`. The common forms are: @@ -54,6 +74,7 @@ Keys follow the pattern `hfId.prop.path`. The common forms are: | `"hf-title.text"` | Inner text of element `hf-title` | | `"hf-logo.attr.src"` | `src` attribute on `hf-logo` | | `"hf-x.style.fontSize"` | Inline `fontSize` style on `hf-x` | +| `"hf-card"` with an element snapshot | Added or restored element, with its HTML, parent, and sibling position | | `"hf-card"` with value `null` | Removal marker — element was deleted | A `null` value is a removal marker. When the SDK serializes the composition it omits that element entirely. This lets the host distinguish "never touched" (key absent) from "user deleted" (key present, value `null`). diff --git a/packages/sdk/src/engine/apply-patches.ts b/packages/sdk/src/engine/apply-patches.ts index c8f40b2c38..bc1a8003fd 100644 --- a/packages/sdk/src/engine/apply-patches.ts +++ b/packages/sdk/src/engine/apply-patches.ts @@ -160,6 +160,33 @@ export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet) } } applyPatchesToDocument(parsed, patches, { keepScriptTargets: true }); + restoreOverridePositions(parsed, overrides); +} + +export function isElementSnapshotOverride( + key: string, + value: OverrideSet[string], +): value is Record & { siblingIndex: number } { + if (value === null || typeof value !== "object") return false; + return /^\/elements\/[^/]+$/.test(keyToPath(key) ?? "") && typeof value.siblingIndex === "number"; +} + +function restoreOverridePositions(parsed: ParsedDocument, overrides: OverrideSet): void { + const positions: Array<{ el: Element; parent: Element; childNodeIndex: number }> = []; + for (const [key, value] of Object.entries(overrides)) { + if (!isElementSnapshotOverride(key, value) || typeof value.childNodeIndex !== "number") + continue; + const el = findById(parsed.document, key); + if (!el?.parentElement) continue; + positions.push({ el, parent: el.parentElement, childNodeIndex: value.childNodeIndex }); + } + // Detach every restored sibling so inserting one cannot shift another's saved position. + for (const { el } of positions) el.remove(); + for (const { el, parent, childNodeIndex } of positions.sort( + (a, b) => a.childNodeIndex - b.childNodeIndex, + )) { + parent.insertBefore(el, parent.childNodes[childNodeIndex] ?? null); + } } /** Keep a deleted node a script looks up: serialize writes it back hidden, so the script finds it. */ @@ -271,7 +298,7 @@ function applyOne( if (el && options.keepScriptTargets && scriptLooksUp(parsed.document, el)) keepDelete(parsed, el); el?.remove(); - } else if (patch.op === "add" && patch.value) { + } else if ((patch.op === "add" || patch.op === "replace") && patch.value) { const v = patch.value as { html: string; parentId: string | null; siblingIndex: number }; const parent = v.parentId ? findById(parsed.document, v.parentId) @@ -282,6 +309,7 @@ function applyOne( tmp.innerHTML = v.html; const node = tmp.firstElementChild; if (!node) return; + if (patch.op === "replace") findById(parsed.document, p.id)?.remove(); const children = Array.from(parent.children); const ref = children[v.siblingIndex] ?? null; parent.insertBefore(node, ref); diff --git a/packages/sdk/src/session.test.ts b/packages/sdk/src/session.test.ts index e2d8f1b8b2..b611c67625 100644 --- a/packages/sdk/src/session.test.ts +++ b/packages/sdk/src/session.test.ts @@ -196,6 +196,221 @@ describe("override-set replay on open", () => { const comp = await openComposition(BASE_HTML, { overrides }); expect(comp.getOverrides()).toEqual(overrides); }); + + it("restores added elements and their later edits from the stored override set", async () => { + const comp = await openComposition(BASE_HTML, { overrides: {} }); + const id = comp.addElement("hf-stage", 1, "

Available today

"); + comp.setText(id, "Available worldwide"); + + const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() }); + + expect(reopened.getElement(id)?.text).toBe("Available worldwide"); + expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([ + "hf-title", + id, + "hf-sub", + "hf-logo", + ]); + expect(reopened.serialize()).toBe(comp.serialize()); + }); + + it("replaces the base element once when reopening an undone removal", async () => { + const comp = await openComposition(BASE_HTML); + comp.setText("hf-title", "Edited headline"); + comp.removeElement("hf-title"); + comp.undo(); + + const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() }); + + expect(reopened.getElement("hf-title")?.text).toBe("Edited headline"); + expect(reopened.getElements().filter((element) => element.id === "hf-title")).toHaveLength(1); + expect(reopened.serialize()).toBe(comp.serialize()); + }); + + it("keeps a restored base element after siblings removed before its latest snapshot", async () => { + const comp = await openComposition(BASE_HTML); + comp.removeElement("hf-logo"); + comp.undo(); + comp.removeElement("hf-title"); + comp.removeElement("hf-logo"); + comp.undo(); + + const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() }); + + expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([ + "hf-sub", + "hf-logo", + ]); + expect(reopened.serialize()).toBe(comp.serialize()); + }); + + it("keeps an added element's position after removing a sibling and undoing its removal", async () => { + const comp = await openComposition(BASE_HTML); + const id = comp.addElement("hf-stage", 2, "

Available today

"); + comp.removeElement("hf-title"); + comp.removeElement(id); + comp.undo(); + + const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() }); + + expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([ + "hf-sub", + id, + "hf-logo", + ]); + expect(reopened.serialize()).toBe(comp.serialize()); + }); + + it("keeps child edits made after restoring an added parent", async () => { + const comp = await openComposition(BASE_HTML); + const parent = comp.addElement("hf-stage", 1, "
Details
"); + const child = comp.addElement(parent, 0, "

Original

"); + comp.setText(child, "Before removal"); + comp.removeElement(parent); + comp.undo(); + comp.setText(child, "After restore"); + + const reopened = await openComposition(BASE_HTML, { overrides: comp.getOverrides() }); + + expect(reopened.getElement(child)?.text).toBe("After restore"); + expect(reopened.getElements().filter((element) => element.id === child)).toHaveLength(1); + expect(reopened.serialize()).toBe(comp.serialize()); + }); + + it.each(["session", "host"] as const)( + "keeps later %s edits to a child after restoring its base parent", + async (origin) => { + const html = + '

In

'; + const comp = await openComposition(html, { coalesceMs: 0 }); + comp.setText("hf-in", "Before removal"); + comp.removeElement("hf-box"); + comp.undo(); + if (origin === "session") comp.setText("hf-in", "After restore"); + else + comp.applyPatches([ + { op: "replace", path: "/elements/hf-in/text", value: "After restore" }, + ]); + + const reopened = await openComposition(html, { + overrides: JSON.parse(JSON.stringify(comp.getOverrides())), + }); + + expect(reopened.getElement("hf-in")?.text).toBe("After restore"); + expect(reopened.serialize()).toBe(comp.serialize()); + }, + ); + + it.each(["base", "added"] as const)( + "keeps a restored %s element's position after removing a temporary sibling", + async (kind) => { + const comp = await openComposition(BASE_HTML, { coalesceMs: 0 }); + const id = kind === "base" ? "hf-sub" : comp.addElement("hf-stage", 1, "

Keep

"); + const temporary = comp.addElement("hf-stage", 1, "

Temporary

"); + comp.removeElement(id); + comp.undo(); + comp.removeElement(temporary); + + const reopened = await openComposition(BASE_HTML, { + overrides: JSON.parse(JSON.stringify(comp.getOverrides())), + }); + + expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual( + kind === "base" ? ["hf-title", "hf-sub", "hf-logo"] : ["hf-title", id, "hf-sub", "hf-logo"], + ); + expect(reopened.serialize()).toBe(comp.serialize()); + }, + ); + + it("positions an added element after replaying later sibling removals", async () => { + const comp = await openComposition(BASE_HTML); + const id = comp.addElement("hf-stage", 2, "

Keep

"); + comp.removeElement("hf-title"); + + const reopened = await openComposition(BASE_HTML, { + overrides: JSON.parse(JSON.stringify(comp.getOverrides())), + }); + + expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([ + "hf-sub", + id, + "hf-logo", + ]); + expect(reopened.serialize()).toBe(comp.serialize()); + }); + + it("restores several saved positions without shifting a previously restored sibling", async () => { + const ids = ["hf-c", "hf-b", "hf-first", "hf-last", "hf-a"]; + const html = `
${ids.map((id) => `

${id}

`).join("")}
`; + const comp = await openComposition(html); + comp.applyPatches([ + ...["hf-c", "hf-b", "hf-a"].map((id) => ({ op: "remove", path: `/elements/${id}` })), + ...["hf-a", "hf-b", "hf-c"].map((id, index) => ({ + op: "add", + path: `/elements/${id}`, + value: { + html: `

${id}

`, + parentId: "hf-stage", + siblingIndex: index + 1, + }, + })), + ]); + + const reopened = await openComposition(html, { + overrides: JSON.parse(JSON.stringify(comp.getOverrides())), + }); + + expect(reopened.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([ + "hf-first", + "hf-a", + "hf-b", + "hf-c", + "hf-last", + ]); + expect(reopened.serialize()).toBe(comp.serialize()); + }); + + it.each([false, true])( + "keeps text around restored inline elements, with temporary sibling: %s", + async (withTemporary) => { + const html = + '

Start A middle B end C.

'; + const comp = await openComposition(html, { coalesceMs: 0 }); + const temporary = withTemporary ? comp.addElement("hf-stage", 0, "Temporary") : null; + comp.removeElement("hf-a"); + comp.undo(); + comp.removeElement("hf-b"); + comp.undo(); + if (temporary) comp.removeElement(temporary); + + const reopened = await openComposition(html, { + overrides: JSON.parse(JSON.stringify(comp.getOverrides())), + }); + + expect(reopened.serialize()).toBe(comp.serialize()); + }, + ); + + it("replays element snapshots with only an element sibling index", async () => { + const comp = await openComposition(BASE_HTML, { + overrides: { + "hf-added": { + html: '

Original

', + parentId: "hf-stage", + siblingIndex: 1, + }, + "hf-added.text": "Updated", + }, + }); + + expect(comp.getElement("hf-stage")?.children.map((child) => child.id)).toEqual([ + "hf-title", + "hf-added", + "hf-sub", + "hf-logo", + ]); + expect(comp.getElement("hf-added")?.text).toBe("Updated"); + }); }); // ─── batch() transactional rollback ─────────────────────────────────────────── diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index 273a86c34a..20a92b0729 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -36,7 +36,12 @@ import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js"; import { parseMutable } from "./engine/model.js"; import type { ParsedDocument } from "./engine/model.js"; import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js"; -import { getGsapScripts, resolveScoped, declarationCarriers } from "./engine/model.js"; +import { + getGsapScripts, + resolveScoped, + declarationCarriers, + getSiblingIndex, +} from "./engine/model.js"; import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn"; import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document"; import { readClipTiming, type ClipTiming } from "@hyperframes/core/composition-contract"; @@ -48,7 +53,11 @@ import { import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables"; import { readVariableDeclarations } from "./engine/variableModel.js"; import { serializeDocument } from "./engine/serialize.js"; -import { applyPatchesToDocument, applyOverrideSet } from "./engine/apply-patches.js"; +import { + applyPatchesToDocument, + applyOverrideSet, + isElementSnapshotOverride, +} from "./engine/apply-patches.js"; import { buildPatchEvent, pathToKey } from "./engine/patches.js"; import { createHistory } from "./history.js"; import type { HistoryModule } from "./history.js"; @@ -646,6 +655,7 @@ class CompositionImpl implements Composition { for (const p of forward) { const key = pathToKey(p.path); if (key !== null) { + delete this.overrides[key]; this.overrides[key] = p.op === "remove" ? null @@ -822,7 +832,19 @@ class CompositionImpl implements Composition { // ── T3 embedded-mode extras ────────────────────────────────────────────────── getOverrides(): OverrideSet { - return { ...this.overrides }; + const overrides = { ...this.overrides }; + for (const [key, value] of Object.entries(overrides)) { + if (!isElementSnapshotOverride(key, value)) continue; + const el = resolveScoped(this.parsed.document, key); + if (el?.parentElement) { + overrides[key] = { + ...value, + siblingIndex: getSiblingIndex(el), + childNodeIndex: Array.from(el.parentElement.childNodes).indexOf(el), + }; + } + } + return overrides; } // fallow-ignore-next-line complexity @@ -841,6 +863,7 @@ class CompositionImpl implements Composition { for (const p of patches) { const key = pathToKey(p.path); if (key !== null) { + delete this.overrides[key]; this.overrides[key] = p.op === "remove" ? null @@ -906,8 +929,6 @@ export async function openComposition( // serves them for the rest of the session (undo-to-base restores). const baseVariableDefaults = declaredDefaults(parsed); - // T3 embedded: replay the stored override-set onto the base in one pass, - // so the session exposes the user's exact edited state — not the template. if (opts?.overrides) applyOverrideSet(parsed, opts.overrides); const session = new CompositionImpl(parsed, opts ?? {}, baseVariableDefaults);