mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(core): GSAP-aware split engine for timeline clip splitting (#1330)
* refactor(studio): extract shared timeline components and deduplicate code Extract shared utilities to reduce duplication across timeline components: - PlayheadIndicator: shared playhead rendering (was duplicated in TimelineCanvas and TimelineEditorNotice) - useContextMenuDismiss: outside-click/Escape dismiss pattern (was duplicated in ClipContextMenu and KeyframeDiamondContextMenu) - TimelineCallbacks: shared callback interfaces for drop and edit operations (was duplicated in NLELayout and Timeline props) - useTimelineZoom: consolidated zoom store selectors - timelineElementSplit: shared canSplitElement, buildPatchTarget, and readFileContent utilities - gsapParser.test-helpers: shared test utilities for parser specs * feat(core): GSAP-aware split engine for timeline clip splitting Add splitAnimationsInScript to the GSAP parser — correctly re-times animations when a timeline clip is split at an arbitrary position: - Animations before split: kept on original, properties inherited via tl.set inserted before other tweens for correct GSAP state recording - Animations after split: retargeted via AST selector update - Spanning animations: trimmed on original, continuation added for new element with correct position and duration - Keyframes: classified by total per-keyframe duration - Reverse iteration prevents stale animation ID collisions Enhance splitElementInHtml: - CSS rule duplication via PostCSS for ID-based styles - Server-side ID deduplication for repeated splits - Media playback-start adjustment for video/audio Add split-animations route to gsap-mutations endpoint.
This commit is contained in:
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"packages/aws-lambda": {
|
||||
"name": "@hyperframes/aws-lambda",
|
||||
"version": "0.6.80",
|
||||
"version": "0.6.88",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/client-sfn": "^3.700.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@hyperframes/cli",
|
||||
"version": "0.6.80",
|
||||
"version": "0.6.88",
|
||||
"bin": {
|
||||
"hyperframes": "./dist/cli.js",
|
||||
},
|
||||
@@ -101,11 +101,12 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@hyperframes/core",
|
||||
"version": "0.6.80",
|
||||
"version": "0.6.88",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-selector-parser": "^7.1.2",
|
||||
"recast": "^0.23.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -130,7 +131,7 @@
|
||||
},
|
||||
"packages/engine": {
|
||||
"name": "@hyperframes/engine",
|
||||
"version": "0.6.80",
|
||||
"version": "0.6.88",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hyperframes/core": "workspace:^",
|
||||
@@ -148,7 +149,7 @@
|
||||
},
|
||||
"packages/gcp-cloud-run": {
|
||||
"name": "@hyperframes/gcp-cloud-run",
|
||||
"version": "0.6.79",
|
||||
"version": "0.6.88",
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.14.0",
|
||||
"@google-cloud/workflows": "^4.2.0",
|
||||
@@ -168,7 +169,7 @@
|
||||
},
|
||||
"packages/player": {
|
||||
"name": "@hyperframes/player",
|
||||
"version": "0.6.80",
|
||||
"version": "0.6.88",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.0",
|
||||
"gsap": "^3.12.5",
|
||||
@@ -180,7 +181,7 @@
|
||||
},
|
||||
"packages/producer": {
|
||||
"name": "@hyperframes/producer",
|
||||
"version": "0.6.80",
|
||||
"version": "0.6.88",
|
||||
"dependencies": {
|
||||
"@fontsource/archivo-black": "^5.2.8",
|
||||
"@fontsource/eb-garamond": "^5.2.7",
|
||||
@@ -221,7 +222,7 @@
|
||||
},
|
||||
"packages/shader-transitions": {
|
||||
"name": "@hyperframes/shader-transitions",
|
||||
"version": "0.6.80",
|
||||
"version": "0.6.88",
|
||||
"dependencies": {
|
||||
"html2canvas": "^1.4.1",
|
||||
},
|
||||
@@ -233,7 +234,7 @@
|
||||
},
|
||||
"packages/studio": {
|
||||
"name": "@hyperframes/studio",
|
||||
"version": "0.6.80",
|
||||
"version": "0.6.88",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
@@ -1738,7 +1739,7 @@
|
||||
|
||||
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
"postcss-selector-parser": ["postcss-selector-parser@7.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Wjvt4scRFouioIInHf51IFNP4ltJ2EngJM+cZPGiqbKetBfmP3vpdPV8ID2S6JS6/jdo74N8+aEYH9lQr2C6sA=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
@@ -2132,6 +2133,8 @@
|
||||
|
||||
"path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
@@ -2148,6 +2151,8 @@
|
||||
|
||||
"tailwindcss/jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="],
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-selector-parser": "^7.1.2",
|
||||
"recast": "^0.23.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -17,9 +17,18 @@ import {
|
||||
convertToKeyframesInScript,
|
||||
removeAllKeyframesFromScript,
|
||||
addAnimationWithKeyframesToScript,
|
||||
splitAnimationsInScript,
|
||||
} from "./gsapParser.js";
|
||||
import type { GsapAnimation } from "./gsapParser.js";
|
||||
import type { Keyframe } from "../core.types";
|
||||
import {
|
||||
parseAndSerialize,
|
||||
parseSingleAnimation,
|
||||
expectKeyframe,
|
||||
expectKeyframesFormat,
|
||||
convertAndReparse,
|
||||
parseSplitAndAssert,
|
||||
} from "./gsapParser.test-helpers.js";
|
||||
|
||||
describe("parseGsapScript", () => {
|
||||
it("parses a basic timeline with .to()", () => {
|
||||
@@ -299,11 +308,7 @@ describe("stagger/yoyo/repeat round-trip", () => {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(".items", { opacity: 1, duration: 0.5, stagger: { each: 0.15, from: "start" } }, 0);
|
||||
`;
|
||||
const parsed = parseGsapScript(script);
|
||||
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
|
||||
preamble: parsed.preamble,
|
||||
postamble: parsed.postamble,
|
||||
});
|
||||
const { serialized } = parseAndSerialize(script);
|
||||
|
||||
expect(serialized).toContain("stagger: {");
|
||||
expect(serialized).toContain("each: 0.15");
|
||||
@@ -315,11 +320,7 @@ describe("stagger/yoyo/repeat round-trip", () => {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el1", { x: 100, duration: 1, yoyo: true, repeat: 3, repeatDelay: 0.2 }, 0);
|
||||
`;
|
||||
const parsed = parseGsapScript(script);
|
||||
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
|
||||
preamble: parsed.preamble,
|
||||
postamble: parsed.postamble,
|
||||
});
|
||||
const { serialized } = parseAndSerialize(script);
|
||||
|
||||
expect(serialized).toContain("yoyo: true");
|
||||
expect(serialized).toContain("repeat: 3");
|
||||
@@ -349,11 +350,7 @@ describe("unresolvable value round-trip", () => {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el1", { opacity: someFn(), x: 50, duration: 1 }, 0);
|
||||
`;
|
||||
const parsed = parseGsapScript(script);
|
||||
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
|
||||
preamble: parsed.preamble,
|
||||
postamble: parsed.postamble,
|
||||
});
|
||||
const { serialized } = parseAndSerialize(script);
|
||||
|
||||
// The raw expression should survive — emitted without quotes
|
||||
expect(serialized).toContain("opacity: someFn()");
|
||||
@@ -1101,9 +1098,8 @@ describe("gsap.utils.toArray targets", () => {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(gsap.utils.toArray(".item"), { opacity: 1, duration: 0.5, stagger: 0.1 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].targetSelector).toBe(".item");
|
||||
const anim = parseSingleAnimation(script);
|
||||
expect(anim.targetSelector).toBe(".item");
|
||||
});
|
||||
|
||||
it("resolves a toArray result stored in a variable", () => {
|
||||
@@ -1112,8 +1108,8 @@ describe("gsap.utils.toArray targets", () => {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(items, { opacity: 1, duration: 0.5 }, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].targetSelector).toBe(".item");
|
||||
const anim = parseSingleAnimation(script);
|
||||
expect(anim.targetSelector).toBe(".item");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1147,9 +1143,8 @@ describe("forEach / map callback targets", () => {
|
||||
tl.to(el, { opacity: 1, duration: 0.4 }, 0);
|
||||
});
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
expect(result.animations[0].targetSelector).toBe(".item");
|
||||
const anim = parseSingleAnimation(script);
|
||||
expect(anim.targetSelector).toBe(".item");
|
||||
});
|
||||
|
||||
it("resolves an inline querySelectorAll().forEach callback param", () => {
|
||||
@@ -1159,8 +1154,8 @@ describe("forEach / map callback targets", () => {
|
||||
tl.to(dot, { scale: 1, duration: 0.3 }, 0);
|
||||
});
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations[0].targetSelector).toBe(".dot");
|
||||
const anim = parseSingleAnimation(script);
|
||||
expect(anim.targetSelector).toBe(".dot");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1205,23 +1200,12 @@ describe("native GSAP keyframes parsing", () => {
|
||||
duration: 5
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
const anim = result.animations[0];
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
expect(anim.keyframes!.format).toBe("percentage");
|
||||
expect(anim.keyframes!.keyframes).toHaveLength(3);
|
||||
const anim = parseSingleAnimation(script);
|
||||
const kfs = expectKeyframesFormat(anim, "percentage", 3);
|
||||
|
||||
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1);
|
||||
|
||||
expect(anim.keyframes!.keyframes[1].percentage).toBe(50);
|
||||
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
|
||||
expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out");
|
||||
|
||||
expect(anim.keyframes!.keyframes[2].percentage).toBe(100);
|
||||
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
|
||||
expectKeyframe(kfs[0], 0, { x: 0, opacity: 1 });
|
||||
expectKeyframe(kfs[1], 50, { x: 100 }, "power2.out");
|
||||
expectKeyframe(kfs[2], 100, { x: 200 });
|
||||
});
|
||||
|
||||
it("parses object array keyframes format", () => {
|
||||
@@ -1235,26 +1219,15 @@ describe("native GSAP keyframes parsing", () => {
|
||||
]
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
const anim = result.animations[0];
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
expect(anim.keyframes!.format).toBe("object-array");
|
||||
expect(anim.keyframes!.keyframes).toHaveLength(3);
|
||||
const anim = parseSingleAnimation(script);
|
||||
const kfs = expectKeyframesFormat(anim, "object-array", 3);
|
||||
|
||||
// Total duration = 0.5 + 1 + 0.8 = 2.3
|
||||
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1);
|
||||
|
||||
expectKeyframe(kfs[0], 0, { x: 0, opacity: 1 });
|
||||
// Second: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22
|
||||
expect(anim.keyframes!.keyframes[1].percentage).toBe(22);
|
||||
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
|
||||
expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out");
|
||||
|
||||
expectKeyframe(kfs[1], 22, { x: 100 }, "power2.out");
|
||||
// Third: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65
|
||||
expect(anim.keyframes!.keyframes[2].percentage).toBe(65);
|
||||
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
|
||||
expectKeyframe(kfs[2], 65, { x: 200 });
|
||||
});
|
||||
|
||||
it("parses simple array keyframes format", () => {
|
||||
@@ -1265,30 +1238,17 @@ describe("native GSAP keyframes parsing", () => {
|
||||
duration: 5
|
||||
}, 0);
|
||||
`;
|
||||
const result = parseGsapScript(script);
|
||||
expect(result.animations).toHaveLength(1);
|
||||
const anim = result.animations[0];
|
||||
const anim = parseSingleAnimation(script);
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
expect(anim.keyframes!.format).toBe("simple-array");
|
||||
expect(anim.keyframes!.easeEach).toBe("power2.inOut");
|
||||
expect(anim.keyframes!.keyframes).toHaveLength(4);
|
||||
|
||||
// Evenly spaced: 0%, 33%, 67%, 100%
|
||||
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(0);
|
||||
|
||||
expect(anim.keyframes!.keyframes[1].percentage).toBe(33);
|
||||
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
|
||||
expect(anim.keyframes!.keyframes[1].properties.opacity).toBe(1);
|
||||
|
||||
expect(anim.keyframes!.keyframes[2].percentage).toBe(67);
|
||||
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
|
||||
expect(anim.keyframes!.keyframes[2].properties.opacity).toBe(1);
|
||||
|
||||
expect(anim.keyframes!.keyframes[3].percentage).toBe(100);
|
||||
expect(anim.keyframes!.keyframes[3].properties.x).toBe(0);
|
||||
expect(anim.keyframes!.keyframes[3].properties.opacity).toBe(0);
|
||||
expectKeyframe(anim.keyframes!.keyframes[0], 0, { x: 0, opacity: 0 });
|
||||
expectKeyframe(anim.keyframes!.keyframes[1], 33, { x: 100, opacity: 1 });
|
||||
expectKeyframe(anim.keyframes!.keyframes[2], 67, { x: 200, opacity: 1 });
|
||||
expectKeyframe(anim.keyframes!.keyframes[3], 100, { x: 0, opacity: 0 });
|
||||
});
|
||||
|
||||
it("parses three-level easing", () => {
|
||||
@@ -1539,18 +1499,11 @@ describe("keyframe mutations", () => {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from("#title", { x: -200, opacity: 0, duration: 0.8 }, 0.3);
|
||||
`;
|
||||
const id = getAnimId(script);
|
||||
const updated = convertToKeyframesInScript(script, id, { x: 0, opacity: 1 });
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const anim = reparsed.animations[0];
|
||||
|
||||
const anim = convertAndReparse(script, { x: 0, opacity: 1 });
|
||||
expect(anim.method).toBe("to");
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
const kfs = anim.keyframes!.keyframes;
|
||||
expect(kfs[0].properties.x).toBe(-200);
|
||||
expect(kfs[0].properties.opacity).toBe(0);
|
||||
expect(kfs[1].properties.x).toBe(0);
|
||||
expect(kfs[1].properties.opacity).toBe(1);
|
||||
const kfs = expectKeyframesFormat(anim, "percentage", 2);
|
||||
expectKeyframe(kfs[0]!, 0, { x: -200, opacity: 0 });
|
||||
expectKeyframe(kfs[1]!, 100, { x: 0, opacity: 1 });
|
||||
});
|
||||
|
||||
it("convertToKeyframesInScript — converts fromTo() to to() + keyframes", () => {
|
||||
@@ -1558,16 +1511,11 @@ describe("keyframe mutations", () => {
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#title", { x: -100 }, { x: 100, duration: 1 }, 0);
|
||||
`;
|
||||
const id = getAnimId(script);
|
||||
const updated = convertToKeyframesInScript(script, id);
|
||||
const reparsed = parseGsapScript(updated);
|
||||
const anim = reparsed.animations[0];
|
||||
|
||||
const anim = convertAndReparse(script);
|
||||
expect(anim.method).toBe("to");
|
||||
expect(anim.keyframes).toBeDefined();
|
||||
const kfs = anim.keyframes!.keyframes;
|
||||
expect(kfs[0].properties.x).toBe(-100);
|
||||
expect(kfs[1].properties.x).toBe(100);
|
||||
const kfs = expectKeyframesFormat(anim, "percentage", 2);
|
||||
expect(kfs[0]!.properties.x).toBe(-100);
|
||||
expect(kfs[1]!.properties.x).toBe(100);
|
||||
});
|
||||
|
||||
it("convertToKeyframesInScript — skips if already has keyframes", () => {
|
||||
@@ -1793,3 +1741,206 @@ tl.to("#title", { x: 100, duration: 0.5 }, 0);
|
||||
expect(id).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitAnimationsInScript", () => {
|
||||
const baseScript = `const tl = gsap.timeline({ paused: true });`;
|
||||
const opts = {
|
||||
originalId: "el1",
|
||||
newId: "el1-split",
|
||||
splitTime: 2,
|
||||
elementStart: 0,
|
||||
elementDuration: 4,
|
||||
};
|
||||
|
||||
const split = (script: string, o = opts) => splitAnimationsInScript(script, o).script;
|
||||
|
||||
it("keeps animation entirely in first half and adds set for inherited state", () => {
|
||||
const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 0);`;
|
||||
const result = split(script);
|
||||
const parsed = parseGsapScript(result);
|
||||
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
|
||||
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
|
||||
expect(forOriginal).toHaveLength(1);
|
||||
expect(forNew).toHaveLength(1);
|
||||
expect(forNew[0]!.method).toBe("set");
|
||||
expect(forNew[0]!.properties.x).toBe(100);
|
||||
expect(forNew[0]!.position).toBe(opts.splitTime);
|
||||
});
|
||||
|
||||
it("retargets animation entirely in second half to new element", () => {
|
||||
const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 3);`;
|
||||
const selectors = parseSplitAndAssert(script, (s) => split(s), 1);
|
||||
expect(selectors[0]).toBe("#el1-split");
|
||||
});
|
||||
|
||||
it("splits spanning tween with linear interpolation and fromTo on clone", () => {
|
||||
const script = `${baseScript}\ntl.to("#el1", { opacity: 1, duration: 4 }, 0);`;
|
||||
const setupOpts = { ...opts, splitTime: 2, elementDuration: 4 };
|
||||
const result = split(script, setupOpts);
|
||||
const parsed = parseGsapScript(result);
|
||||
const first = parsed.animations.find((a) => a.targetSelector === "#el1");
|
||||
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
|
||||
const continuation = forNew.find((a) => a.method === "fromTo");
|
||||
expect(first).toBeDefined();
|
||||
expect(first!.duration).toBe(2);
|
||||
expect(first!.properties.opacity).toBe(0.5);
|
||||
expect(continuation).toBeDefined();
|
||||
expect(continuation!.duration).toBe(2);
|
||||
expect(continuation!.fromProperties?.opacity).toBe(0.5);
|
||||
expect(continuation!.properties.opacity).toBe(1);
|
||||
});
|
||||
|
||||
it("retargets multiple animations at the same position both after split", () => {
|
||||
const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 3);\ntl.to("#el1", { y: 200, duration: 1 }, 3);`;
|
||||
const result = split(script);
|
||||
const parsed = parseGsapScript(result);
|
||||
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
|
||||
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
|
||||
expect(forOriginal.length).toBe(0);
|
||||
expect(forNew.length).toBe(2);
|
||||
});
|
||||
|
||||
it("returns script unchanged when no matching animations", () => {
|
||||
const script = `${baseScript}\ntl.to("#other", { x: 100, duration: 1 }, 0);`;
|
||||
const result = split(script);
|
||||
expect(result).toBe(script);
|
||||
});
|
||||
|
||||
it("handles multiple animations independently", () => {
|
||||
const script = `${baseScript}
|
||||
tl.to("#el1", { x: 100, duration: 1 }, 0);
|
||||
tl.to("#el1", { y: 200, duration: 1 }, 3);`;
|
||||
const result = split(script);
|
||||
const parsed = parseGsapScript(result);
|
||||
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
|
||||
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
|
||||
expect(forOriginal).toHaveLength(1);
|
||||
expect(forOriginal[0]!.properties.x).toBe(100);
|
||||
expect(forNew).toHaveLength(2);
|
||||
const retargeted = forNew.find((a) => a.method === "to");
|
||||
const inherited = forNew.find((a) => a.method === "set");
|
||||
expect(retargeted!.properties.y).toBe(200);
|
||||
expect(inherited!.properties.x).toBe(100);
|
||||
});
|
||||
|
||||
it("interpolates fromTo properties at split point on both halves", () => {
|
||||
const script = `${baseScript}\ntl.fromTo("#el1", { opacity: 0 }, { opacity: 1, duration: 4 }, 0);`;
|
||||
const result = split(script);
|
||||
const parsed = parseGsapScript(result);
|
||||
const first = parsed.animations.find((a) => a.targetSelector === "#el1");
|
||||
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
|
||||
const continuation = forNew.find((a) => a.method === "fromTo");
|
||||
expect(first!.properties.opacity).toBe(0.5);
|
||||
expect(continuation).toBeDefined();
|
||||
expect(continuation!.fromProperties?.opacity).toBe(0.5);
|
||||
expect(continuation!.properties.opacity).toBe(1);
|
||||
});
|
||||
|
||||
it("round-trips correctly through parseGsapScript", () => {
|
||||
const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 4 }, 0);`;
|
||||
const result = split(script);
|
||||
const parsed = parseGsapScript(result);
|
||||
expect(parsed.animations.length).toBeGreaterThanOrEqual(2);
|
||||
for (const anim of parsed.animations) {
|
||||
expect(typeof anim.position).toBe("number");
|
||||
if (anim.method !== "set") expect(anim.duration).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves spanning keyframes on original and warns via skippedSelectors", () => {
|
||||
const script = `${baseScript}\ntl.to("#el1", { keyframes: [{ opacity: 1, duration: 1 }, { scale: 1.2, duration: 1 }, { x: 50, duration: 1 }] }, 1);`;
|
||||
const splitOpts = {
|
||||
originalId: "el1",
|
||||
newId: "el1-split",
|
||||
splitTime: 2.5,
|
||||
elementStart: 0,
|
||||
elementDuration: 5,
|
||||
};
|
||||
const fullResult = splitAnimationsInScript(script, splitOpts);
|
||||
const parsed = parseGsapScript(fullResult.script);
|
||||
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
|
||||
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
|
||||
expect(forOriginal.length).toBe(1);
|
||||
expect(forOriginal[0]!.keyframes).toBeDefined();
|
||||
expect(forNew.length).toBe(1);
|
||||
expect(forNew[0]!.method).toBe("set");
|
||||
expect(forNew[0]!.properties.opacity).toBe(1);
|
||||
expect(fullResult.skippedSelectors).toContain("#el1 (keyframes spanning split)");
|
||||
});
|
||||
|
||||
it("retargets keyframes animation entirely after split", () => {
|
||||
const script = `${baseScript}\ntl.to("#el1", { keyframes: [{ opacity: 1, duration: 0.5 }, { scale: 1.2, duration: 0.5 }] }, 4);`;
|
||||
const splitOpts = {
|
||||
originalId: "el1",
|
||||
newId: "el1-split",
|
||||
splitTime: 3,
|
||||
elementStart: 0,
|
||||
elementDuration: 5,
|
||||
};
|
||||
const result = split(script, splitOpts);
|
||||
const parsed = parseGsapScript(result);
|
||||
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
|
||||
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
|
||||
expect(forOriginal.length).toBe(0);
|
||||
expect(forNew.length).toBe(1);
|
||||
expect(forNew[0]!.keyframes).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps keyframes animation entirely before split and inherits final keyframe state", () => {
|
||||
const script = `${baseScript}\ntl.to("#el1", { keyframes: [{ opacity: 1, duration: 0.5 }, { scale: 1.2, duration: 0.5 }] }, 0);\ntl.to("#el1", { y: 100, duration: 1 }, 4);`;
|
||||
const splitOpts = {
|
||||
originalId: "el1",
|
||||
newId: "el1-split",
|
||||
splitTime: 3,
|
||||
elementStart: 0,
|
||||
elementDuration: 5,
|
||||
};
|
||||
const result = split(script, splitOpts);
|
||||
const parsed = parseGsapScript(result);
|
||||
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
|
||||
const setState = forNew.find((a) => a.method === "set");
|
||||
expect(setState).toBeDefined();
|
||||
expect(setState!.properties.scale).toBe(1.2);
|
||||
});
|
||||
|
||||
it("retargets set tween entirely after split", () => {
|
||||
const script = `${baseScript}\ntl.set("#el1", { opacity: 0 }, 3);`;
|
||||
const result = split(script);
|
||||
const parsed = parseGsapScript(result);
|
||||
const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1");
|
||||
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
|
||||
expect(forOriginal.length).toBe(0);
|
||||
expect(forNew.length).toBe(1);
|
||||
expect(forNew[0]!.method).toBe("set");
|
||||
expect(forNew[0]!.position).toBe(3);
|
||||
});
|
||||
|
||||
it("inserts inherited state set before other tweens targeting new element", () => {
|
||||
const script = `${baseScript}\ntl.to("#el1", { opacity: 1, x: 50, duration: 0.5 }, 0);\ntl.to("#el1", { opacity: 0, duration: 0.5 }, 5);`;
|
||||
const splitOpts = {
|
||||
originalId: "el1",
|
||||
newId: "el1-split",
|
||||
splitTime: 3,
|
||||
elementStart: 0,
|
||||
elementDuration: 6,
|
||||
};
|
||||
const result = split(script, splitOpts);
|
||||
const parsed = parseGsapScript(result);
|
||||
const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split");
|
||||
const setState = forNew.find((a) => a.method === "set");
|
||||
const exitTween = forNew.find((a) => a.method === "to");
|
||||
expect(setState).toBeDefined();
|
||||
expect(exitTween).toBeDefined();
|
||||
expect(setState!.properties.opacity).toBe(1);
|
||||
expect(setState!.properties.x).toBe(50);
|
||||
const setIdx = parsed.animations.indexOf(setState!);
|
||||
const exitIdx = parsed.animations.indexOf(exitTween!);
|
||||
expect(setIdx).toBeLessThan(exitIdx);
|
||||
});
|
||||
|
||||
it("reports skipped selectors for non-ID-based animations referencing the element", () => {
|
||||
const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 0);\ntl.to(".el1", { opacity: 0, duration: 1 }, 1);`;
|
||||
const result = splitAnimationsInScript(script, opts);
|
||||
expect(result.skippedSelectors).toEqual([".el1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -580,6 +580,20 @@ function parsePercentageKeyframes(node: any, scope: ScopeBindings): GsapKeyframe
|
||||
};
|
||||
}
|
||||
|
||||
function computeKeyframesTotalDuration(varsNode: any, scope: ScopeBindings): number | undefined {
|
||||
const kfNode = (varsNode.properties ?? []).find(
|
||||
(p: any) => (p.key?.name ?? p.key?.value) === "keyframes",
|
||||
)?.value;
|
||||
if (!kfNode || kfNode.type !== "ArrayExpression") return undefined;
|
||||
let total = 0;
|
||||
for (const el of kfNode.elements ?? []) {
|
||||
if (!el || el.type !== "ObjectExpression") continue;
|
||||
const r = objectExpressionToRecord(el, scope);
|
||||
if (typeof r.duration === "number") total += r.duration;
|
||||
}
|
||||
return total > 0 ? total : undefined;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
|
||||
const elements = node.elements ?? [];
|
||||
@@ -861,9 +875,13 @@ function tweenCallToAnimation(
|
||||
const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0;
|
||||
const position: number | string =
|
||||
typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0;
|
||||
const duration = typeof vars.duration === "number" ? vars.duration : undefined;
|
||||
let duration = typeof vars.duration === "number" ? vars.duration : undefined;
|
||||
const ease = typeof vars.ease === "string" ? vars.ease : undefined;
|
||||
|
||||
if (duration === undefined && keyframesData) {
|
||||
duration = computeKeyframesTotalDuration(call.varsArg, scope);
|
||||
}
|
||||
|
||||
const anim: Omit<GsapAnimation, "id"> = {
|
||||
targetSelector: call.selector,
|
||||
method: call.method,
|
||||
@@ -1025,6 +1043,21 @@ function setVarsKey(varsArg: any, key: string, value: number | string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an ObjectExpression's properties, keeping non-editable keys
|
||||
* and delegating the keep/drop decision for editable keys to `shouldKeep`.
|
||||
*/
|
||||
function filterEditableKeys(varsArg: any, shouldKeep: (key: string) => boolean): void {
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
varsArg.properties = varsArg.properties.filter((p: any) => {
|
||||
if (!isObjectProperty(p)) return true;
|
||||
const key = propKeyName(p);
|
||||
if (typeof key !== "string") return true;
|
||||
if (!isEditablePropertyKey(key)) return true;
|
||||
return shouldKeep(key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the editable-property keys on an ObjectExpression with `newProps`,
|
||||
* leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys
|
||||
@@ -1034,15 +1067,7 @@ function reconcileEditableProperties(
|
||||
varsArg: any,
|
||||
newProps: Record<string, number | string>,
|
||||
): void {
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
// Drop editable props no longer present.
|
||||
varsArg.properties = varsArg.properties.filter((p: any) => {
|
||||
if (!isObjectProperty(p)) return true;
|
||||
const key = propKeyName(p);
|
||||
if (typeof key !== "string") return true;
|
||||
if (!isEditablePropertyKey(key)) return true;
|
||||
return key in newProps;
|
||||
});
|
||||
filterEditableKeys(varsArg, (key) => key in newProps);
|
||||
// Upsert each new prop, preserving the order keys first appeared.
|
||||
for (const [key, value] of Object.entries(newProps)) {
|
||||
setVarsKey(varsArg, key, value);
|
||||
@@ -1125,6 +1150,24 @@ export function updateAnimationInScript(
|
||||
return recast.print(parsed.ast).code;
|
||||
}
|
||||
|
||||
function updateAnimationSelector(script: string, animationId: string, newSelector: string): string {
|
||||
let parsed: ParsedGsapAst;
|
||||
try {
|
||||
parsed = parseGsapAst(script);
|
||||
} catch {
|
||||
return script;
|
||||
}
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
const selectorArg = target.call.path.node.arguments?.[0];
|
||||
if (selectorArg?.type === "StringLiteral") {
|
||||
selectorArg.value = newSelector;
|
||||
} else if (selectorArg?.type === "Identifier") {
|
||||
target.call.path.node.arguments[0] = { type: "StringLiteral", value: newSelector };
|
||||
}
|
||||
return recast.print(parsed.ast).code;
|
||||
}
|
||||
|
||||
export function addAnimationToScript(
|
||||
script: string,
|
||||
animation: Omit<GsapAnimation, "id">,
|
||||
@@ -1277,8 +1320,186 @@ export function removeAnimationFromScript(script: string, animationId: string):
|
||||
return recast.print(parsed.ast).code;
|
||||
}
|
||||
|
||||
function insertInheritedStateSet(
|
||||
script: string,
|
||||
selector: string,
|
||||
position: number,
|
||||
properties: Record<string, number | string>,
|
||||
): string {
|
||||
let parsed: ParsedGsapAst;
|
||||
try {
|
||||
parsed = parseGsapAst(script);
|
||||
} catch {
|
||||
return script;
|
||||
}
|
||||
const tlVar = parsed.timelineVar;
|
||||
const props = Object.entries(properties)
|
||||
.map(([k, v]) => `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`)
|
||||
.join(", ");
|
||||
const code = `${tlVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`;
|
||||
const newStatement = parseScript(code).program.body[0];
|
||||
const anchor = findTimelineDeclarationPath(parsed.ast, tlVar);
|
||||
if (anchor) {
|
||||
anchor.insertAfter(newStatement);
|
||||
} else if (parsed.located.length > 0) {
|
||||
const firstTween = parsed.located[0]!.call;
|
||||
const stmtPath = findStatementPath(firstTween.path);
|
||||
if (stmtPath) stmtPath.insertBefore(newStatement);
|
||||
else parsed.ast.program.body.unshift(newStatement);
|
||||
} else {
|
||||
parsed.ast.program.body.push(newStatement);
|
||||
}
|
||||
return recast.print(parsed.ast).code;
|
||||
}
|
||||
|
||||
// ── Split Animation Functions ─────────────────────────────────────────────
|
||||
|
||||
export interface SplitAnimationsOptions {
|
||||
originalId: string;
|
||||
newId: string;
|
||||
splitTime: number;
|
||||
elementStart: number;
|
||||
elementDuration: number;
|
||||
}
|
||||
|
||||
export interface SplitAnimationsResult {
|
||||
script: string;
|
||||
/** Non-ID-selector animations that the engine cannot safely retarget. */
|
||||
skippedSelectors: string[];
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function splitAnimationsInScript(
|
||||
script: string,
|
||||
opts: SplitAnimationsOptions,
|
||||
): SplitAnimationsResult {
|
||||
const parsed = parseGsapScript(script);
|
||||
const originalSelector = `#${opts.originalId}`;
|
||||
const newSelector = `#${opts.newId}`;
|
||||
|
||||
const skippedSelectors: string[] = [];
|
||||
for (const a of parsed.animations) {
|
||||
if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) {
|
||||
skippedSelectors.push(a.targetSelector);
|
||||
}
|
||||
}
|
||||
|
||||
const matching = parsed.animations.filter((a) => a.targetSelector === originalSelector);
|
||||
if (matching.length === 0) return { script, skippedSelectors };
|
||||
|
||||
let result = script;
|
||||
const newElementStart = opts.splitTime;
|
||||
const inheritedProps: Record<string, number | string> = {};
|
||||
|
||||
// Reverse iteration: updateAnimationSelector mutates selectors in the source
|
||||
// string, which can shift count-based ID suffixes (e.g. "#hero-1" → "#hero-2")
|
||||
// for later animations. Processing last-to-first prevents stale ID collisions.
|
||||
for (let i = matching.length - 1; i >= 0; i--) {
|
||||
const anim = matching[i]!;
|
||||
const pos = typeof anim.position === "number" ? anim.position : 0;
|
||||
const dur = anim.duration ?? 0;
|
||||
const animEnd = pos + dur;
|
||||
|
||||
if (anim.keyframes) {
|
||||
if (pos >= opts.splitTime) {
|
||||
result = updateAnimationSelector(result, anim.id, newSelector);
|
||||
} else if (animEnd > opts.splitTime) {
|
||||
// Spanning keyframes can't be correctly split without renormalizing
|
||||
// percentages and durations — leave on original, warn the caller.
|
||||
skippedSelectors.push(`${originalSelector} (keyframes spanning split)`);
|
||||
const kfs = anim.keyframes.keyframes;
|
||||
for (const kf of kfs) {
|
||||
const kfTime = pos + (kf.percentage / 100) * dur;
|
||||
if (kfTime <= opts.splitTime) {
|
||||
for (const [k, v] of Object.entries(kf.properties)) {
|
||||
inheritedProps[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Entirely before split — extract final keyframe properties
|
||||
const kfs = anim.keyframes.keyframes;
|
||||
if (kfs.length > 0) {
|
||||
for (const [k, v] of Object.entries(kfs[kfs.length - 1]!.properties)) {
|
||||
inheritedProps[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (animEnd <= opts.splitTime) {
|
||||
for (const [k, v] of Object.entries(anim.properties)) {
|
||||
inheritedProps[k] = v;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pos >= opts.splitTime) {
|
||||
result = updateAnimationSelector(result, anim.id, newSelector);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Spans the split — use linear interpolation to compute mid-values,
|
||||
// then .fromTo() on the clone so both halves play the correct range.
|
||||
// For .fromTo() tweens we have explicit from-values; for .to() tweens
|
||||
// we use accumulated state from prior animations, defaulting to 0 for
|
||||
// unknown numeric properties (the standard GSAP transform initial state).
|
||||
const progress = dur > 0 ? (opts.splitTime - pos) / dur : 0;
|
||||
const fromSource = anim.fromProperties ?? inheritedProps;
|
||||
const midProps: Record<string, number | string> = {};
|
||||
for (const [k, v] of Object.entries(anim.properties)) {
|
||||
if (typeof v !== "number") {
|
||||
midProps[k] = v;
|
||||
continue;
|
||||
}
|
||||
const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0;
|
||||
midProps[k] = fromVal + (v - fromVal) * progress;
|
||||
}
|
||||
|
||||
const firstHalfDuration = opts.splitTime - pos;
|
||||
result = updateAnimationInScript(result, anim.id, {
|
||||
duration: firstHalfDuration,
|
||||
properties: midProps,
|
||||
});
|
||||
|
||||
const secondHalfDuration = animEnd - opts.splitTime;
|
||||
const addResult = addAnimationToScript(result, {
|
||||
targetSelector: newSelector,
|
||||
method: "fromTo",
|
||||
position: newElementStart,
|
||||
duration: secondHalfDuration,
|
||||
properties: { ...anim.properties },
|
||||
fromProperties: { ...midProps },
|
||||
ease: anim.ease,
|
||||
extras: anim.extras,
|
||||
});
|
||||
result = addResult.script;
|
||||
|
||||
for (const [k, v] of Object.entries(midProps)) {
|
||||
inheritedProps[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(inheritedProps).length > 0) {
|
||||
result = insertInheritedStateSet(result, newSelector, newElementStart, inheritedProps);
|
||||
}
|
||||
|
||||
return { script: result, skippedSelectors };
|
||||
}
|
||||
|
||||
// ── Keyframe Mutation Functions ────────────────────────────────────────────
|
||||
|
||||
function sortedKeyframes(
|
||||
kfs: Array<{ percentage: number; properties: Record<string, number | string>; ease?: string }>,
|
||||
) {
|
||||
return kfs.slice().sort((a, b) => a.percentage - b.percentage);
|
||||
}
|
||||
|
||||
function keyframePropsToCode(kf: { properties: Record<string, number | string> }): string[] {
|
||||
return Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
}
|
||||
|
||||
/** Remove a named property from an ObjectExpression's properties array. */
|
||||
function removeVarsKey(varsArg: any, key: string): void {
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
@@ -1344,6 +1565,19 @@ function collapseKeyframesToFlat(varsArg: any, record: Record<string, unknown>):
|
||||
removeVarsKey(varsArg, "easeEach");
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an animation's keyframes ObjectExpression and build the percentage key.
|
||||
* Shared preamble for addKeyframeToScript, removeKeyframeFromScript, and
|
||||
* updateKeyframeInScript.
|
||||
*/
|
||||
function locateKeyframeCtx(script: string, animationId: string, percentage: number) {
|
||||
const loc = locateAnimation(script, animationId);
|
||||
if (!loc) return null;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return null;
|
||||
return { loc, kfNode, pctKey: `${percentage}%` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a keyframe at the given percentage in an existing percentage-keyframes
|
||||
* object. If the percentage already exists, its value is replaced.
|
||||
@@ -1375,8 +1609,8 @@ export function addKeyframeToScript(
|
||||
kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
}
|
||||
|
||||
const pctKey = `${percentage}%`;
|
||||
|
||||
const newValueNode = buildKeyframeValueNode(properties, ease);
|
||||
|
||||
// Replace if this percentage already exists
|
||||
@@ -1467,12 +1701,10 @@ export function removeKeyframeFromScript(
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
): string {
|
||||
const loc = locateAnimation(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
const ctx = locateKeyframeCtx(script, animationId, percentage);
|
||||
if (!ctx) return script;
|
||||
const { loc, kfNode, pctKey } = ctx;
|
||||
|
||||
const pctKey = `${percentage}%`;
|
||||
const removeIdx = kfNode.properties.findIndex(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
|
||||
);
|
||||
@@ -1502,12 +1734,10 @@ export function updateKeyframeInScript(
|
||||
properties: Record<string, number | string>,
|
||||
ease?: string,
|
||||
): string {
|
||||
const loc = locateAnimation(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
const ctx = locateKeyframeCtx(script, animationId, percentage);
|
||||
if (!ctx) return script;
|
||||
const { loc, kfNode, pctKey } = ctx;
|
||||
|
||||
const pctKey = `${percentage}%`;
|
||||
const existing = kfNode.properties.find(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
|
||||
);
|
||||
@@ -1560,14 +1790,15 @@ function resolveConversionProps(
|
||||
|
||||
/** Strip editable properties and ease/keyframes keys from a varsArg. */
|
||||
function stripEditableAndEase(varsArg: any): void {
|
||||
// ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it —
|
||||
// drop it explicitly before filtering, along with keyframes.
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
varsArg.properties = varsArg.properties.filter((p: any) => {
|
||||
if (!isObjectProperty(p)) return true;
|
||||
const key = propKeyName(p);
|
||||
if (typeof key !== "string") return true;
|
||||
if (key === "ease" || key === "keyframes") return false;
|
||||
return !isEditablePropertyKey(key);
|
||||
return key !== "ease" && key !== "keyframes";
|
||||
});
|
||||
filterEditableKeys(varsArg, () => false);
|
||||
}
|
||||
|
||||
/** Build and prepend a keyframes property node onto varsArg. */
|
||||
@@ -1674,11 +1905,8 @@ export function materializeKeyframesInScript(
|
||||
}
|
||||
|
||||
const entries: string[] = [];
|
||||
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
|
||||
for (const kf of sorted) {
|
||||
const propEntries = Object.entries(kf.properties).map(
|
||||
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
|
||||
);
|
||||
for (const kf of sortedKeyframes(keyframes)) {
|
||||
const propEntries = keyframePropsToCode(kf);
|
||||
if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`);
|
||||
entries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
|
||||
}
|
||||
@@ -1977,11 +2205,8 @@ export function unrollDynamicAnimations(
|
||||
const calls: string[] = [];
|
||||
for (const el of elements) {
|
||||
const kfEntries: string[] = [];
|
||||
const sorted = el.keyframes.slice().sort((a, b) => a.percentage - b.percentage);
|
||||
for (const kf of sorted) {
|
||||
const propEntries = Object.entries(kf.properties).map(
|
||||
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
|
||||
);
|
||||
for (const kf of sortedKeyframes(el.keyframes)) {
|
||||
const propEntries = keyframePropsToCode(kf);
|
||||
kfEntries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
|
||||
}
|
||||
if (el.easeEach) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
removeElementFromHtml,
|
||||
patchElementInHtml,
|
||||
probeElementInSource,
|
||||
splitElementInHtml,
|
||||
probeElementInSource,
|
||||
} from "./sourceMutation.js";
|
||||
|
||||
describe("removeElementFromHtml", () => {
|
||||
@@ -467,3 +467,53 @@ describe("splitElementInHtml — hfId clone isolation", () => {
|
||||
expect(occurrences).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitElementInHtml", () => {
|
||||
const source = `<!DOCTYPE html><html><head><style>#box { position: absolute; top: 100px; background: red; }</style></head><body><div data-composition-id="root"><div id="box" class="clip" data-start="1" data-duration="6">Hello</div></div></body></html>`;
|
||||
|
||||
it("splits element at the given time", () => {
|
||||
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.html).toContain('data-duration="2"');
|
||||
expect(result.html).toContain('id="box-split"');
|
||||
expect(result.html).toContain('data-start="3"');
|
||||
expect(result.html).toContain('data-duration="4"');
|
||||
});
|
||||
|
||||
it("duplicates CSS rules for the new element ID", () => {
|
||||
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
|
||||
expect(result.html).toContain("#box-split");
|
||||
expect(result.html).toContain("background: red");
|
||||
const cssMatches = result.html.match(/#box-split\s*\{/g);
|
||||
expect(cssMatches?.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("deduplicates IDs when the requested newId already exists", () => {
|
||||
const withExisting = source.replace(
|
||||
"</div></div>",
|
||||
'</div><div id="box-split" data-start="5" data-duration="1">Existing</div></div>',
|
||||
);
|
||||
const result = splitElementInHtml(withExisting, { id: "box" }, 3, "box-split");
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.html).toContain('id="box-split-2"');
|
||||
});
|
||||
|
||||
it("keeps clip class on the cloned element", () => {
|
||||
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
|
||||
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
|
||||
});
|
||||
|
||||
it("returns matched false for out-of-range split time", () => {
|
||||
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
|
||||
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
|
||||
});
|
||||
|
||||
it("adjusts media playback-start for the second half", () => {
|
||||
const mediaSource = source.replace(
|
||||
'id="box" class="clip" data-start="1" data-duration="6"',
|
||||
'id="box" class="clip" data-start="1" data-duration="6" data-playback-start="0"',
|
||||
);
|
||||
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
|
||||
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
import postcss from "postcss";
|
||||
import selectorParser from "postcss-selector-parser";
|
||||
|
||||
export interface SourceMutationTarget {
|
||||
id?: string | null;
|
||||
@@ -18,6 +20,36 @@ function parseSourceDocument(source: string): { document: Document; wrappedFragm
|
||||
};
|
||||
}
|
||||
|
||||
function duplicateCssRulesForId(document: Document, originalId: string, newId: string): void {
|
||||
const idToken = `#${originalId}`;
|
||||
const transform = selectorParser((selectors) => {
|
||||
selectors.walkIds((node) => {
|
||||
if (node.value === originalId) node.value = newId;
|
||||
});
|
||||
});
|
||||
for (const styleEl of document.querySelectorAll("style")) {
|
||||
const css = styleEl.textContent ?? "";
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
root = postcss.parse(css);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const clones: postcss.Rule[] = [];
|
||||
root.walkRules((rule) => {
|
||||
if (!rule.selector.includes(idToken)) return;
|
||||
const newSelector = transform.processSync(rule.selector);
|
||||
if (newSelector === rule.selector) return;
|
||||
const clone = rule.clone({ selector: newSelector });
|
||||
clones.push(clone);
|
||||
});
|
||||
if (clones.length > 0) {
|
||||
for (const c of clones) root.append(c);
|
||||
styleEl.textContent = root.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function querySelectorAllWithTemplates(root: Document | Element, selector: string): Element[] {
|
||||
const matches = Array.from(root.querySelectorAll(selector));
|
||||
if (matches.length > 0) return matches;
|
||||
@@ -287,6 +319,7 @@ function setElementDuration(
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function splitElementInHtml(
|
||||
source: string,
|
||||
target: SourceMutationTarget,
|
||||
@@ -302,6 +335,14 @@ export function splitElementInHtml(
|
||||
return { html: source, matched: false, newId: null };
|
||||
}
|
||||
|
||||
if (document.getElementById(newId)) {
|
||||
let suffix = 2;
|
||||
const base = newId;
|
||||
while (document.getElementById(newId)) {
|
||||
newId = `${base}-${suffix++}`;
|
||||
}
|
||||
}
|
||||
|
||||
const firstDuration = splitTime - start;
|
||||
const secondDuration = duration - firstDuration;
|
||||
|
||||
@@ -311,6 +352,9 @@ export function splitElementInHtml(
|
||||
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
|
||||
setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
|
||||
|
||||
// Keep the "clip" class — the runtime uses it to control visibility
|
||||
// based on data-start/data-duration timing.
|
||||
|
||||
// Adjust media trim offset for the second half
|
||||
const playbackStartAttr = el.hasAttribute("data-playback-start")
|
||||
? "data-playback-start"
|
||||
@@ -327,6 +371,12 @@ export function splitElementInHtml(
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate CSS rules targeting the original ID so the clone inherits the same styles.
|
||||
const originalId = el.getAttribute("id");
|
||||
if (originalId) {
|
||||
duplicateCssRulesForId(document, originalId, newId);
|
||||
}
|
||||
|
||||
// Trim the original element's duration
|
||||
setElementDuration(el, start, firstDuration, usesDataEnd);
|
||||
|
||||
|
||||
@@ -408,15 +408,25 @@ type GsapMutationRequest =
|
||||
auto?: boolean;
|
||||
}>;
|
||||
ease?: string;
|
||||
}
|
||||
| {
|
||||
type: "split-animations";
|
||||
originalId: string;
|
||||
newId: string;
|
||||
splitTime: number;
|
||||
elementStart: number;
|
||||
elementDuration: number;
|
||||
};
|
||||
|
||||
// ── GSAP mutation executor ──────────────────────────────────────────────────
|
||||
|
||||
type GsapMutationResult = string | { script: string; skippedSelectors: string[] };
|
||||
|
||||
async function executeGsapMutation(
|
||||
body: GsapMutationRequest,
|
||||
block: NonNullable<ReturnType<typeof extractGsapScriptBlock>>,
|
||||
respond: (data: unknown, status?: number) => Response,
|
||||
): Promise<string | Response> {
|
||||
): Promise<GsapMutationResult | Response> {
|
||||
const parser = await loadGsapParser();
|
||||
const {
|
||||
parseGsapScript,
|
||||
@@ -434,6 +444,7 @@ async function executeGsapMutation(
|
||||
updateArcSegmentInScript,
|
||||
removeArcPathFromScript,
|
||||
addAnimationWithKeyframesToScript,
|
||||
splitAnimationsInScript,
|
||||
} = parser;
|
||||
|
||||
function requireAnimation(
|
||||
@@ -606,6 +617,36 @@ async function executeGsapMutation(
|
||||
);
|
||||
return result.script;
|
||||
}
|
||||
case "split-animations": {
|
||||
if (
|
||||
typeof body.originalId !== "string" ||
|
||||
!body.originalId ||
|
||||
typeof body.newId !== "string" ||
|
||||
!body.newId ||
|
||||
typeof body.splitTime !== "number" ||
|
||||
!Number.isFinite(body.splitTime) ||
|
||||
typeof body.elementStart !== "number" ||
|
||||
!Number.isFinite(body.elementStart) ||
|
||||
typeof body.elementDuration !== "number" ||
|
||||
!Number.isFinite(body.elementDuration) ||
|
||||
body.elementDuration <= 0
|
||||
) {
|
||||
return respond(
|
||||
{
|
||||
error:
|
||||
"split-animations requires originalId, newId (non-empty strings), splitTime, elementStart (finite numbers), and elementDuration (positive number)",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return splitAnimationsInScript(block.scriptText, {
|
||||
originalId: body.originalId,
|
||||
newId: body.newId,
|
||||
splitTime: body.splitTime,
|
||||
elementStart: body.elementStart,
|
||||
elementDuration: body.elementDuration,
|
||||
});
|
||||
}
|
||||
default:
|
||||
return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
|
||||
}
|
||||
@@ -1019,21 +1060,24 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
const result = await executeGsapMutation(body, block, respond);
|
||||
if (result instanceof Response) return result;
|
||||
|
||||
const newScript = result;
|
||||
const newScript = typeof result === "string" ? result : result.script;
|
||||
const newHtml = block.replaceScript(newScript);
|
||||
if (newHtml !== html) {
|
||||
writeFileSync(res.absPath, newHtml, "utf-8");
|
||||
}
|
||||
|
||||
// Re-parse the mutated script so the UI gets fresh state
|
||||
const { parseGsapScript } = await loadGsapParser();
|
||||
const freshParsed = parseGsapScript(newScript);
|
||||
return c.json({
|
||||
const responsePayload: Record<string, unknown> = {
|
||||
ok: true,
|
||||
parsed: freshParsed,
|
||||
before: html,
|
||||
after: newHtml,
|
||||
scriptText: newScript,
|
||||
});
|
||||
};
|
||||
if (typeof result !== "string" && result.skippedSelectors.length > 0) {
|
||||
responsePayload.skippedSelectors = result.skippedSelectors;
|
||||
}
|
||||
return c.json(responsePayload);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user