mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(sdk,core): ws-3 prerequisites — acorn keyframe-collapse foundation + removeAllKeyframes (#1499)
* feat(sdk,core): ws-3 prerequisites — acorn keyframe-collapse foundation + removeAllKeyframes
P1: gsapWriter.parity.test.ts — recast-vs-acorn parity harness (reparse-equivalence).
P2: move pure keyframe-conversion transforms (resolveConversionProps, cssIdentityValue)
to recast-free gsapSerialize.ts so the acorn/SDK path can share them.
P3: MagicString splice primitives in gsapWriterAcorn.ts (buildVarsObjectCode, overwriteVarsArg).
P4: reference vertical slice — removeAllKeyframesFromScript ported to acorn writer +
removeAllKeyframes SDK op (types/mutate/can) + Studio cutover (useGsapKeyframeOps),
replacing the server-authoritative ponytail stub.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sdk,core): ws-3 — convertToKeyframes acorn port + SDK op + Studio cutover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(sdk,core): ws-3 — materializeKeyframes + splitIntoPropertyGroups acorn ports + SDK ops
- acorn: buildKeyframeObjectCode, materializeKeyframesFromScript, addAnimationWithKeyframesToScript
- acorn: splitIntoPropertyGroupsFromScript with filterGroupKeyframes/filterGroupProperties helpers
- parity tests: materialize (2 positive + 1 no-op) and split (2 positive + 2 no-op) suites
- SDK types: materializeKeyframes + splitIntoPropertyGroups EditOp variants
- mutate.ts: handlers + can() gates for both new ops
- mutate.gsap.test.ts: 6 new tests (53 total passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
* feat(sdk,core): ws-3 — splitAnimationsInScript acorn port + SDK op
- acorn: updateAnimationSelectorInScript, insertInheritedStateSetInScript helpers
- acorn: splitAnimationsInScript exported (parity with recast version)
- parity: 4 new fixtures (3 cases + no-op) — 23 total parity tests
- SDK types: splitAnimations EditOp variant
- mutate.ts: handleSplitAnimations + can() gate
- mutate.gsap.test.ts: 3 new tests (56 total passing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Miguel Ángel
parent
ceb815c318
commit
a746db6017
@@ -20,6 +20,7 @@ import {
|
||||
type ParsedGsap,
|
||||
serializeValue as valueToCode,
|
||||
safeJsKey as safeKey,
|
||||
resolveConversionProps,
|
||||
} from "./gsapSerialize";
|
||||
|
||||
export type {
|
||||
@@ -2009,62 +2010,6 @@ export function updateKeyframeInScript(
|
||||
return recast.print(loc.parsed.ast).code;
|
||||
}
|
||||
|
||||
/** Resolve from/to property maps for a tween being converted to keyframes. */
|
||||
const CSS_IDENTITY: Record<string, number> = {
|
||||
opacity: 1,
|
||||
autoAlpha: 1,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
};
|
||||
|
||||
function cssIdentityValue(prop: string): number {
|
||||
return CSS_IDENTITY[prop] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the 0% (from) and 100% (to) property maps for a tween being
|
||||
* converted to percentage keyframes.
|
||||
*
|
||||
* @param resolvedFromValues — Despite the "from" in the name (historical), these
|
||||
* are runtime-captured DOM values that override the conversion endpoint:
|
||||
* - For to(): overrides fromProps (the 0% state / where the element is now).
|
||||
* - For from(): overrides toProps (the 100% state / where the element rests).
|
||||
* - For fromTo(): merges into toProps (the 100% endpoint the user is editing).
|
||||
*/
|
||||
function resolveConversionProps(
|
||||
anim: GsapAnimation,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
): { fromProps: Record<string, number | string>; toProps: Record<string, number | string> } {
|
||||
if (anim.method === "to") {
|
||||
const identityFrom: Record<string, number | string> = {};
|
||||
for (const [key, val] of Object.entries(anim.properties)) {
|
||||
if (val != null) identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val;
|
||||
}
|
||||
const fromProps = resolvedFromValues
|
||||
? { ...identityFrom, ...resolvedFromValues }
|
||||
: identityFrom;
|
||||
return { fromProps, toProps: { ...anim.properties } };
|
||||
}
|
||||
if (anim.method === "from") {
|
||||
const identityTo: Record<string, number | string> = {};
|
||||
for (const [key, val] of Object.entries(anim.properties)) {
|
||||
if (val != null) identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val;
|
||||
}
|
||||
const toProps = resolvedFromValues ? { ...identityTo, ...resolvedFromValues } : identityTo;
|
||||
return { fromProps: { ...anim.properties }, toProps };
|
||||
}
|
||||
// fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state),
|
||||
// anim.properties = toVars (100% state). resolvedFromValues contains the
|
||||
// current DOM position from a drag — it represents the NEW destination, so
|
||||
// it merges into toProps (the 100% endpoint the user is editing), NOT into
|
||||
// fromProps. This is intentional and not inverted.
|
||||
const toProps = resolvedFromValues
|
||||
? { ...anim.properties, ...resolvedFromValues }
|
||||
: { ...anim.properties };
|
||||
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps };
|
||||
}
|
||||
|
||||
/** Strip editable properties and ease/keyframes keys from a varsArg. */
|
||||
function stripEditableAndEase(varsArg: AstNode): void {
|
||||
// ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it —
|
||||
|
||||
@@ -413,3 +413,65 @@ export function gsapAnimationsToKeyframes(
|
||||
.filter((kf): kf is NonNullable<typeof kf> => kf !== null)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Keyframe-conversion transforms (pure; shared by recast + acorn writers) ────
|
||||
|
||||
/**
|
||||
* CSS identity values for properties whose "rest" state isn't 0 — used to
|
||||
* synthesize the missing endpoint when converting a flat tween to keyframes.
|
||||
*/
|
||||
const CSS_IDENTITY: Record<string, number> = {
|
||||
opacity: 1,
|
||||
autoAlpha: 1,
|
||||
scale: 1,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
};
|
||||
|
||||
function cssIdentityValue(prop: string): number {
|
||||
return CSS_IDENTITY[prop] ?? 0;
|
||||
}
|
||||
|
||||
/** Build the identity-endpoint map for a flat tween's properties. */
|
||||
function buildIdentityMap(props: Record<string, number | string>): Record<string, number | string> {
|
||||
const identity: Record<string, number | string> = {};
|
||||
for (const [key, val] of Object.entries(props)) {
|
||||
if (val != null) identity[key] = typeof val === "number" ? cssIdentityValue(key) : val;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the 0% (from) and 100% (to) property maps for a tween being
|
||||
* converted to percentage keyframes.
|
||||
*
|
||||
* @param resolvedFromValues — Despite the "from" in the name (historical), these
|
||||
* are runtime-captured DOM values that override the conversion endpoint:
|
||||
* - For to(): overrides fromProps (the 0% state / where the element is now).
|
||||
* - For from(): overrides toProps (the 100% state / where the element rests).
|
||||
* - For fromTo(): merges into toProps (the 100% endpoint the user is editing).
|
||||
*/
|
||||
export function resolveConversionProps(
|
||||
anim: GsapAnimation,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
): { fromProps: Record<string, number | string>; toProps: Record<string, number | string> } {
|
||||
if (anim.method === "to") {
|
||||
const identity = buildIdentityMap(anim.properties);
|
||||
const fromProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity;
|
||||
return { fromProps, toProps: { ...anim.properties } };
|
||||
}
|
||||
if (anim.method === "from") {
|
||||
const identity = buildIdentityMap(anim.properties);
|
||||
const toProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity;
|
||||
return { fromProps: { ...anim.properties }, toProps };
|
||||
}
|
||||
// fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state),
|
||||
// anim.properties = toVars (100% state). resolvedFromValues contains the
|
||||
// current DOM position from a drag — it represents the NEW destination, so
|
||||
// it merges into toProps (the 100% endpoint the user is editing), NOT into
|
||||
// fromProps. This is intentional and not inverted.
|
||||
const toProps = resolvedFromValues
|
||||
? { ...anim.properties, ...resolvedFromValues }
|
||||
: { ...anim.properties };
|
||||
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Parity harness — recast writer (gsapParser.ts) vs acorn writer
|
||||
* (gsapWriterAcorn.ts). Both must produce scripts that REPARSE to the same
|
||||
* animation model. Byte-equality is not expected (recast pretty-prints, acorn
|
||||
* splices), so parity is asserted on the parsed GsapAnimation, not raw text.
|
||||
*
|
||||
* This is the safety net for porting WS-3 ops one at a time: each ported op
|
||||
* gets a fixture row here proving it matches the battle-tested original.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseGsapScript,
|
||||
removeAllKeyframesFromScript as removeAllRecast,
|
||||
convertToKeyframesInScript as convertRecast,
|
||||
materializeKeyframesInScript as materializeRecast,
|
||||
splitIntoPropertyGroups as splitGroupsRecast,
|
||||
splitAnimationsInScript as splitAnimsRecast,
|
||||
type SplitAnimationsOptions,
|
||||
} from "./gsapParser.js";
|
||||
import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js";
|
||||
import {
|
||||
removeAllKeyframesFromScript as removeAllAcorn,
|
||||
convertToKeyframesFromScript as convertAcorn,
|
||||
materializeKeyframesFromScript as materializeAcorn,
|
||||
splitIntoPropertyGroupsFromScript as splitGroupsAcorn,
|
||||
splitAnimationsInScript as splitAnimsAcorn,
|
||||
} from "./gsapWriterAcorn.js";
|
||||
|
||||
function acornId(script: string): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite;
|
||||
return parsed.located[0]!.id;
|
||||
}
|
||||
|
||||
/** Reparse a written script and return the first animation's editable shape. */
|
||||
function shapeOf(script: string) {
|
||||
const anim = parseGsapScript(script).animations[0]!;
|
||||
return {
|
||||
method: anim.method,
|
||||
properties: anim.properties,
|
||||
keyframes: anim.keyframes,
|
||||
duration: anim.duration,
|
||||
ease: anim.ease,
|
||||
};
|
||||
}
|
||||
|
||||
const REMOVE_ALL_FIXTURES: Array<{ name: string; script: string }> = [
|
||||
{
|
||||
name: "to() — collapses to last keyframe",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", {
|
||||
keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200, opacity: 1 } },
|
||||
duration: 2
|
||||
}, 0);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "to() — single keyframe + ease",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", {
|
||||
keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } },
|
||||
duration: 1,
|
||||
ease: "none"
|
||||
}, 0.5);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "to() — easeEach dropped on collapse",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#card", {
|
||||
keyframes: { "0%": { y: 0 }, "100%": { y: -40 }, easeEach: "power2.inOut" },
|
||||
duration: 1.5
|
||||
}, 0);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
describe("parity: removeAllKeyframesFromScript (recast vs acorn)", () => {
|
||||
for (const { name, script } of REMOVE_ALL_FIXTURES) {
|
||||
it(name, () => {
|
||||
const id = acornId(script);
|
||||
// Sanity: recast and acorn agree on the id for this tween.
|
||||
expect(parseGsapScript(script).animations[0]!.id).toBe(id);
|
||||
|
||||
const recastOut = removeAllRecast(script, id);
|
||||
const acornOut = removeAllAcorn(script, id);
|
||||
|
||||
const recastShape = shapeOf(recastOut);
|
||||
const acornShape = shapeOf(acornOut);
|
||||
|
||||
expect(acornShape.keyframes).toBeUndefined();
|
||||
expect(acornShape).toEqual(recastShape);
|
||||
});
|
||||
}
|
||||
|
||||
it("no-op when id not found", () => {
|
||||
const script = REMOVE_ALL_FIXTURES[0]!.script;
|
||||
expect(removeAllAcorn(script, "nonexistent-id")).toBe(script);
|
||||
});
|
||||
|
||||
it("no-op when tween has no keyframes", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#flat", { x: 100, duration: 1 }, 0);
|
||||
`;
|
||||
const id = acornId(script);
|
||||
expect(removeAllAcorn(script, id)).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
const CONVERT_FIXTURES: Array<{
|
||||
name: string;
|
||||
script: string;
|
||||
resolvedFromValues?: Record<string, number | string>;
|
||||
}> = [
|
||||
{
|
||||
name: "to() — builds 0%/100% keyframes with identity from",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 200, opacity: 0.5, duration: 1.5 }, 0);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "to() — with ease becomes easeEach + ease: none",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 100, duration: 1, ease: "power2.out" }, 0);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "from() — method renamed to to()",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.from("#card", { y: -50, opacity: 0, duration: 0.8 }, 0);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "fromTo() — method renamed, fromArg removed",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.fromTo("#text", { x: 0 }, { x: 300, duration: 2 }, 0);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "to() — with resolvedFromValues overrides 0%",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, duration: 1 }, 0);
|
||||
`,
|
||||
resolvedFromValues: { x: 42 },
|
||||
},
|
||||
];
|
||||
|
||||
describe("parity: convertToKeyframesFromScript (recast vs acorn)", () => {
|
||||
for (const { name, script, resolvedFromValues } of CONVERT_FIXTURES) {
|
||||
it(name, () => {
|
||||
const id = acornId(script);
|
||||
const recastOut = convertRecast(script, id, resolvedFromValues);
|
||||
const acornOut = convertAcorn(script, id, resolvedFromValues);
|
||||
|
||||
const recastShape = shapeOf(recastOut);
|
||||
const acornShape = shapeOf(acornOut);
|
||||
|
||||
expect(acornShape.keyframes).toBeDefined();
|
||||
expect(acornShape.method).toBe("to");
|
||||
expect(acornShape).toEqual(recastShape);
|
||||
});
|
||||
}
|
||||
|
||||
it("no-op when id not found", () => {
|
||||
const script = CONVERT_FIXTURES[0]!.script;
|
||||
expect(convertAcorn(script, "nonexistent-id")).toBe(script);
|
||||
});
|
||||
|
||||
it("no-op when tween already has keyframes", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1 }, 0);
|
||||
`;
|
||||
const id = acornId(script);
|
||||
expect(convertAcorn(script, id)).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
// ── materializeKeyframes parity ───────────────────────────────────────────────
|
||||
|
||||
const MATERIALIZE_KFS = [
|
||||
{ percentage: 0, properties: { x: 0, opacity: 1 } },
|
||||
{ percentage: 50, properties: { x: 150, opacity: 0.5 } },
|
||||
{ percentage: 100, properties: { x: 300, opacity: 0 } },
|
||||
];
|
||||
|
||||
const MATERIALIZE_FIXTURES: Array<{
|
||||
name: string;
|
||||
script: string;
|
||||
kfs: typeof MATERIALIZE_KFS;
|
||||
easeEach?: string;
|
||||
}> = [
|
||||
{
|
||||
name: "flat tween — adds keyframes property",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 300, duration: 2 }, 0);
|
||||
`,
|
||||
kfs: MATERIALIZE_KFS,
|
||||
},
|
||||
{
|
||||
name: "with easeEach",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { opacity: 0, duration: 1 }, 0);
|
||||
`,
|
||||
kfs: [
|
||||
{ percentage: 0, properties: { opacity: 1 } },
|
||||
{ percentage: 100, properties: { opacity: 0 } },
|
||||
],
|
||||
easeEach: "power2.inOut",
|
||||
},
|
||||
];
|
||||
|
||||
describe("parity: materializeKeyframesFromScript (recast vs acorn)", () => {
|
||||
for (const { name, script, kfs, easeEach } of MATERIALIZE_FIXTURES) {
|
||||
it(name, () => {
|
||||
const id = acornId(script);
|
||||
const recastOut = materializeRecast(script, id, kfs, easeEach);
|
||||
const acornOut = materializeAcorn(script, id, kfs, easeEach);
|
||||
const recastShape = shapeOf(recastOut);
|
||||
const acornShape = shapeOf(acornOut);
|
||||
expect(acornShape.keyframes).toBeDefined();
|
||||
expect(acornShape).toEqual(recastShape);
|
||||
});
|
||||
}
|
||||
|
||||
it("no-op when id not found", () => {
|
||||
const script = MATERIALIZE_FIXTURES[0]!.script;
|
||||
expect(materializeAcorn(script, "nope", MATERIALIZE_KFS)).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
// ── splitIntoPropertyGroups parity ────────────────────────────────────────────
|
||||
|
||||
function shapesOf(script: string) {
|
||||
return parseGsapScript(script).animations.map((a) => ({
|
||||
method: a.method,
|
||||
properties: a.properties,
|
||||
keyframes: a.keyframes,
|
||||
duration: a.duration,
|
||||
ease: a.ease,
|
||||
selector: a.targetSelector,
|
||||
propertyGroup: a.propertyGroup,
|
||||
}));
|
||||
}
|
||||
|
||||
const SPLIT_FIXTURES: Array<{ name: string; script: string }> = [
|
||||
{
|
||||
name: "flat mixed tween — splits into position + visual groups",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 100, y: 50, opacity: 0.5, duration: 1 }, 0);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "keyframed mixed tween — splits per group",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { keyframes: { "0%": { x: 0, opacity: 1 }, "100%": { x: 200, opacity: 0 } }, duration: 1 }, 0);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
describe("parity: splitIntoPropertyGroupsFromScript (recast vs acorn)", () => {
|
||||
for (const { name, script } of SPLIT_FIXTURES) {
|
||||
it(name, () => {
|
||||
const id = acornId(script);
|
||||
const { script: recastOut } = splitGroupsRecast(script, id);
|
||||
const { script: acornOut } = splitGroupsAcorn(script, id);
|
||||
const recastShapes = shapesOf(recastOut);
|
||||
const acornShapes = shapesOf(acornOut);
|
||||
expect(acornShapes).toHaveLength(recastShapes.length);
|
||||
expect(acornShapes.length).toBeGreaterThan(1);
|
||||
// Each produced group should match its counterpart by propertyGroup
|
||||
const sortByGroup = (arr: typeof recastShapes) =>
|
||||
arr.slice().sort((a, b) => (a.propertyGroup ?? "").localeCompare(b.propertyGroup ?? ""));
|
||||
expect(sortByGroup(acornShapes)).toEqual(sortByGroup(recastShapes));
|
||||
});
|
||||
}
|
||||
|
||||
it("no-op when id not found", () => {
|
||||
const script = SPLIT_FIXTURES[0]!.script;
|
||||
const { script: out, ids } = splitGroupsAcorn(script, "nope");
|
||||
expect(out).toBe(script);
|
||||
expect(ids).toEqual(["nope"]);
|
||||
});
|
||||
|
||||
it("no-op when single-group tween", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#el", { x: 100, y: 50, duration: 1 }, 0);
|
||||
`;
|
||||
const id = acornId(script);
|
||||
const { script: out } = splitGroupsAcorn(script, id);
|
||||
expect(out).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
// ── splitAnimationsInScript parity ────────────────────────────────────────────
|
||||
|
||||
function animShapesOf(script: string) {
|
||||
return parseGsapScript(script).animations.map((a) => ({
|
||||
method: a.method,
|
||||
selector: a.targetSelector,
|
||||
properties: a.properties,
|
||||
fromProperties: a.fromProperties,
|
||||
duration: a.duration,
|
||||
position: a.position,
|
||||
}));
|
||||
}
|
||||
|
||||
const SPLIT_ANIM_CASES: Array<{ name: string; script: string; opts: SplitAnimationsOptions }> = [
|
||||
{
|
||||
name: "all tweens before split — retargets none",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 100, duration: 1 }, 0);
|
||||
`,
|
||||
opts: {
|
||||
originalId: "hero",
|
||||
newId: "hero-2",
|
||||
splitTime: 2,
|
||||
elementStart: 0,
|
||||
elementDuration: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tween entirely after split — retargeted to newId",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { opacity: 0, duration: 0.5 }, 3);
|
||||
`,
|
||||
opts: {
|
||||
originalId: "hero",
|
||||
newId: "hero-2",
|
||||
splitTime: 2,
|
||||
elementStart: 0,
|
||||
elementDuration: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tween spanning split — truncated first half + fromTo second half",
|
||||
script: `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 200, duration: 4 }, 0);
|
||||
`,
|
||||
opts: {
|
||||
originalId: "hero",
|
||||
newId: "hero-2",
|
||||
splitTime: 2,
|
||||
elementStart: 0,
|
||||
elementDuration: 4,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("parity: splitAnimationsInScript (recast vs acorn)", () => {
|
||||
for (const { name, script, opts } of SPLIT_ANIM_CASES) {
|
||||
it(name, () => {
|
||||
const { script: recastOut } = splitAnimsRecast(script, opts);
|
||||
const { script: acornOut } = splitAnimsAcorn(script, opts);
|
||||
const sortByPos = (arr: ReturnType<typeof animShapesOf>) =>
|
||||
arr.slice().sort((a, b) => {
|
||||
const pa = typeof a.position === "number" ? a.position : 0;
|
||||
const pb = typeof b.position === "number" ? b.position : 0;
|
||||
return pa - pb || (a.selector ?? "").localeCompare(b.selector ?? "");
|
||||
});
|
||||
expect(sortByPos(animShapesOf(acornOut))).toEqual(sortByPos(animShapesOf(recastOut)));
|
||||
});
|
||||
}
|
||||
|
||||
it("no-op when originalId not found in script", () => {
|
||||
const script = SPLIT_ANIM_CASES[0]!.script;
|
||||
const opts: SplitAnimationsOptions = {
|
||||
originalId: "nonexistent",
|
||||
newId: "x",
|
||||
splitTime: 2,
|
||||
elementStart: 0,
|
||||
elementDuration: 4,
|
||||
};
|
||||
expect(splitAnimsAcorn(script, opts).script).toBe(script);
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,16 @@
|
||||
* pretty-printer churn. Consumes ParsedGsapAcornForWrite from gsapParserAcorn.ts.
|
||||
*/
|
||||
import MagicString from "magic-string";
|
||||
import { serializeValue, safeJsKey, type GsapAnimation } from "./gsapSerialize.js";
|
||||
import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapSerialize.js";
|
||||
import { resolveConversionProps } from "./gsapSerialize.js";
|
||||
import {
|
||||
parseGsapScriptAcornForWrite,
|
||||
type ParsedGsapAcornForWrite,
|
||||
type TweenCallInfo,
|
||||
} from "./gsapParserAcorn.js";
|
||||
import { classifyPropertyGroup } from "./gsapConstants.js";
|
||||
import type { PropertyGroupName } from "./gsapConstants.js";
|
||||
import type { SplitAnimationsOptions, SplitAnimationsResult } from "./gsapParser.js";
|
||||
import * as acornWalk from "acorn-walk";
|
||||
|
||||
// ── Code generation helpers ──────────────────────────────────────────────────
|
||||
@@ -127,6 +131,18 @@ function removeProp(ms: MagicString, propNode: any, editableProps: any[]): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize a vars record to an object-literal source: `{ k: v, ... }`. */
|
||||
function buildVarsObjectCode(record: Record<string, number | string>): string {
|
||||
const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}";
|
||||
}
|
||||
|
||||
/** Overwrite a tween call's vars ObjectExpression with freshly-built source. */
|
||||
function overwriteVarsArg(ms: MagicString, call: TweenCallInfo, objCode: string): void {
|
||||
if (!call.varsArg) return;
|
||||
ms.overwrite(call.varsArg.start, call.varsArg.end, objCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a property value if it exists, or append a new key: val before the
|
||||
* closing `}`. Call with the full ObjectExpression node.
|
||||
@@ -507,7 +523,7 @@ function percentageFromKey(key: string): number {
|
||||
|
||||
/** Serialize a final keyframe property record (number|string values) to code. */
|
||||
function recordToCode(record: Record<string, number | string>): string {
|
||||
const entries = Object.entries(record).map(([k, v]) => `${safeJsKey(k)}: ${serializeValue(v)}`);
|
||||
const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
|
||||
@@ -899,6 +915,347 @@ export function removePropertyFromAnimation(
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all keyframes from a tween, collapsing to a flat tween with one
|
||||
* keyframe's properties: the first for `from()`, the last otherwise (the
|
||||
* destination = the visible resting state).
|
||||
*/
|
||||
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
const kfs = target.animation.keyframes?.keyframes;
|
||||
if (!kfs || kfs.length === 0) return script;
|
||||
|
||||
const sorted = [...kfs].sort((a, b) => a.percentage - b.percentage);
|
||||
const collapse = target.call.method === "from" ? sorted[0]! : sorted[sorted.length - 1]!;
|
||||
|
||||
// Flat vars = existing top-level props, then collapse-keyframe props (these
|
||||
// win; skip the per-keyframe `ease` key), then duration/ease/extras. Drops
|
||||
// keyframes + easeEach by reconstruction.
|
||||
const flat: Record<string, number | string> = { ...target.animation.properties };
|
||||
for (const [k, v] of Object.entries(collapse.properties)) {
|
||||
if (k !== "ease") flat[k] = v;
|
||||
}
|
||||
if (target.animation.duration !== undefined) flat.duration = target.animation.duration;
|
||||
if (target.animation.ease) flat.ease = target.animation.ease;
|
||||
for (const [k, v] of Object.entries(target.animation.extras ?? {})) {
|
||||
if (typeof v === "number" || typeof v === "string") flat[k] = v;
|
||||
}
|
||||
|
||||
const ms = new MagicString(script);
|
||||
overwriteVarsArg(ms, target.call, buildVarsObjectCode(flat));
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
/** Build the full replacement vars object for a tween being converted to keyframes. */
|
||||
function buildKeyframesVarsCode(
|
||||
animation: GsapAnimation,
|
||||
fromProps: Record<string, number | string>,
|
||||
toProps: Record<string, number | string>,
|
||||
): string {
|
||||
const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
const easeEntry = animation.ease ? `, easeEach: ${JSON.stringify(animation.ease)}` : "";
|
||||
const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`;
|
||||
const parts: string[] = [`keyframes: ${kfCode}`];
|
||||
if (animation.duration !== undefined) parts.push(`duration: ${valueToCode(animation.duration)}`);
|
||||
if (animation.ease) parts.push(`ease: "none"`);
|
||||
for (const [k, v] of Object.entries(animation.extras ?? {})) {
|
||||
if (typeof v === "number" || typeof v === "string")
|
||||
parts.push(`${safeKey(k)}: ${valueToCode(v)}`);
|
||||
}
|
||||
return `{ ${parts.join(", ")} }`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a flat tween (to/from/fromTo) to percentage-keyframes format.
|
||||
* `resolvedFromValues` supplies the current DOM state: overrides the 0% endpoint
|
||||
* for `to()`, the 100% endpoint for `from()`, or merges into toProps for `fromTo()`.
|
||||
*/
|
||||
export function convertToKeyframesFromScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
const { animation, call } = target;
|
||||
if (animation.keyframes || call.method === "set") return script;
|
||||
|
||||
const { fromProps, toProps } = resolveConversionProps(animation, resolvedFromValues);
|
||||
const ms = new MagicString(script);
|
||||
|
||||
if (call.method === "from" || call.method === "fromTo") {
|
||||
ms.overwrite(call.node.callee.property.start, call.node.callee.property.end, "to");
|
||||
}
|
||||
if (call.method === "fromTo" && call.fromArg) {
|
||||
ms.remove(call.fromArg.start, call.varsArg.start);
|
||||
}
|
||||
overwriteVarsArg(ms, call, buildKeyframesVarsCode(animation, fromProps, toProps));
|
||||
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// ── Keyframe-object code builder ─────────────────────────────────────────────
|
||||
|
||||
/** Build a percentage-keyframes object literal: `{ "0%": { x: 0 }, "100%": { x: 100 } }`. */
|
||||
function buildKeyframeObjectCode(
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
easeEach?: string,
|
||||
): string {
|
||||
const entries = keyframes.map((kf) => {
|
||||
const props = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
|
||||
return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`;
|
||||
});
|
||||
if (easeEach) entries.push(`easeEach: ${JSON.stringify(easeEach)}`);
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
|
||||
// ── Materialize keyframes ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replace a dynamic or static keyframes expression with a fully-resolved
|
||||
* percentage-keyframes object. Called when a user first edits a dynamically-
|
||||
* generated keyframe in the studio so it becomes statically editable.
|
||||
*/
|
||||
export function materializeKeyframesFromScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
easeEach?: string,
|
||||
resolvedSelector?: string,
|
||||
): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
|
||||
const { call } = target;
|
||||
const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
|
||||
const kfObjCode = buildKeyframeObjectCode(sorted, easeEach);
|
||||
const ms = new MagicString(script);
|
||||
|
||||
if (resolvedSelector) {
|
||||
const selectorArg = call.node.arguments[0];
|
||||
if (selectorArg)
|
||||
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(resolvedSelector));
|
||||
}
|
||||
|
||||
const kfProp = findPropertyNode(call.varsArg, "keyframes");
|
||||
if (kfProp) {
|
||||
ms.overwrite(kfProp.value.start, kfProp.value.end, kfObjCode);
|
||||
} else if (call.varsArg?.type === "ObjectExpression") {
|
||||
const vars = call.varsArg;
|
||||
if (vars.properties.length > 0) {
|
||||
ms.prependLeft(vars.properties[0].start, `keyframes: ${kfObjCode}, `);
|
||||
} else {
|
||||
ms.appendLeft(vars.end - 1, `keyframes: ${kfObjCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
const eachProp = findPropertyNode(call.varsArg, "easeEach");
|
||||
if (eachProp) {
|
||||
const allProps = (call.varsArg.properties ?? []).filter((p: any) => isObjectProperty(p));
|
||||
removeProp(ms, eachProp, allProps);
|
||||
}
|
||||
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// ── Add animation with keyframes ──────────────────────────────────────────────
|
||||
|
||||
/** Insert a new keyframed `to()` call and return the new animation ID. */
|
||||
export function addAnimationWithKeyframesToScript(
|
||||
script: string,
|
||||
targetSelector: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
ease?: string,
|
||||
): { script: string; id: string } {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return { script, id: "" };
|
||||
const insertionPoint = findInsertionPoint(parsed);
|
||||
if (insertionPoint === null) return { script, id: "" };
|
||||
|
||||
const sorted = [...keyframes].sort((a, b) => a.percentage - b.percentage);
|
||||
const kfObjCode = buildKeyframeObjectCode(sorted);
|
||||
const varParts = [`keyframes: ${kfObjCode}`, `duration: ${valueToCode(duration)}`];
|
||||
if (ease) varParts.push(`ease: ${JSON.stringify(ease)}`);
|
||||
const stmtCode = `${parsed.timelineVar}.to(${JSON.stringify(targetSelector)}, { ${varParts.join(", ")} }, ${valueToCode(position)});`;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
ms.appendLeft(insertionPoint, "\n" + stmtCode);
|
||||
|
||||
const result = ms.toString();
|
||||
const reParsed = parseGsapScriptAcornForWrite(result);
|
||||
const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? "";
|
||||
return { script: result, id: newId };
|
||||
}
|
||||
|
||||
// ── Split into property groups ────────────────────────────────────────────────
|
||||
|
||||
function collectPropertyKeys(anim: GsapAnimation): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
if (anim.keyframes) {
|
||||
for (const kf of anim.keyframes.keyframes) {
|
||||
for (const k of Object.keys(kf.properties)) keys.add(k);
|
||||
}
|
||||
} else {
|
||||
for (const k of Object.keys(anim.properties)) keys.add(k);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function partitionPropertyGroups(keys: Set<string>): Map<PropertyGroupName, string[]> {
|
||||
const groups = new Map<PropertyGroupName, string[]>();
|
||||
for (const key of keys) {
|
||||
if (key === "transformOrigin") continue;
|
||||
const group = classifyPropertyGroup(key);
|
||||
let arr = groups.get(group);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
groups.set(group, arr);
|
||||
}
|
||||
arr.push(key);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function assignTransformOrigin(groupProps: Map<PropertyGroupName, string[]>): void {
|
||||
let largestGroup: PropertyGroupName | undefined;
|
||||
let largestCount = 0;
|
||||
for (const [group, props] of groupProps) {
|
||||
if (props.length > largestCount) {
|
||||
largestCount = props.length;
|
||||
largestGroup = group;
|
||||
}
|
||||
}
|
||||
if (largestGroup) groupProps.get(largestGroup)!.push("transformOrigin");
|
||||
}
|
||||
|
||||
function filterGroupKeyframes(
|
||||
kfs: GsapPercentageKeyframe[],
|
||||
propSet: Set<string>,
|
||||
): Array<{ percentage: number; properties: Record<string, number | string>; ease?: string }> {
|
||||
const result: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}> = [];
|
||||
for (const kf of kfs) {
|
||||
const filtered: Record<string, number | string> = {};
|
||||
for (const [k, v] of Object.entries(kf.properties)) {
|
||||
if (propSet.has(k)) filtered[k] = v;
|
||||
}
|
||||
if (Object.keys(filtered).length > 0) {
|
||||
result.push({
|
||||
percentage: kf.percentage,
|
||||
properties: filtered,
|
||||
...(kf.ease ? { ease: kf.ease } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function filterGroupProperties(
|
||||
properties: Record<string, number | string>,
|
||||
propSet: Set<string>,
|
||||
): Record<string, number | string> {
|
||||
const result: Record<string, number | string> = {};
|
||||
for (const [k, v] of Object.entries(properties)) {
|
||||
if (propSet.has(k)) result[k] = v;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function addGroupAnimToScript(
|
||||
script: string,
|
||||
anim: GsapAnimation,
|
||||
propSet: Set<string>,
|
||||
): { script: string; id: string } {
|
||||
if (anim.keyframes) {
|
||||
const groupKeyframes = filterGroupKeyframes(anim.keyframes.keyframes, propSet);
|
||||
if (groupKeyframes.length === 0) return { script, id: "" };
|
||||
const pos = typeof anim.position === "number" ? anim.position : 0;
|
||||
return addAnimationWithKeyframesToScript(
|
||||
script,
|
||||
anim.targetSelector,
|
||||
pos,
|
||||
anim.duration ?? 0.5,
|
||||
groupKeyframes,
|
||||
anim.keyframes.easeEach ?? anim.ease,
|
||||
);
|
||||
}
|
||||
const groupProperties = filterGroupProperties(anim.properties, propSet);
|
||||
if (Object.keys(groupProperties).length === 0) return { script, id: "" };
|
||||
const fromProperties =
|
||||
anim.method === "fromTo" && anim.fromProperties
|
||||
? filterGroupProperties(anim.fromProperties, propSet)
|
||||
: undefined;
|
||||
return addAnimationToScript(script, {
|
||||
targetSelector: anim.targetSelector,
|
||||
method: anim.method,
|
||||
position: anim.position,
|
||||
duration: anim.duration,
|
||||
ease: anim.ease,
|
||||
properties: groupProperties,
|
||||
fromProperties,
|
||||
extras: anim.extras,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a mixed-property tween into one tween per property group (position,
|
||||
* scale, visual, etc.) so each group can be edited independently.
|
||||
* Returns the updated script and the IDs of the newly-created tweens.
|
||||
*/
|
||||
export function splitIntoPropertyGroupsFromScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
): { script: string; ids: string[] } {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return { script, ids: [animationId] };
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return { script, ids: [animationId] };
|
||||
const { animation } = target;
|
||||
|
||||
const allPropKeys = collectPropertyKeys(animation);
|
||||
const groupProps = partitionPropertyGroups(allPropKeys);
|
||||
if (groupProps.size <= 1) return { script, ids: [animationId] };
|
||||
if (allPropKeys.has("transformOrigin")) assignTransformOrigin(groupProps);
|
||||
|
||||
let result = removeAnimationFromScript(script, animationId);
|
||||
for (const [, props] of groupProps) {
|
||||
const { script: next, id } = addGroupAnimToScript(result, animation, new Set(props));
|
||||
if (id) result = next;
|
||||
}
|
||||
|
||||
const reParsed = parseGsapScriptAcornForWrite(result);
|
||||
const newIds = (reParsed?.located ?? [])
|
||||
.filter((l) => l.animation.targetSelector === animation.targetSelector)
|
||||
.map((l) => l.id);
|
||||
return { script: result, ids: newIds };
|
||||
}
|
||||
|
||||
// ── Label write ops ───────────────────────────────────────────────────────────
|
||||
|
||||
export function addLabelToScript(script: string, name: string, position: number): string {
|
||||
@@ -946,3 +1303,169 @@ export function removeLabelFromScript(script: string, name: string): string {
|
||||
}
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// ── splitAnimationsInScript helpers ──────────────────────────────────────────
|
||||
|
||||
/** Overwrite the selector (first arg) of a tween call. */
|
||||
function updateAnimationSelectorInScript(
|
||||
script: string,
|
||||
animationId: string,
|
||||
newSelector: string,
|
||||
): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const target = parsed.located.find((l) => l.id === animationId);
|
||||
if (!target) return script;
|
||||
const selectorArg = target.call.node.arguments?.[0];
|
||||
if (!selectorArg) return script;
|
||||
const ms = new MagicString(script);
|
||||
ms.overwrite(selectorArg.start, selectorArg.end, JSON.stringify(newSelector));
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a `tl.set()` call immediately after the timeline declaration
|
||||
* (before existing tweens) to establish inherited state on a new element.
|
||||
*/
|
||||
function insertInheritedStateSetInScript(
|
||||
script: string,
|
||||
selector: string,
|
||||
position: number,
|
||||
properties: Record<string, number | string>,
|
||||
): string {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return script;
|
||||
const props = Object.entries(properties)
|
||||
.map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`)
|
||||
.join(", ");
|
||||
const code = `${parsed.timelineVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`;
|
||||
const ms = new MagicString(script);
|
||||
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
|
||||
if (tlDecl) {
|
||||
ms.appendLeft(tlDecl.end, "\n" + code);
|
||||
} else if (parsed.located.length > 0) {
|
||||
const firstCall = parsed.located[0]!.call;
|
||||
const exprStmt = findEnclosingExpressionStatement(firstCall.ancestors);
|
||||
const insertAt = exprStmt?.start ?? firstCall.node.start;
|
||||
ms.prependLeft(insertAt, code + "\n");
|
||||
} else {
|
||||
ms.append("\n" + code);
|
||||
}
|
||||
return ms.toString();
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function splitAnimationsInScript(
|
||||
script: string,
|
||||
opts: SplitAnimationsOptions,
|
||||
): SplitAnimationsResult {
|
||||
const parsed = parseGsapScriptAcornForWrite(script);
|
||||
if (!parsed) return { script, skippedSelectors: [] };
|
||||
|
||||
const originalSelector = `#${opts.originalId}`;
|
||||
const newSelector = `#${opts.newId}`;
|
||||
|
||||
const animations = parsed.located.map((l) => l.animation);
|
||||
const skippedSelectors: string[] = [];
|
||||
|
||||
for (const a of animations) {
|
||||
if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) {
|
||||
skippedSelectors.push(a.targetSelector);
|
||||
}
|
||||
}
|
||||
|
||||
const matching = 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: updateAnimationSelectorInScript mutates selectors which
|
||||
// can shift count-based ID suffixes for later animations.
|
||||
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 = updateAnimationSelectorInScript(result, anim.id, newSelector);
|
||||
} else if (animEnd > opts.splitTime) {
|
||||
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 {
|
||||
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 = updateAnimationSelectorInScript(result, anim.id, newSelector);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Spans the split — linear interpolation to compute mid-values.
|
||||
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 = insertInheritedStateSetInScript(result, newSelector, newElementStart, inheritedProps);
|
||||
}
|
||||
|
||||
return { script: result, skippedSelectors };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user