Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/sdk/src/engine/mutate.gsap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,61 @@ describe("removeLabel", () => {
expect(result.forward).toHaveLength(0);
});
});

// ─── removeElement GSAP cascade ──────────────────────────────────────────────

describe("removeElement — GSAP cascade", () => {
it("removes animations targeting the removed element from the script", () => {
const parsed = fresh();
const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
// forward: [remove_element, replace_script]
expect(result.forward).toHaveLength(2);
expect(result.forward[0]).toEqual({ op: "remove", path: "/elements/hf-box" });
const newScript = String(result.forward[1]?.value ?? "");
expect(newScript).not.toContain("hf-box");
});

it("inverse restores element AND script", () => {
const parsed = fresh();
const { inverse } = applyOp(parsed, { type: "removeElement", target: "hf-box" });
// inverse[0] = restore element, inverse[1] = restore script
expect(inverse).toHaveLength(2);
expect(inverse[0]?.op).toBe("add");
expect(inverse[0]?.path).toBe("/elements/hf-box");
expect(inverse[1]?.op).toBe("replace");
expect(inverse[1]?.path).toBe("/script/gsap");
const restoredScript = String(inverse[1]?.value ?? "");
expect(restoredScript).toContain("hf-box");
});

it("applying inverse restores element and GSAP script to original", () => {
const parsed = fresh();
const origScript = getScript(parsed);
const { inverse } = applyOp(parsed, { type: "removeElement", target: "hf-box" });
applyPatchesToDocument(parsed, inverse);
expect(parsed.document.querySelector('[data-hf-id="hf-box"]')).not.toBeNull();
expect(getScript(parsed)).toBe(origScript);
});

it("emits only element patch when composition has no GSAP script", () => {
const noScriptHtml = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-box"></div>
</div>`.trim();
const parsed = parseMutable(noScriptHtml);
const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
expect(result.forward).toHaveLength(1);
expect(result.forward[0]?.op).toBe("remove");
});

it("does not remove animations targeting other elements", () => {
const twoTweenScript = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
tl.to("[data-hf-id=\\"hf-stage\\"]", { scale: 1.05, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
const parsed = fresh(twoTweenScript);
const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
const newScript = String(result.forward[1]?.value ?? "");
expect(newScript).not.toContain("hf-box");
expect(newScript).toContain("hf-stage");
});
});
39 changes: 36 additions & 3 deletions packages/sdk/src/engine/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,9 +344,7 @@ function handleSetTiming(
// so without this the runtime would overwrite our attribute edits on next init.
if (parsedGsap && currentScript) {
for (const { id: animId, animation } of parsedGsap.located) {
const sel = animation.targetSelector;
if (sel !== `[data-hf-id="${id}"]` && sel !== `[data-hf-id='${id}']` && sel !== `#${id}`)
continue;
if (!selectorMatchesId(animation.targetSelector, id)) continue;
const updates: Partial<GsapAnimation> = {};
if (timing.start !== undefined && newStart !== null) updates.position = newStart;
if (timing.duration !== undefined && newDuration !== null) updates.duration = newDuration;
Expand Down Expand Up @@ -398,6 +396,9 @@ function handleSetHold(

function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResult {
const result: MutationResult = { forward: [], inverse: [] };
const origScript = getGsapScript(parsed.document);
let currentScript = origScript;

for (const id of ids) {
const el = findById(parsed.document, id);
if (!el) continue;
Expand All @@ -411,7 +412,17 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
const path = elementPath(id);
result.forward.push(patchRemove(path));
result.inverse.push(patchAdd(path, { html, parentId, siblingIndex }));

if (currentScript) currentScript = cascadeRemoveAnimations(currentScript, id);
}

if (origScript && currentScript && currentScript !== origScript) {
setGsapScript(parsed.document, currentScript);
const gsapResult = gsapScriptChange(origScript, currentScript);
result.forward.push(...gsapResult.forward);
result.inverse.push(...gsapResult.inverse);
}

return result;
}

Expand Down Expand Up @@ -487,6 +498,28 @@ function handleSetVariableValue(
return { forward: [p.forward], inverse: [p.inverse] };
}

// ─── GSAP selector helpers ───────────────────────────────────────────────────

function selectorMatchesId(selector: string, id: HfId): boolean {
return (
selector === `[data-hf-id="${id}"]` ||
selector === `[data-hf-id='${id}']` ||
selector === `#${id}`
);
}

function cascadeRemoveAnimations(script: string, id: HfId): string {
const parsedGsap = parseGsapScriptAcornForWrite(script);
if (!parsedGsap) return script;
let current = script;
for (const { id: animId, animation } of parsedGsap.located) {
if (selectorMatchesId(animation.targetSelector, id)) {
current = removeAnimationFromScript(current, animId);
}
}
return current;
}

// ─── setClassStyle handler ────────────────────────────────────────────────────

function handleSetClassStyle(
Expand Down
65 changes: 65 additions & 0 deletions packages/sdk/src/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,68 @@ describe("batch rollback on throw", () => {
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
});
});

// ─── canUndo / canRedo ────────────────────────────────────────────────────────

describe("canUndo / canRedo", () => {
it("returns false before any mutation", async () => {
const comp = await openComposition(BASE_HTML);
expect(comp.canUndo()).toBe(false);
expect(comp.canRedo()).toBe(false);
});

it("canUndo true after a mutation, false after undoing back to start", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#ff0000" });
expect(comp.canUndo()).toBe(true);
expect(comp.canRedo()).toBe(false);

comp.undo();
expect(comp.canUndo()).toBe(false);
expect(comp.canRedo()).toBe(true);
});

it("canRedo cleared after a new mutation", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#ff0000" });
comp.undo();
expect(comp.canRedo()).toBe(true);

comp.setStyle("hf-title", { color: "#00ff00" });
expect(comp.canRedo()).toBe(false);
});

it("returns false in embedded (T3) mode — no history", async () => {
const comp = await openComposition(BASE_HTML, { overrides: {} });
comp.setStyle("hf-title", { color: "#ff0000" });
expect(comp.canUndo()).toBe(false);
expect(comp.canRedo()).toBe(false);
});
});

// ─── override-set orphan cleanup ──────────────────────────────────────────────

describe("override-set orphan cleanup on removeElement", () => {
it("purges property keys for removed element from the override-set", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#ff0000", fontSize: "96px" });
expect(Object.keys(comp.getOverrides())).toContain("hf-title.style.color");

comp.removeElement("hf-title");
const overrides = comp.getOverrides();
// removal marker present
expect(overrides["hf-title"]).toBeNull();
// orphan property keys gone
expect(Object.keys(overrides)).not.toContain("hf-title.style.color");
expect(Object.keys(overrides)).not.toContain("hf-title.style.fontSize");
});

it("property keys for other elements are unaffected", async () => {
const comp = await openComposition(BASE_HTML);
comp.setStyle("hf-title", { color: "#ff0000" });
comp.setStyle("hf-sub", { opacity: "1" });
comp.removeElement("hf-title");
const overrides = comp.getOverrides();
expect(overrides["hf-sub.style.opacity"]).toBe("1");
});
});
21 changes: 21 additions & 0 deletions packages/sdk/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ class CompositionImpl implements Composition {
this.historyModule?.redo();
}

canUndo(): boolean {
return this.historyModule?.canUndo() ?? false;
}

canRedo(): boolean {
return this.historyModule?.canRedo() ?? false;
}

// ── Query API (F1) ───────────────────────────────────────────────────────────

getElements(): ElementSnapshot[] {
Expand Down Expand Up @@ -228,6 +236,19 @@ class CompositionImpl implements Composition {
}
}

// Purge orphan property keys for removed elements so the override-set stays
// compact and a future T3 session doesn't replay stale properties onto a
// non-existent element.
for (const p of forward) {
const elemMatch = /^\/elements\/([^/]+)$/.exec(p.path);
if (p.op === "remove" && elemMatch) {
const id = elemMatch[1]!;
for (const key of Object.keys(this.overrides)) {
if (key.startsWith(`${id}.`)) delete this.overrides[key];
}
}
}

if (this.batchDepth > 0) {
this.batchForward.push(...forward);
this.batchInverse.push(...inverse);
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ export interface Composition {
removeGsapTween(animationId: string): void;
undo(): void;
redo(): void;
canUndo(): boolean;
canRedo(): boolean;

// ── Query API (F1) ─────────────────────────────────────────────────────────
getElements(): ElementSnapshot[];
Expand Down
Loading
Loading