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:
Vance Ingalls
2026-06-17 16:51:28 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Miguel Ángel
parent ceb815c318
commit a746db6017
9 changed files with 1397 additions and 75 deletions
+1 -56
View File
@@ -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);
});
});
+525 -2
View File
@@ -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 };
}
+214
View File
@@ -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", () => {
+123 -11
View File
@@ -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)
+26
View File
@@ -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(
+25
View File
@@ -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,