mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +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 };
|
||||
}
|
||||
|
||||
@@ -510,6 +510,220 @@ describe("removeGsapKeyframe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAllKeyframes", () => {
|
||||
it("collapses keyframed to() tween to last keyframe's props", () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: animId });
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).not.toContain("keyframes");
|
||||
expect(newScript).not.toContain('"50%"');
|
||||
expect(newScript).toContain("opacity: 1");
|
||||
});
|
||||
|
||||
it("no-op (empty patch) when animation id not found", () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: "nope" });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no-op when tween has no keyframes", () => {
|
||||
const parsed = fresh(GSAP_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: animId });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── convertToKeyframes ────────────────────────────────────────────────────────
|
||||
|
||||
describe("convertToKeyframes", () => {
|
||||
// GSAP_SCRIPT: position 0.2 → id suffix "200"; opacity = visual group
|
||||
it("converts flat to() tween to percentage keyframes", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, { type: "convertToKeyframes", animationId: TWEEN_ANIM_ID });
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("keyframes");
|
||||
expect(newScript).toContain('"0%"');
|
||||
expect(newScript).toContain('"100%"');
|
||||
expect(newScript).toContain("easeEach");
|
||||
expect(newScript).toContain('ease: "none"');
|
||||
});
|
||||
|
||||
it("passes resolvedFromValues into 0% endpoint", () => {
|
||||
const script = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 200, duration: 1 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const parsed = fresh(script);
|
||||
// position 0 → "0"; x = position group
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-position`;
|
||||
const result = applyOp(parsed, {
|
||||
type: "convertToKeyframes",
|
||||
animationId: animId,
|
||||
resolvedFromValues: { x: 42 },
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("42");
|
||||
});
|
||||
|
||||
it("no-op when animation already has keyframes", () => {
|
||||
const parsed = fresh(KF_SCRIPT);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
|
||||
const result = applyOp(parsed, { type: "convertToKeyframes", animationId: animId });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no-op when animation id not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, { type: "convertToKeyframes", animationId: "nope" });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── materializeKeyframes ─────────────────────────────────────────────────────
|
||||
|
||||
describe("materializeKeyframes", () => {
|
||||
it("adds keyframes property to flat tween", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "materializeKeyframes",
|
||||
animationId: TWEEN_ANIM_ID,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("keyframes");
|
||||
expect(newScript).toContain('"0%"');
|
||||
expect(newScript).toContain('"100%"');
|
||||
});
|
||||
|
||||
it("injects easeEach into keyframes object", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "materializeKeyframes",
|
||||
animationId: TWEEN_ANIM_ID,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { opacity: 0 } },
|
||||
{ percentage: 100, properties: { opacity: 1 } },
|
||||
],
|
||||
easeEach: "power2.out",
|
||||
});
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("easeEach");
|
||||
expect(newScript).toContain("power2.out");
|
||||
});
|
||||
|
||||
it("no-op when animation id not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, {
|
||||
type: "materializeKeyframes",
|
||||
animationId: "nope",
|
||||
keyframes: [{ percentage: 0, properties: { opacity: 0 } }],
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── splitIntoPropertyGroups ──────────────────────────────────────────────────
|
||||
|
||||
describe("splitIntoPropertyGroups", () => {
|
||||
it("splits mixed tween into multiple group tweens", () => {
|
||||
const script = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, opacity: 0.5, duration: 1 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const parsed = fresh(script);
|
||||
// mixed tween has no propertyGroup → no group suffix in id
|
||||
const animId = `[data-hf-id="hf-box"]-to-0`;
|
||||
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId });
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
// x is position group, opacity is visual group — expect 2 tweens
|
||||
const toCount = (newScript.match(/\.to\(/g) ?? []).length;
|
||||
expect(toCount).toBe(2);
|
||||
});
|
||||
|
||||
it("no-op when animation id not found", () => {
|
||||
const parsed = fresh();
|
||||
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: "nope" });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("no-op when tween has only one property group", () => {
|
||||
// x + y = same "position" group → nothing to split
|
||||
const script = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, y: 50, duration: 1 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const parsed = fresh(script);
|
||||
const animId = `[data-hf-id="hf-box"]-to-0-position`;
|
||||
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId });
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── splitAnimations ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("splitAnimations", () => {
|
||||
const SPLIT_SCRIPT = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { x: 200, duration: 4 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
function freshSplit() {
|
||||
return parseMutable(`<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<div data-hf-id="hf-hero"></div>
|
||||
<script>${SPLIT_SCRIPT}</script>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
it("retargets post-split tween to newId", () => {
|
||||
const parsed = freshSplit();
|
||||
const result = applyOp(parsed, {
|
||||
type: "splitAnimations",
|
||||
originalId: "hero",
|
||||
newId: "hero-2",
|
||||
splitTime: 3,
|
||||
elementStart: 0,
|
||||
elementDuration: 4,
|
||||
});
|
||||
expect(result.forward).toHaveLength(1);
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain("#hero-2");
|
||||
});
|
||||
|
||||
it("spanning tween produces fromTo on new element", () => {
|
||||
const parsed = freshSplit();
|
||||
const result = applyOp(parsed, {
|
||||
type: "splitAnimations",
|
||||
originalId: "hero",
|
||||
newId: "hero-2",
|
||||
splitTime: 2,
|
||||
elementStart: 0,
|
||||
elementDuration: 4,
|
||||
});
|
||||
const newScript = String(result.forward[0]?.value ?? "");
|
||||
expect(newScript).toContain(".fromTo(");
|
||||
expect(newScript).toContain("#hero-2");
|
||||
});
|
||||
|
||||
it("no-op when originalId not found", () => {
|
||||
const parsed = freshSplit();
|
||||
const result = applyOp(parsed, {
|
||||
type: "splitAnimations",
|
||||
originalId: "nonexistent",
|
||||
newId: "x",
|
||||
splitTime: 2,
|
||||
elementStart: 0,
|
||||
elementDuration: 4,
|
||||
});
|
||||
expect(result.forward).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Label ops ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("addLabel", () => {
|
||||
|
||||
@@ -50,6 +50,11 @@ import {
|
||||
removePropertyFromAnimation,
|
||||
addKeyframeToScript,
|
||||
removeKeyframeFromScript,
|
||||
removeAllKeyframesFromScript,
|
||||
convertToKeyframesFromScript,
|
||||
materializeKeyframesFromScript,
|
||||
splitIntoPropertyGroupsFromScript,
|
||||
splitAnimationsInScript,
|
||||
updateKeyframeInScript,
|
||||
addLabelToScript,
|
||||
removeLabelFromScript,
|
||||
@@ -146,18 +151,8 @@ function dispatchRemoveGsapKeyframe(
|
||||
: handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex);
|
||||
}
|
||||
|
||||
function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
|
||||
function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
|
||||
switch (op.type) {
|
||||
case "addGsapTween":
|
||||
return handleAddGsapTween(parsed, op.target, op.tween);
|
||||
case "setGsapTween":
|
||||
return handleSetGsapTween(parsed, op.animationId, op.properties);
|
||||
case "removeGsapProperty":
|
||||
return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from);
|
||||
case "removeGsapTween":
|
||||
return handleRemoveGsapTween(parsed, op.animationId);
|
||||
case "deleteAllForSelector":
|
||||
return handleDeleteAllForSelector(parsed, op.selector);
|
||||
case "setGsapKeyframe":
|
||||
return handleSetGsapKeyframe(
|
||||
parsed,
|
||||
@@ -171,6 +166,41 @@ function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undef
|
||||
return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value);
|
||||
case "removeGsapKeyframe":
|
||||
return dispatchRemoveGsapKeyframe(parsed, op);
|
||||
case "removeAllKeyframes":
|
||||
return handleRemoveAllKeyframes(parsed, op.animationId);
|
||||
case "convertToKeyframes":
|
||||
return handleConvertToKeyframes(parsed, op.animationId, op.resolvedFromValues);
|
||||
case "materializeKeyframes":
|
||||
return handleMaterializeKeyframes(
|
||||
parsed,
|
||||
op.animationId,
|
||||
op.keyframes,
|
||||
op.easeEach,
|
||||
op.resolvedSelector,
|
||||
);
|
||||
case "splitIntoPropertyGroups":
|
||||
return handleSplitIntoPropertyGroups(parsed, op.animationId);
|
||||
case "splitAnimations":
|
||||
return handleSplitAnimations(parsed, op);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
|
||||
const kf = applyGsapKeyframeOp(parsed, op);
|
||||
if (kf !== undefined) return kf;
|
||||
switch (op.type) {
|
||||
case "addGsapTween":
|
||||
return handleAddGsapTween(parsed, op.target, op.tween);
|
||||
case "setGsapTween":
|
||||
return handleSetGsapTween(parsed, op.animationId, op.properties);
|
||||
case "removeGsapProperty":
|
||||
return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from);
|
||||
case "removeGsapTween":
|
||||
return handleRemoveGsapTween(parsed, op.animationId);
|
||||
case "deleteAllForSelector":
|
||||
return handleDeleteAllForSelector(parsed, op.selector);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -737,6 +767,83 @@ function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): Mut
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleRemoveAllKeyframes(parsed: ParsedDocument, animationId: string): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const newScript = removeAllKeyframesFromScript(script, animationId);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleConvertToKeyframes(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const newScript = convertToKeyframesFromScript(script, animationId, resolvedFromValues);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleMaterializeKeyframes(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>,
|
||||
easeEach?: string,
|
||||
resolvedSelector?: string,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const newScript = materializeKeyframesFromScript(
|
||||
script,
|
||||
animationId,
|
||||
keyframes,
|
||||
easeEach,
|
||||
resolvedSelector,
|
||||
);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleSplitIntoPropertyGroups(
|
||||
parsed: ParsedDocument,
|
||||
animationId: string,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const { script: newScript } = splitIntoPropertyGroupsFromScript(script, animationId);
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleSplitAnimations(
|
||||
parsed: ParsedDocument,
|
||||
op: Extract<EditOp, { type: "splitAnimations" }>,
|
||||
): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
const { script: newScript } = splitAnimationsInScript(script, {
|
||||
originalId: op.originalId,
|
||||
newId: op.newId,
|
||||
splitTime: op.splitTime,
|
||||
elementStart: op.elementStart,
|
||||
elementDuration: op.elementDuration,
|
||||
});
|
||||
if (newScript === script) return EMPTY;
|
||||
setGsapScript(parsed.document, newScript);
|
||||
return gsapScriptChange(script, newScript);
|
||||
}
|
||||
|
||||
function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult {
|
||||
const script = getGsapScript(parsed.document);
|
||||
if (!script) return EMPTY;
|
||||
@@ -954,6 +1061,11 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
|
||||
case "removeGsapKeyframe":
|
||||
case "removeGsapProperty":
|
||||
case "removeGsapTween":
|
||||
case "removeAllKeyframes":
|
||||
case "convertToKeyframes":
|
||||
case "materializeKeyframes":
|
||||
case "splitIntoPropertyGroups":
|
||||
case "splitAnimations":
|
||||
case "deleteAllForSelector":
|
||||
case "removeLabel":
|
||||
if (getGsapScript(parsed.document) === null)
|
||||
|
||||
@@ -105,7 +105,33 @@ export type EditOp =
|
||||
| { type: "removeGsapKeyframe"; animationId: string; percentage: number }
|
||||
| { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean }
|
||||
| { type: "removeGsapTween"; animationId: string }
|
||||
| { type: "removeAllKeyframes"; animationId: string }
|
||||
| {
|
||||
type: "convertToKeyframes";
|
||||
animationId: string;
|
||||
resolvedFromValues?: Record<string, number | string>;
|
||||
}
|
||||
| { type: "deleteAllForSelector"; selector: string }
|
||||
| {
|
||||
type: "materializeKeyframes";
|
||||
animationId: string;
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>;
|
||||
easeEach?: string;
|
||||
resolvedSelector?: string;
|
||||
}
|
||||
| { type: "splitIntoPropertyGroups"; animationId: string }
|
||||
| {
|
||||
type: "splitAnimations";
|
||||
originalId: string;
|
||||
newId: string;
|
||||
splitTime: number;
|
||||
elementStart: number;
|
||||
elementDuration: number;
|
||||
}
|
||||
| { type: "addLabel"; name: string; position: number }
|
||||
| { type: "removeLabel"; name: string };
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { executeOptimistic } from "../utils/optimisticUpdate";
|
||||
import {
|
||||
sdkGsapKeyframePersist,
|
||||
sdkGsapRemoveKeyframePersist,
|
||||
sdkGsapRemoveAllKeyframesPersist,
|
||||
sdkGsapConvertToKeyframesPersist,
|
||||
type CutoverDeps,
|
||||
} from "../utils/sdkCutover";
|
||||
import type { KeyframeCacheEntry } from "../player/store/playerStore";
|
||||
@@ -191,31 +193,52 @@ export function useGsapKeyframeOps({
|
||||
);
|
||||
|
||||
const convertToKeyframes = useCallback(
|
||||
(
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
) => {
|
||||
// ponytail: no SDK equivalent; convertToKeyframes stays server-authoritative (T6f scope)
|
||||
if (sdkSession && sdkDeps) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapConvertToKeyframesPersist(
|
||||
targetPath,
|
||||
animationId,
|
||||
resolvedFromValues,
|
||||
sdkSession,
|
||||
sdkDeps,
|
||||
{ label: "Convert to keyframes" },
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
return commitMutation(
|
||||
selection,
|
||||
{ type: "convert-to-keyframes", animationId, resolvedFromValues },
|
||||
{ label: "Convert to keyframes" },
|
||||
);
|
||||
},
|
||||
[commitMutation],
|
||||
[commitMutation, activeCompPath, sdkSession, sdkDeps],
|
||||
);
|
||||
|
||||
const removeAllKeyframes = useCallback(
|
||||
(selection: DomEditSelection, animationId: string) => {
|
||||
// ponytail: no SDK equivalent for remove-all-keyframes; stays server-authoritative
|
||||
async (selection: DomEditSelection, animationId: string) => {
|
||||
if (sdkSession && sdkDeps) {
|
||||
const targetPath = selection.sourceFile || activeCompPath || "index.html";
|
||||
const handled = await sdkGsapRemoveAllKeyframesPersist(
|
||||
targetPath,
|
||||
animationId,
|
||||
sdkSession,
|
||||
sdkDeps,
|
||||
{ label: "Remove all keyframes" },
|
||||
);
|
||||
if (handled) return;
|
||||
}
|
||||
commitMutationSafely(
|
||||
selection,
|
||||
{ type: "remove-all-keyframes", animationId },
|
||||
{ label: "Remove all keyframes", softReload: true },
|
||||
);
|
||||
},
|
||||
[commitMutationSafely],
|
||||
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
|
||||
);
|
||||
|
||||
const commitKeyframeAtTime = useCallback(
|
||||
|
||||
@@ -293,6 +293,31 @@ export function sdkGsapDeleteAllForSelectorPersist(
|
||||
);
|
||||
}
|
||||
|
||||
export function sdkGsapRemoveAllKeyframesPersist(
|
||||
targetPath: string,
|
||||
animationId: string,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
|
||||
s.dispatch({ type: "removeAllKeyframes", animationId }),
|
||||
);
|
||||
}
|
||||
|
||||
export function sdkGsapConvertToKeyframesPersist(
|
||||
targetPath: string,
|
||||
animationId: string,
|
||||
resolvedFromValues: Record<string, number | string> | undefined,
|
||||
sdkSession: Composition | null | undefined,
|
||||
deps: CutoverDeps,
|
||||
options?: CutoverOptions,
|
||||
): Promise<boolean> {
|
||||
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
|
||||
s.dispatch({ type: "convertToKeyframes", animationId, resolvedFromValues }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function sdkDeletePersist(
|
||||
hfId: string,
|
||||
originalContent: string,
|
||||
|
||||
Reference in New Issue
Block a user