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
8 changes: 8 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/parsers/gsapCascade.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
47 changes: 5 additions & 42 deletions packages/core/src/parsers/htmlParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(["video", "image", "audio"]);
Expand Down Expand Up @@ -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 "<!DOCTYPE html>\n" + doc.documentElement.outerHTML;
}

Expand Down
97 changes: 97 additions & 0 deletions packages/core/src/studio-api/helpers/sourceMutation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,103 @@ describe("removeElementFromHtml", () => {

expect(removeElementFromHtml(html, { id: "photo" })).toBe(`<div id="rest"></div>`);
});

it("strips the GSAP tween for a leaf element on delete", () => {
const html = `<!doctype html><html><body>
<div data-hf-id="leaf" id="leaf"></div>
<div data-hf-id="sibling" id="sibling"></div>
<script>
var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"leaf\\"]", { opacity: 0, duration: 1 }, 0);
tl.to("[data-hf-id=\\"sibling\\"]", { x: 100, duration: 1 }, 0);
</script>
</body></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 = `<!doctype html><html><body>
<div data-hf-id="parent" id="parent">
<div data-hf-id="child-a" id="child-a"></div>
<div data-hf-id="child-b" id="child-b"></div>
</div>
<div data-hf-id="outside" id="outside"></div>
<script>
var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"parent\\"]", { opacity: 0, duration: 1 }, 0);
tl.to("[data-hf-id=\\"child-a\\"]", { x: 100, duration: 1 }, 0);
tl.to("[data-hf-id=\\"child-b\\"]", { y: 200, duration: 1 }, 0);
tl.to("[data-hf-id=\\"outside\\"]", { scale: 2, duration: 1 }, 0);
</script>
</body></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 = `<!doctype html><html><body>
<div data-hf-id="box" id="box"></div>
<div data-hf-id="other" id="other"></div>
<script>
var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"box\\"]", { x: 100, duration: 1 }, 0);
tl.to("[data-hf-id=\\"other\\"]", { y: 50, duration: 1 }, 0);
tl.to("[data-hf-id=\\"box\\"]", { x: 200, duration: 1 }, 2);
</script>
</body></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 = `<!doctype html><html><body>
<div data-hf-id="el" id="el"></div>
<script>${script}</script>
</body></html>`;

const updated = removeElementFromHtml(html, { hfId: "el" });

expect(updated).toContain(script);
});

it("leaves script unchanged when deleted element has no GSAP tweens", () => {
const html = `<!doctype html><html><body>
<div data-hf-id="el" id="el"></div>
<div data-hf-id="other" id="other"></div>
<script>
var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"other\\"]", { opacity: 1, duration: 1 }, 0);
</script>
</body></html>`;

const updated = removeElementFromHtml(html, { hfId: "el" });

expect(updated).not.toContain('data-hf-id="el"');
expect(updated).toContain("opacity: 1");
});
});

describe("patchElementInHtml", () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/studio-api/helpers/sourceMutation.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
}

Expand Down
51 changes: 5 additions & 46 deletions packages/sdk/src/engine/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading