mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(sdk): stage 4 — canUndo/canRedo, removeElement GSAP cascade, override-set cleanup (#1431)
* feat(sdk): stage 4 — canUndo/canRedo, removeElement GSAP cascade, override-set cleanup * docs(sdk): document cascadeRemoveAnimations bare-id v1 limitation for scoped ids Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * chore(sdk): remove sdk-status-report.txt from source tree Internal planning artifact should not be committed to the repo. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Miguel Ángel
parent
5ecaac1fcb
commit
9627e03fa7
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,9 +345,7 @@ function handleSetTiming(
|
||||
// have zero playback effect; the script's position/duration silently overrides them.
|
||||
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;
|
||||
@@ -399,6 +397,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;
|
||||
@@ -412,7 +413,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;
|
||||
}
|
||||
|
||||
@@ -488,6 +499,32 @@ 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}`
|
||||
);
|
||||
}
|
||||
|
||||
// v1 limitation: uses bare-id matching across the whole script, so a selector targeting
|
||||
// "hf-leaf" will cascade-remove animations for both "hf-parent/hf-leaf" and any other
|
||||
// element whose scoped or bare id matches "hf-leaf". Acceptable for typical single-comp
|
||||
// use; sub-composition authors with leaf-id collisions should use fully-qualified selectors.
|
||||
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(
|
||||
|
||||
@@ -232,3 +232,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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,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[] {
|
||||
@@ -238,6 +246,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);
|
||||
|
||||
@@ -229,6 +229,8 @@ export interface Composition {
|
||||
removeGsapTween(animationId: string): void;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
canUndo(): boolean;
|
||||
canRedo(): boolean;
|
||||
|
||||
// ── Query API (F1) ─────────────────────────────────────────────────────────
|
||||
getElements(): ElementSnapshot[];
|
||||
|
||||
Reference in New Issue
Block a user