diff --git a/packages/core/package.json b/packages/core/package.json index e4d5aa19b..23e3f6505 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,6 +95,10 @@ "import": "./src/parsers/hfIds.ts", "types": "./src/parsers/hfIds.ts" }, + "./gsap-cascade": { + "import": "./src/parsers/gsapCascade.ts", + "types": "./src/parsers/gsapCascade.ts" + }, "./gsap-parser": { "import": "./src/parsers/gsapParser.ts", "types": "./src/parsers/gsapParser.ts" @@ -194,6 +198,10 @@ "import": "./dist/parsers/hfIds.js", "types": "./dist/parsers/hfIds.d.ts" }, + "./gsap-cascade": { + "import": "./dist/parsers/gsapCascade.js", + "types": "./dist/parsers/gsapCascade.d.ts" + }, "./gsap-parser": { "import": "./dist/parsers/gsapParser.js", "types": "./dist/parsers/gsapParser.d.ts" diff --git a/packages/core/src/parsers/gsapCascade.ts b/packages/core/src/parsers/gsapCascade.ts new file mode 100644 index 000000000..4f4cf27f8 --- /dev/null +++ b/packages/core/src/parsers/gsapCascade.ts @@ -0,0 +1,70 @@ +import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; +import { removeAnimationFromScript } from "./gsapWriterAcorn.js"; + +/** Minimal structural shape for collectSubtreeHfIds — satisfied by both linkedom and DOMParser elements. */ +export interface HfIdElement { + getAttribute(name: string): string | null; + querySelectorAll(selector: string): ArrayLike<{ getAttribute(name: string): string | null }>; +} + +export function selectorMatchesId(selector: string, id: string): boolean { + return ( + selector === `[data-hf-id="${id}"]` || + selector === `[data-hf-id='${id}']` || + selector === `#${id}` + ); +} + +// v1 limitation: selectorMatchesId 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. + +/** Collect all bare data-hf-id values from el and all its [data-hf-id] descendants. */ +export function collectSubtreeHfIds(el: HfIdElement): string[] { + const ids: string[] = []; + const own = el.getAttribute("data-hf-id"); + if (own) ids.push(own); + for (const child of Array.from(el.querySelectorAll("[data-hf-id]"))) { + const id = child.getAttribute("data-hf-id"); + if (id) ids.push(id); + } + return ids; +} + +export function cascadeRemoveAnimations(script: string, id: string): string { + // Re-parse after each removal: animation ids are positional, so removing one + // tween renumbers the survivors — ids from a single up-front parse go stale and + // no-op, orphaning later tweens on the removed element. Same fix as + // stripGsapForId in htmlParser.ts (R3 #3); this is its SDK-side twin. + let current = script; + for (;;) { + const parsedGsap = parseGsapScriptAcornForWrite(current); + if (!parsedGsap) return current; + const match = parsedGsap.located.find((l) => selectorMatchesId(l.animation.targetSelector, id)); + if (!match) return current; + const next = removeAnimationFromScript(current, match.id); + if (next === current) return current; + current = next; + } +} + +/** Minimal interface for a document with queryable, mutable script elements. */ +export interface ScriptDocument { + querySelectorAll(selector: string): ArrayLike<{ textContent: string | null }>; +} + +/** Strip tweens for each id from every GSAP script element in the document. */ +export function cascadeRemoveAnimationsFromDocument(doc: ScriptDocument, ids: string[]): void { + if (ids.length === 0) return; + for (const script of Array.from(doc.querySelectorAll("script"))) { + const text = script.textContent ?? ""; + if (!text.includes("gsap") && !text.includes("ScrollTrigger")) continue; + let current = text; + for (const id of ids) { + current = cascadeRemoveAnimations(current, id); + } + if (current !== text) script.textContent = current; + } +} diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts index 5a9082544..cd8e68b29 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/core/src/parsers/htmlParser.ts @@ -12,8 +12,7 @@ import type { } from "../core.types"; import { validateCompositionGsap } from "./gsapSerialize"; import { ensureHfIds } from "./hfIds.js"; -import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; -import { removeAnimationFromScript } from "./gsapWriterAcorn.js"; +import { collectSubtreeHfIds, cascadeRemoveAnimationsFromDocument } from "./gsapCascade.js"; import type { ValidationResult } from "../core.types"; const MEDIA_TYPES = new Set(["video", "image", "audio"]); @@ -674,49 +673,13 @@ export function addElementToHtml( }; } -function selectorTargetsId(selector: string, id: string): boolean { - return ( - selector === `#${id}` || - selector === `[data-hf-id="${id}"]` || - selector === `[data-hf-id='${id}']` - ); -} - -function stripGsapForId(script: string, elementId: string): string { - // Re-parse after every removal. Animation ids are count-based (positional), so - // removing one tween renumbers the survivors — ids captured from a single - // up-front parse go stale and silently no-op, orphaning later tweens on the - // now-deleted element. Always remove the FIRST still-matching animation in a - // freshly-parsed script until none remain. - let current = script; - for (;;) { - const parsed = parseGsapScriptAcornForWrite(current); - if (!parsed) return current; - const match = parsed.located.find((l) => - selectorTargetsId(l.animation.targetSelector, elementId), - ); - if (!match) return current; - const updated = removeAnimationFromScript(current, match.id); - // Guard against a non-removing match (would otherwise loop forever). - if (updated === current) return current; - current = updated; - } -} - -function cascadeRemoveGsapById(doc: Document, elementId: string): void { - for (const script of Array.from(doc.querySelectorAll("script"))) { - const text = script.textContent ?? ""; - if (!text.includes("gsap") && !text.includes("ScrollTrigger")) continue; - const updated = stripGsapForId(text, elementId); - if (updated !== text) script.textContent = updated; - } -} - export function removeElementFromHtml(html: string, elementId: string): string { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); - doc.getElementById(elementId)?.remove(); - cascadeRemoveGsapById(doc, elementId); + const el = doc.getElementById(elementId); + const subtreeIds = el ? collectSubtreeHfIds(el) : []; + el?.remove(); + cascadeRemoveAnimationsFromDocument(doc, subtreeIds.length > 0 ? subtreeIds : [elementId]); return "\n" + doc.documentElement.outerHTML; } diff --git a/packages/core/src/studio-api/helpers/sourceMutation.test.ts b/packages/core/src/studio-api/helpers/sourceMutation.test.ts index 06bb7155e..57b7ee17f 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.test.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.test.ts @@ -32,6 +32,103 @@ describe("removeElementFromHtml", () => { expect(removeElementFromHtml(html, { id: "photo" })).toBe(`
`); }); + + it("strips the GSAP tween for a leaf element on delete", () => { + const html = ` +
+
+ +`; + + const updated = removeElementFromHtml(html, { hfId: "leaf" }); + + expect(updated).not.toContain('data-hf-id="leaf"'); + expect(updated).not.toContain("opacity: 0"); + // sibling tween must survive + expect(updated).toContain('data-hf-id="sibling"'); + expect(updated).toContain("x: 100"); + }); + + it("strips all subtree GSAP tweens when deleting a parent with animated children", () => { + const html = ` +
+
+
+
+
+ +`; + + const updated = removeElementFromHtml(html, { hfId: "parent" }); + + expect(updated).not.toContain('data-hf-id="parent"'); + expect(updated).not.toContain('data-hf-id="child-a"'); + expect(updated).not.toContain('data-hf-id="child-b"'); + expect(updated).not.toContain("opacity: 0"); + expect(updated).not.toContain("x: 100"); + expect(updated).not.toContain("y: 200"); + // outside element and its tween must survive + expect(updated).toContain('data-hf-id="outside"'); + expect(updated).toContain("scale: 2"); + }); + + it("handles positional renumbering — strips both tweens when element has two", () => { + const html = ` +
+
+ +`; + + const updated = removeElementFromHtml(html, { hfId: "box" }); + + expect(updated).not.toContain("x: 100"); + expect(updated).not.toContain("x: 200"); + // other's tween must survive + expect(updated).toContain("y: 50"); + }); + + it("leaves non-gsap scripts untouched", () => { + const script = `console.log("hello world");`; + const html = ` +
+ +`; + + const updated = removeElementFromHtml(html, { hfId: "el" }); + + expect(updated).toContain(script); + }); + + it("leaves script unchanged when deleted element has no GSAP tweens", () => { + const html = ` +
+
+ +`; + + const updated = removeElementFromHtml(html, { hfId: "el" }); + + expect(updated).not.toContain('data-hf-id="el"'); + expect(updated).toContain("opacity: 1"); + }); }); describe("patchElementInHtml", () => { diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index bfec5eb4c..84f6a080c 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -1,6 +1,10 @@ import { parseHTML } from "linkedom"; import postcss from "postcss"; import selectorParser from "postcss-selector-parser"; +import { + collectSubtreeHfIds, + cascadeRemoveAnimationsFromDocument, +} from "../../parsers/gsapCascade.js"; export interface SourceMutationTarget { id?: string | null; @@ -118,7 +122,10 @@ export function removeElementFromHtml(source: string, target: SourceMutationTarg const element = findTargetElement(document, target); if (!element) return source; + const subtreeIds = collectSubtreeHfIds(element); element.remove(); + cascadeRemoveAnimationsFromDocument(document, subtreeIds); + return wrappedFragment ? document.body.innerHTML || "" : document.toString(); } diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 6091a7a19..84b507e9e 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -42,6 +42,11 @@ import { } from "./patches.js"; import { upsertCssRule } from "./cssWriter.js"; import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn"; +import { + selectorMatchesId, + collectSubtreeHfIds, + cascadeRemoveAnimations, +} from "@hyperframes/core/gsap-cascade"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { addAnimationToScript, @@ -127,7 +132,6 @@ function validateSetAttribute(name: string, value: string | null): void { export class UnsupportedOpError extends Error { // Stable error code — part of the public API contract (F7); hosts switch on // err.code rather than the message. - // fallow-ignore-next-line unused-class-member readonly code = "E_UNSUPPORTED_OP"; constructor(opType: string) { super( @@ -699,51 +703,6 @@ 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: selectorMatchesId 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. - -/** Collect all bare data-hf-id values from el and all its descendants. */ -function collectSubtreeHfIds(el: Element): string[] { - const ids: string[] = []; - const own = el.getAttribute("data-hf-id"); - if (own) ids.push(own); - for (const child of Array.from(el.querySelectorAll("[data-hf-id]"))) { - const id = child.getAttribute("data-hf-id"); - if (id) ids.push(id); - } - return ids; -} - -function cascadeRemoveAnimations(script: string, id: HfId): string { - // Re-parse after each removal: animation ids are positional, so removing one - // tween renumbers the survivors — ids from a single up-front parse go stale and - // no-op, orphaning later tweens on the removed element. Same fix as - // stripGsapForId in htmlParser.ts (R3 #3); this is its SDK-side twin. - let current = script; - for (;;) { - const parsedGsap = parseGsapScriptAcornForWrite(current); - if (!parsedGsap) return current; - const match = parsedGsap.located.find((l) => selectorMatchesId(l.animation.targetSelector, id)); - if (!match) return current; - const next = removeAnimationFromScript(current, match.id); - if (next === current) return current; // guard against a non-removing match - current = next; - } -} - // ─── setClassStyle handler ──────────────────────────────────────────────────── function handleSetClassStyle(