Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/parsers/src/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3056,6 +3056,29 @@ describe("single position write per element (consolidation)", () => {
expect(out).not.toMatch(/gsap\.set\("#box",\s*\{\s*x:/);
expect(parseGsapScript(out).animations.some((a) => "opacity" in a.properties)).toBe(true);
});

it("remove-all-keyframes collapses synthetic motionPath keyframes to a held position", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", {
motionPath: { path: [{ x: 0, y: 10 }, { x: 200, y: 40 }] },
duration: 2,
ease: "power1.inOut",
}, 1);
`;
const motionPathTween = parseGsapScript(script).animations.find(
(animation) => animation.arcPath?.enabled,
)!;

const out = removeAllKeyframesFromScript(script, motionPathTween.id);
const kept = posWritesFor(out, "#box");

expect(out).not.toBe(script);
expect(kept).toHaveLength(1);
expect(kept[0].arcPath).toBeUndefined();
expect(kept[0].duration).toBe(0);
expect(kept[0].properties).toMatchObject({ x: 200, y: 40 });
});
});

describe("recast writer never doubles vars keys", () => {
Expand Down
77 changes: 53 additions & 24 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
serializeValue as valueToCode,
safeJsKey as safeKey,
resolveConversionProps,
mergePercentageKeyframes,
} from "./gsapSerialize";

export type {
Expand Down Expand Up @@ -1967,7 +1968,7 @@ function buildKeyframeObjectCode(
}>,
options?: { easeEach?: string },
): string {
const entries = keyframes.map((kf) => {
const entries = mergePercentageKeyframes(keyframes).map((kf) => {
const props = keyframePropsToCode(kf);
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
if (kf.auto) props.push(`_auto: 1`);
Expand Down Expand Up @@ -2361,10 +2362,9 @@ export function removeKeyframeFromScript(
/**
* Retime a keyframe: move the keyframe at `fromPercentage` to `toPercentage`,
* PRESERVING its properties and per-keyframe ease (the Studio "Move to Playhead"
* gesture). Re-sorts keyframes by percentage. If a keyframe already exists at
* `toPercentage`, it is overwritten by the moved one (no duplicate). No-op when
* the animation/keyframe isn't found, the tween has no object-form keyframes, or
* the move resolves onto the same keyframe. Acorn twin: moveKeyframeInScript.
* gesture). Re-sorts keyframes by percentage. No-op when the animation/keyframe
* isn't found, the tween has no object-form keyframes, the move resolves onto the
* same keyframe, or the destination is occupied. Acorn twin: moveKeyframeInScript.
*/
export function moveKeyframeInScript(
script: string,
Expand All @@ -2388,19 +2388,19 @@ export function moveKeyframeInScript(
// retime, because findKeyframePropByPct resolves the destination back onto the
// from-keyframe — so a deliberate 1% drag committed nothing. Acorn twin too.
if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return script;
// A destination keyframe is only a real collision (overwrite) when it's a
// DIFFERENT keyframe; resolving back onto the from-keyframe is not.
// Never overwrite another authored keyframe. Resolving the destination back
// onto the source keyframe is not a collision (the tolerance is intentionally
// wider than MOVE_NOOP_EPSILON_PCT).
const dest = findKeyframePropByPct(kfNode, toPercentage);
const collision = dest && dest.prop !== match.prop ? dest : null;
if (dest && dest.prop !== match.prop) return script;

// Reuse each keyframe's value node verbatim (preserves properties +
// per-keyframe ease + _auto). Drop the moved keyframe (and any destination
// keyframe it overwrites), re-key the moved value to toPercentage, then re-sort.
// per-keyframe ease + _auto). Drop the moved keyframe, re-key its value to
// toPercentage, then re-sort.
const movedValue = match.prop.value;
const entries: Array<{ pct: number; value: AstNode }> = [];
for (const prop of filterPercentageProps(kfNode)) {
if (prop === match.prop) continue;
if (collision && prop === collision.prop) continue;
const pct = percentageFromKey(propKeyName(prop) ?? "");
if (Number.isNaN(pct)) continue;
entries.push({ pct, value: prop.value });
Expand Down Expand Up @@ -2452,7 +2452,11 @@ export function resizeKeyframedTweenInScript(
match.prop.key = parseExpr(`{ ${JSON.stringify(`${to}%`)}: 0 }`).properties[0].key;
}

applyUpdatesToCall(loc.target.call, { position: newPosition, duration: newDuration });
const durationAuthored = findPropertyNode(loc.target.call.varsArg, "duration") !== undefined;
applyUpdatesToCall(loc.target.call, {
position: newPosition,
...(durationAuthored ? { duration: newDuration } : {}),
});
return recast.print(loc.parsed.ast).code;
}

Expand Down Expand Up @@ -2501,6 +2505,21 @@ export function updateKeyframeInScript(
return recast.print(arrLoc.parsed.ast).code;
}

// motionPath waypoints are exposed to Studio as synthetic keyframes, while
// their source representation owns one tween-level ease. Route an ease-only
// segment edit to that authored owner instead of silently no-oping because a
// literal keyframes block does not exist.
if (
arrLoc &&
!arrVal &&
ease !== undefined &&
Object.keys(properties).length === 0 &&
findPropertyNode(arrLoc.target.call.varsArg, "motionPath")
) {
applyUpdatesToCall(arrLoc.target.call, { ease });
return recast.print(arrLoc.parsed.ast).code;
}

const ctx = locateKeyframeCtx(script, animationId, percentage);
if (!ctx) return script;
const { loc, kfNode } = ctx;
Expand Down Expand Up @@ -2640,19 +2659,29 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
const kfNode =
findKeyframesObjectNode(loc.target.call.varsArg) ??
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;

const kfEntries = filterPercentageProps(kfNode)
.map((p: AstNode) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
.filter((e) => !Number.isNaN(e.pct))
.sort((a, b) => a.pct - b.pct);
if (kfEntries.length === 0) return script;

// For to()/set(): collapse to last keyframe (the destination = visible state).
// For from(): collapse to first keyframe (the starting state).
const method = loc.target.call.method;
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
let record: Record<string, unknown>;
if (kfNode) {
const kfEntries = filterPercentageProps(kfNode)
.map((p: AstNode) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
.filter((e) => !Number.isNaN(e.pct))
.sort((a, b) => a.pct - b.pct);
if (kfEntries.length === 0) return script;
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
} else {
// motionPath waypoints are exposed to Studio as synthetic keyframes. They
// have no literal `keyframes` node, so collapse the parsed endpoint and
// remove the authored path instead of silently returning the original file.
const synthetic = loc.target.animation.arcPath?.enabled
? loc.target.animation.keyframes?.keyframes
: undefined;
if (!synthetic?.length) return script;
const sorted = [...synthetic].sort((a, b) => a.percentage - b.percentage);
record = (method === "from" ? sorted[0] : sorted[sorted.length - 1])!.properties;
removeVarsKey(loc.target.call.varsArg, "motionPath");
}

collapseKeyframesToFlat(loc.target.call.varsArg, record);
// Removing ALL keyframes HOLDS the element statically — collapse to a
// zero-duration immediateRender tween (a `gsap.set` equivalent), dropping the
Expand Down
29 changes: 29 additions & 0 deletions packages/parsers/src/gsapSerialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,35 @@ export interface GsapPercentageKeyframe {
ease?: string;
}

export interface WritableGsapPercentageKeyframe extends GsapPercentageKeyframe {
auto?: boolean;
}

/**
* Collapse duplicate percentage entries before serializing an object literal.
* Matches addKeyframeToScript's merge contract: later properties/ease win while
* unrelated authored properties, an earlier ease, and endpoint auto markers survive.
*/
export function mergePercentageKeyframes(
keyframes: readonly WritableGsapPercentageKeyframe[],
): WritableGsapPercentageKeyframe[] {
const byPercentage = new Map<number, WritableGsapPercentageKeyframe>();
for (const keyframe of keyframes) {
const existing = byPercentage.get(keyframe.percentage);
if (!existing) {
byPercentage.set(keyframe.percentage, {
...keyframe,
properties: { ...keyframe.properties },
});
continue;
}
existing.properties = { ...existing.properties, ...keyframe.properties };
if (keyframe.ease !== undefined) existing.ease = keyframe.ease;
if (keyframe.auto) existing.auto = true;
}
return [...byPercentage.values()];
}

export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array";

export interface GsapKeyframesData {
Expand Down
90 changes: 78 additions & 12 deletions packages/parsers/src/gsapWriter.parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,10 @@ const UPD_ARRAY_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { keyframes: [{ x: 0, y: 0 }, { x: 50, y: 80 }, { x: 100, y: 0 }], duration: 1 }, 0.2);
`;
const UPD_MOTION_PATH_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#title", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 40 }] }, duration: 1, ease: "power1.inOut" }, 0.2);
`;

describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
function expectParity(
Expand Down Expand Up @@ -1100,6 +1104,17 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
);
});

it("updates the authored tween ease for synthetic motion-path keyframes", () => {
const id = acornId(UPD_MOTION_PATH_SCRIPT);
const ease = "spring(0.42)";
const acorn = updateKeyframeAcorn(UPD_MOTION_PATH_SCRIPT, id, 100, {}, ease);
const recast = updateKeyframeRecast(UPD_MOTION_PATH_SCRIPT, id, 100, {}, ease);

expect(modelOf(acorn)).toEqual(modelOf(recast));
expect(parseGsapScript(acorn).animations[0]?.ease).toBe(ease);
expect(parseGsapScript(recast).animations[0]?.ease).toBe(ease);
});

// KNOWN DIVERGENCE (acorn-array bug, follow-up — NOT a test artifact):
// For PARTIAL props on ARRAY-form keyframes the two writers disagree. recast's
// array branch (gsapParser.updateKeyframeInScript) does a whole-value REPLACE
Expand All @@ -1124,8 +1139,9 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => {
// ── moveKeyframeInScript (retime: preserve value + ease) ─────────────────────
// "Move to Playhead" retimes a keyframe in time, keeping its properties and
// per-keyframe ease. The moved keyframe must vanish from the source percentage
// and reappear (with identical value + ease) at the destination; a destination
// collision is overwritten, not duplicated. recast and acorn must agree.
// and reappear (with identical value + ease) at the destination. An occupied
// destination rejects the retime without mutating authored data. Both writers
// must agree.
const MOVE_KF_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { x: 0 }, "50%": { x: 100, opacity: 0.5, ease: "power2.in" }, "100%": { x: 200 } }, duration: 1 }, 0.2);
Expand All @@ -1145,16 +1161,10 @@ describe("moveKeyframeInScript: retime preserves value + ease (acorn) ", () => {
expect(pcts).not.toContain(50);
});

it("overwrites the destination keyframe on collision (no duplicate)", () => {
it("rejects an occupied destination without mutating either writer", () => {
const id = acornId(MOVE_KF_SCRIPT);
const out = moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 100);
const kfs = shapeOf(out).keyframes?.keyframes ?? [];
const pcts = kfs.map((k) => k.percentage);
expect(pcts).toEqual([0, 100]);
const dest = kfs.find((k) => k.percentage === 100)!;
// The moved keyframe's value + ease replaced the old 100% { x: 200 }.
expect(dest.properties).toEqual({ x: 100, opacity: 0.5 });
expect(dest.ease).toBe("power2.in");
expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 100)).toBe(MOVE_KF_SCRIPT);
expect(moveKeyframeRecast(MOVE_KF_SCRIPT, id, 50, 100)).toBe(MOVE_KF_SCRIPT);
});

it("no-ops only for a negligible move (below the drag epsilon)", () => {
Expand Down Expand Up @@ -1203,7 +1213,7 @@ describe("parity: moveKeyframeInScript (recast vs acorn)", () => {
expectParity(MOVE_KF_SCRIPT, 50, 10);
});

it("retime onto an existing percentage (collision overwrite)", () => {
it("reject an occupied destination", () => {
expectParity(MOVE_KF_SCRIPT, 50, 100);
});

Expand Down Expand Up @@ -1242,6 +1252,12 @@ describe("moveKeyframeInScript: array-form keyframes (recast + acorn parity)", (
modelOf(moveKeyframeRecast(KF_ADD_ARRAY_SCRIPT, id, 50, 75)),
);
});

it("leaves array-form source untouched when the destination is occupied", () => {
const id = acornId(KF_ADD_ARRAY_SCRIPT);
expect(moveKeyframeAcorn(KF_ADD_ARRAY_SCRIPT, id, 50, 100)).toBe(KF_ADD_ARRAY_SCRIPT);
expect(moveKeyframeRecast(KF_ADD_ARRAY_SCRIPT, id, 50, 100)).toBe(KF_ADD_ARRAY_SCRIPT);
});
});

// ── resizeKeyframedTweenInScript (boundary drag: re-key + grow window) ────────
Expand All @@ -1253,6 +1269,10 @@ const RESIZE_KF_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0, _auto: 1 }, "50%": { opacity: 0.5, ease: "power2.in" }, "100%": { opacity: 1, _auto: 1 }, easeEach: "power1.inOut" }, duration: 1, ease: "power3.out" }, 0.2);
`;
const RESIZE_KF_NO_DURATION_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.5, ease: "power2.in" }, "100%": { opacity: 1 } }, ease: "power3.out" }, 0.2);
`;
// Window [0.2, 1.2]; drag the last keyframe (abs 1.2) out to abs 2.2 → [0.2, 2.2].
// abs 0.2/0.7/2.2 over the new 2.0s window → 0 / 25 / 100.
const RESIZE_REMAP = [
Expand Down Expand Up @@ -1290,6 +1310,18 @@ describe("resizeKeyframedTweenInScript: preserves author intent (acorn + recast)
it(`${label}: no-ops on unknown id`, () => {
expect(resize(RESIZE_KF_SCRIPT, "bad-id", 0.2, 2, RESIZE_REMAP)).toBe(RESIZE_KF_SCRIPT);
});

it(`${label}: preserves an unauthored duration`, () => {
const id = acornId(RESIZE_KF_NO_DURATION_SCRIPT);
const out = resize(RESIZE_KF_NO_DURATION_SCRIPT, id, 0.4, 2, RESIZE_REMAP);
expect(out).not.toMatch(/\bduration\s*:/);
const shape = shapeOf(out);
expect(shape.duration).toBeUndefined();
expect(parseGsapScript(out).animations[0]!.position).toBeCloseTo(0.4, 5);
expect(shape.keyframes?.keyframes.map((keyframe) => keyframe.percentage)).toEqual([
0, 25, 100,
]);
});
}

it("parity: both writers reparse to the same model", () => {
Expand All @@ -1298,6 +1330,15 @@ describe("resizeKeyframedTweenInScript: preserves author intent (acorn + recast)
modelOf(resizeKeyframedTweenRecast(RESIZE_KF_SCRIPT, id, 0.2, 2, RESIZE_REMAP)),
);
});

it("parity: both writers preserve duration absence", () => {
const id = acornId(RESIZE_KF_NO_DURATION_SCRIPT);
expect(
modelOf(resizeKeyframedTweenAcorn(RESIZE_KF_NO_DURATION_SCRIPT, id, 0.4, 2, RESIZE_REMAP)),
).toEqual(
modelOf(resizeKeyframedTweenRecast(RESIZE_KF_NO_DURATION_SCRIPT, id, 0.4, 2, RESIZE_REMAP)),
);
});
});

// Regression: same array-form gap as moveKeyframeInScript above — boundary
Expand Down Expand Up @@ -1375,6 +1416,31 @@ function lastModelOf(script: string) {
return arr[arr.length - 1];
}

describe("keyframe object builders: duplicate percentage merge", () => {
const duplicateKeyframes = [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 }, ease: "power1.in" },
{ percentage: 50, properties: { x: 120, opacity: 0.5 }, auto: true },
{ percentage: 100, properties: { x: 200 } },
];

for (const [label, add] of [
["acorn", addWithKfAcorn],
["recast", addWithKfRecast],
] as const) {
it(`${label}: emits one merged key and preserves authored properties and ease`, () => {
const out = add(ADD_WITH_KF_BASE, "#hero", 0, 1, duplicateKeyframes).script;
expect(out.match(/["']50%["']\s*:/g)).toHaveLength(1);
const atFifty = lastModelOf(out)?.keyframes?.keyframes.filter(
(keyframe) => keyframe.percentage === 50,
);
expect(atFifty).toHaveLength(1);
expect(atFifty?.[0]?.properties).toEqual({ x: 120, opacity: 0.5, _auto: 1 });
expect(atFifty?.[0]?.ease).toBe("power1.in");
});
}
});

// NOTE (WS-3.F): recast is retired, so `recast` here is an alias of the acorn
// writer and the historical `toEqual(lastModelOf(recast))` comparisons are
// tautologies. The WS-3.C ops below instead pin the acorn output as golden
Expand Down
Loading
Loading