Skip to content

Commit 33aa492

Browse files
committed
feat(sdk): stage 4 — canUndo/canRedo, removeElement GSAP cascade, override-set cleanup
1 parent 23563d4 commit 33aa492

6 files changed

Lines changed: 373 additions & 3 deletions

File tree

packages/sdk/src/engine/mutate.gsap.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,61 @@ describe("removeLabel", () => {
419419
expect(result.forward).toHaveLength(0);
420420
});
421421
});
422+
423+
// ─── removeElement GSAP cascade ──────────────────────────────────────────────
424+
425+
describe("removeElement — GSAP cascade", () => {
426+
it("removes animations targeting the removed element from the script", () => {
427+
const parsed = fresh();
428+
const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
429+
// forward: [remove_element, replace_script]
430+
expect(result.forward).toHaveLength(2);
431+
expect(result.forward[0]).toEqual({ op: "remove", path: "/elements/hf-box" });
432+
const newScript = String(result.forward[1]?.value ?? "");
433+
expect(newScript).not.toContain("hf-box");
434+
});
435+
436+
it("inverse restores element AND script", () => {
437+
const parsed = fresh();
438+
const { inverse } = applyOp(parsed, { type: "removeElement", target: "hf-box" });
439+
// inverse[0] = restore element, inverse[1] = restore script
440+
expect(inverse).toHaveLength(2);
441+
expect(inverse[0]?.op).toBe("add");
442+
expect(inverse[0]?.path).toBe("/elements/hf-box");
443+
expect(inverse[1]?.op).toBe("replace");
444+
expect(inverse[1]?.path).toBe("/script/gsap");
445+
const restoredScript = String(inverse[1]?.value ?? "");
446+
expect(restoredScript).toContain("hf-box");
447+
});
448+
449+
it("applying inverse restores element and GSAP script to original", () => {
450+
const parsed = fresh();
451+
const origScript = getScript(parsed);
452+
const { inverse } = applyOp(parsed, { type: "removeElement", target: "hf-box" });
453+
applyPatchesToDocument(parsed, inverse);
454+
expect(parsed.document.querySelector('[data-hf-id="hf-box"]')).not.toBeNull();
455+
expect(getScript(parsed)).toBe(origScript);
456+
});
457+
458+
it("emits only element patch when composition has no GSAP script", () => {
459+
const noScriptHtml = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
460+
<div data-hf-id="hf-box"></div>
461+
</div>`.trim();
462+
const parsed = parseMutable(noScriptHtml);
463+
const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
464+
expect(result.forward).toHaveLength(1);
465+
expect(result.forward[0]?.op).toBe("remove");
466+
});
467+
468+
it("does not remove animations targeting other elements", () => {
469+
const twoTweenScript = `var tl = gsap.timeline({ paused: true });
470+
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
471+
tl.to("[data-hf-id=\\"hf-stage\\"]", { scale: 1.05, duration: 1 }, 0);
472+
window.__timelines["t"] = tl;`;
473+
const parsed = fresh(twoTweenScript);
474+
const result = applyOp(parsed, { type: "removeElement", target: "hf-box" });
475+
const newScript = String(result.forward[1]?.value ?? "");
476+
expect(newScript).not.toContain("hf-box");
477+
expect(newScript).toContain("hf-stage");
478+
});
479+
});

packages/sdk/src/engine/mutate.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,9 +344,7 @@ function handleSetTiming(
344344
// so without this the runtime would overwrite our attribute edits on next init.
345345
if (parsedGsap && currentScript) {
346346
for (const { id: animId, animation } of parsedGsap.located) {
347-
const sel = animation.targetSelector;
348-
if (sel !== `[data-hf-id="${id}"]` && sel !== `[data-hf-id='${id}']` && sel !== `#${id}`)
349-
continue;
347+
if (!selectorMatchesId(animation.targetSelector, id)) continue;
350348
const updates: Partial<GsapAnimation> = {};
351349
if (timing.start !== undefined && newStart !== null) updates.position = newStart;
352350
if (timing.duration !== undefined && newDuration !== null) updates.duration = newDuration;
@@ -398,6 +396,9 @@ function handleSetHold(
398396

399397
function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResult {
400398
const result: MutationResult = { forward: [], inverse: [] };
399+
const origScript = getGsapScript(parsed.document);
400+
let currentScript = origScript;
401+
401402
for (const id of ids) {
402403
const el = findById(parsed.document, id);
403404
if (!el) continue;
@@ -411,7 +412,17 @@ function handleRemoveElement(parsed: ParsedDocument, ids: HfId[]): MutationResul
411412
const path = elementPath(id);
412413
result.forward.push(patchRemove(path));
413414
result.inverse.push(patchAdd(path, { html, parentId, siblingIndex }));
415+
416+
if (currentScript) currentScript = cascadeRemoveAnimations(currentScript, id);
417+
}
418+
419+
if (origScript && currentScript && currentScript !== origScript) {
420+
setGsapScript(parsed.document, currentScript);
421+
const gsapResult = gsapScriptChange(origScript, currentScript);
422+
result.forward.push(...gsapResult.forward);
423+
result.inverse.push(...gsapResult.inverse);
414424
}
425+
415426
return result;
416427
}
417428

@@ -487,6 +498,28 @@ function handleSetVariableValue(
487498
return { forward: [p.forward], inverse: [p.inverse] };
488499
}
489500

501+
// ─── GSAP selector helpers ───────────────────────────────────────────────────
502+
503+
function selectorMatchesId(selector: string, id: HfId): boolean {
504+
return (
505+
selector === `[data-hf-id="${id}"]` ||
506+
selector === `[data-hf-id='${id}']` ||
507+
selector === `#${id}`
508+
);
509+
}
510+
511+
function cascadeRemoveAnimations(script: string, id: HfId): string {
512+
const parsedGsap = parseGsapScriptAcornForWrite(script);
513+
if (!parsedGsap) return script;
514+
let current = script;
515+
for (const { id: animId, animation } of parsedGsap.located) {
516+
if (selectorMatchesId(animation.targetSelector, id)) {
517+
current = removeAnimationFromScript(current, animId);
518+
}
519+
}
520+
return current;
521+
}
522+
490523
// ─── setClassStyle handler ────────────────────────────────────────────────────
491524

492525
function handleSetClassStyle(

packages/sdk/src/session.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,68 @@ describe("batch rollback on throw", () => {
232232
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
233233
});
234234
});
235+
236+
// ─── canUndo / canRedo ────────────────────────────────────────────────────────
237+
238+
describe("canUndo / canRedo", () => {
239+
it("returns false before any mutation", async () => {
240+
const comp = await openComposition(BASE_HTML);
241+
expect(comp.canUndo()).toBe(false);
242+
expect(comp.canRedo()).toBe(false);
243+
});
244+
245+
it("canUndo true after a mutation, false after undoing back to start", async () => {
246+
const comp = await openComposition(BASE_HTML);
247+
comp.setStyle("hf-title", { color: "#ff0000" });
248+
expect(comp.canUndo()).toBe(true);
249+
expect(comp.canRedo()).toBe(false);
250+
251+
comp.undo();
252+
expect(comp.canUndo()).toBe(false);
253+
expect(comp.canRedo()).toBe(true);
254+
});
255+
256+
it("canRedo cleared after a new mutation", async () => {
257+
const comp = await openComposition(BASE_HTML);
258+
comp.setStyle("hf-title", { color: "#ff0000" });
259+
comp.undo();
260+
expect(comp.canRedo()).toBe(true);
261+
262+
comp.setStyle("hf-title", { color: "#00ff00" });
263+
expect(comp.canRedo()).toBe(false);
264+
});
265+
266+
it("returns false in embedded (T3) mode — no history", async () => {
267+
const comp = await openComposition(BASE_HTML, { overrides: {} });
268+
comp.setStyle("hf-title", { color: "#ff0000" });
269+
expect(comp.canUndo()).toBe(false);
270+
expect(comp.canRedo()).toBe(false);
271+
});
272+
});
273+
274+
// ─── override-set orphan cleanup ──────────────────────────────────────────────
275+
276+
describe("override-set orphan cleanup on removeElement", () => {
277+
it("purges property keys for removed element from the override-set", async () => {
278+
const comp = await openComposition(BASE_HTML);
279+
comp.setStyle("hf-title", { color: "#ff0000", fontSize: "96px" });
280+
expect(Object.keys(comp.getOverrides())).toContain("hf-title.style.color");
281+
282+
comp.removeElement("hf-title");
283+
const overrides = comp.getOverrides();
284+
// removal marker present
285+
expect(overrides["hf-title"]).toBeNull();
286+
// orphan property keys gone
287+
expect(Object.keys(overrides)).not.toContain("hf-title.style.color");
288+
expect(Object.keys(overrides)).not.toContain("hf-title.style.fontSize");
289+
});
290+
291+
it("property keys for other elements are unaffected", async () => {
292+
const comp = await openComposition(BASE_HTML);
293+
comp.setStyle("hf-title", { color: "#ff0000" });
294+
comp.setStyle("hf-sub", { opacity: "1" });
295+
comp.removeElement("hf-title");
296+
const overrides = comp.getOverrides();
297+
expect(overrides["hf-sub.style.opacity"]).toBe("1");
298+
});
299+
});

packages/sdk/src/session.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,14 @@ class CompositionImpl implements Composition {
150150
this.historyModule?.redo();
151151
}
152152

153+
canUndo(): boolean {
154+
return this.historyModule?.canUndo() ?? false;
155+
}
156+
157+
canRedo(): boolean {
158+
return this.historyModule?.canRedo() ?? false;
159+
}
160+
153161
// ── Query API (F1) ───────────────────────────────────────────────────────────
154162

155163
getElements(): ElementSnapshot[] {
@@ -238,6 +246,19 @@ class CompositionImpl implements Composition {
238246
}
239247
}
240248

249+
// Purge orphan property keys for removed elements so the override-set stays
250+
// compact and a future T3 session doesn't replay stale properties onto a
251+
// non-existent element.
252+
for (const p of forward) {
253+
const elemMatch = /^\/elements\/([^/]+)$/.exec(p.path);
254+
if (p.op === "remove" && elemMatch) {
255+
const id = elemMatch[1]!;
256+
for (const key of Object.keys(this.overrides)) {
257+
if (key.startsWith(`${id}.`)) delete this.overrides[key];
258+
}
259+
}
260+
}
261+
241262
if (this.batchDepth > 0) {
242263
this.batchForward.push(...forward);
243264
this.batchInverse.push(...inverse);

packages/sdk/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,8 @@ export interface Composition {
229229
removeGsapTween(animationId: string): void;
230230
undo(): void;
231231
redo(): void;
232+
canUndo(): boolean;
233+
canRedo(): boolean;
232234

233235
// ── Query API (F1) ─────────────────────────────────────────────────────────
234236
getElements(): ElementSnapshot[];

0 commit comments

Comments
 (0)