Skip to content
Merged
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
94 changes: 94 additions & 0 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1969,6 +1969,10 @@ function percentageFromKey(key: string): number {

const PCT_TOLERANCE = 2;

// Below this (tween-%) a retime resolves onto its own source keyframe → skip the
// write. Mirrors the drag layer's NOOP_EPSILON so a deliberate 1% retime commits.
const MOVE_NOOP_EPSILON_PCT = 0.05;

function findKeyframePropByPct(
kfNode: AstNode,
percentage: number,
Expand Down Expand Up @@ -2315,6 +2319,96 @@ export function removeKeyframeFromScript(
return recast.print(loc.parsed.ast).code;
}

/**
* 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.
*/
export function moveKeyframeInScript(
script: string,
animationId: string,
fromPercentage: number,
toPercentage: number,
): string {
const loc = locateAnimationWithFallback(script, animationId);
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;

const match = findKeyframePropByPct(kfNode, fromPercentage);
if (!match) return script;
// No-op ONLY for a negligible move (matches the drag's NOOP_EPSILON). The old
// `collision.prop === match.prop` guard dropped EVERY sub-PCT_TOLERANCE (2%)
// 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.
const dest = findKeyframePropByPct(kfNode, toPercentage);
const collision = dest && dest.prop !== match.prop ? dest : null;

// 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.
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 });
}
entries.push({ pct: toPercentage, value: movedValue });
entries.sort((a, b) => a.pct - b.pct);

kfNode.properties = entries.map((e) => {
const p = parseExpr(`{ ${JSON.stringify(`${e.pct}%`)}: {} }`).properties[0];
p.value = e.value;
return p;
});
return recast.print(loc.parsed.ast).code;
}

/**
* Resize a keyframed tween's window (boundary drag-to-retime): set the tween's
* `position` + `duration` and RE-KEY each existing keyframe to its new percentage
* via `pctRemap` (each `{ from, to }` matches an existing keyframe by its current
* tween-% and re-keys it to `to`).
*
* Re-keys the percentage KEY in place, leaving every value node (so `_auto` +
* per-keyframe `ease` survive), the keyframes-object `easeEach`, and the OUTER
* tween `ease` untouched. Acorn twin: resizeKeyframedTweenInScript.
*/
export function resizeKeyframedTweenInScript(
script: string,
animationId: string,
newPosition: number,
newDuration: number,
pctRemap: ReadonlyArray<{ from: number; to: number }>,
): string {
const loc = locateAnimationWithFallback(script, animationId);
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;

const seen = new Set<AstNode>();
for (const { from, to } of pctRemap) {
const match = findKeyframePropByPct(kfNode, from);
if (!match || seen.has(match.prop)) continue;
seen.add(match.prop);
// Replace only the key node; the value node (incl. _auto + per-keyframe ease)
// stays verbatim. easeEach is a sibling non-percentage prop, left untouched.
match.prop.key = parseExpr(`{ ${JSON.stringify(`${to}%`)}: 0 }`).properties[0].key;
}

applyUpdatesToCall(loc.target.call, { position: newPosition, duration: newDuration });
return recast.print(loc.parsed.ast).code;
}

/**
* Replace the properties (and optionally ease) at an existing keyframe percentage.
*/
Expand Down
155 changes: 155 additions & 0 deletions packages/parsers/src/gsapWriter.parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
addKeyframeToScript as addKeyframeRecast,
updateKeyframeInScript as updateKeyframeRecast,
removeKeyframeFromScript as removeKeyframeRecast,
moveKeyframeInScript as moveKeyframeRecast,
resizeKeyframedTweenInScript as resizeKeyframedTweenRecast,
addAnimationWithKeyframesToScript as addWithKfRecast,
shiftPositionsInScript as shiftRecast,
scalePositionsInScript as scaleRecast,
Expand All @@ -49,6 +51,8 @@ import {
addKeyframeToScript as addKeyframeAcorn,
updateKeyframeInScript as updateKeyframeAcorn,
removeKeyframeFromScript as removeKeyframeAcorn,
moveKeyframeInScript as moveKeyframeAcorn,
resizeKeyframedTweenInScript as resizeKeyframedTweenAcorn,
addAnimationWithKeyframesToScript as addWithKfAcorn,
removeAnimationFromScript as removeAnimAcorn,
shiftPositionsInScript as shiftAcorn,
Expand Down Expand Up @@ -1117,6 +1121,157 @@ 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.
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);
`;

describe("moveKeyframeInScript: retime preserves value + ease (acorn) ", () => {
it("moves a keyframe to a new percentage, keeping properties + ease", () => {
const id = acornId(MOVE_KF_SCRIPT);
const out = moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 75);
const kfs = shapeOf(out).keyframes?.keyframes ?? [];
const pcts = kfs.map((k) => k.percentage);
expect(pcts).toEqual([0, 75, 100]);
const moved = kfs.find((k) => k.percentage === 75)!;
expect(moved.properties).toEqual({ x: 100, opacity: 0.5 });
expect(moved.ease).toBe("power2.in");
// The source percentage is gone.
expect(pcts).not.toContain(50);
});

it("overwrites the destination keyframe on collision (no duplicate)", () => {
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");
});

it("no-ops only for a negligible move (below the drag epsilon)", () => {
const id = acornId(MOVE_KF_SCRIPT);
// < 0.05% of travel resolves onto its own source keyframe → skip the write.
expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 50.02)).toBe(MOVE_KF_SCRIPT);
expect(moveKeyframeRecast(MOVE_KF_SCRIPT, id, 50, 50.02)).toBe(MOVE_KF_SCRIPT);
});

// Regression: a deliberate sub-PCT_TOLERANCE (2%) retime must COMMIT, not get
// swallowed by the old `collision.prop === match.prop` guard (findKfPropByPct
// resolves the 51% destination back onto the 50% from-keyframe).
it("commits a sub-2% retime, keeping value + ease", () => {
const id = acornId(MOVE_KF_SCRIPT);
const out = moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 51);
expect(out).not.toBe(MOVE_KF_SCRIPT);
const kfs = shapeOf(out).keyframes?.keyframes ?? [];
expect(kfs.map((k) => k.percentage)).toEqual([0, 51, 100]);
const moved = kfs.find((k) => k.percentage === 51)!;
expect(moved.properties).toEqual({ x: 100, opacity: 0.5 });
expect(moved.ease).toBe("power2.in");
});

it("no-ops on unknown id / absent source keyframe (both writers)", () => {
const id = acornId(MOVE_KF_SCRIPT);
expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, "bad-id", 50, 75)).toBe(MOVE_KF_SCRIPT);
expect(moveKeyframeRecast(MOVE_KF_SCRIPT, "bad-id", 50, 75)).toBe(MOVE_KF_SCRIPT);
expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 33, 75)).toBe(MOVE_KF_SCRIPT);
});
});

describe("parity: moveKeyframeInScript (recast vs acorn)", () => {
function expectParity(script: string, from: number, to: number) {
const id = acornId(script);
expect(parseGsapScript(script).animations[0]!.id).toBe(id);
expect(modelOf(moveKeyframeAcorn(script, id, from, to))).toEqual(
modelOf(moveKeyframeRecast(script, id, from, to)),
);
}

it("retime to a fresh percentage", () => {
expectParity(MOVE_KF_SCRIPT, 50, 75);
});

it("retime earlier, re-sorting keyframes", () => {
expectParity(MOVE_KF_SCRIPT, 50, 10);
});

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

it("retime an endpoint inward", () => {
expectParity(MOVE_KF_SCRIPT, 0, 25);
});

it("sub-2% retime agrees between writers (regression for the swallow bug)", () => {
expectParity(MOVE_KF_SCRIPT, 50, 51);
});
});

// ── resizeKeyframedTweenInScript (boundary drag: re-key + grow window) ────────
// Boundary drag-to-retime grows/shifts the tween window and RE-KEYS keyframes in
// place. Unlike replace-with-keyframes (array rebuild), it must preserve author
// intent verbatim: `_auto` endpoint markers, per-keyframe `ease`, the keyframes-
// object `easeEach`, and the OUTER tween `ease`.
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);
`;
// 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 = [
{ from: 0, to: 0 },
{ from: 50, to: 25 },
{ from: 100, to: 100 },
];

describe("resizeKeyframedTweenInScript: preserves author intent (acorn + recast)", () => {
for (const [label, resize] of [
["acorn", resizeKeyframedTweenAcorn],
["recast", resizeKeyframedTweenRecast],
] as const) {
it(`${label}: re-keys + grows the window, keeping _auto / ease / easeEach / outer ease`, () => {
const id = acornId(RESIZE_KF_SCRIPT);
const out = resize(RESIZE_KF_SCRIPT, id, 0.2, 2, RESIZE_REMAP);
expect(out).not.toBe(RESIZE_KF_SCRIPT);
const shape = shapeOf(out);
expect(shape.duration).toBe(2);
expect(parseGsapScript(out).animations[0]!.position).toBeCloseTo(0.2, 5);
// Outer tween ease + keyframes-object easeEach survive.
expect(shape.ease).toBe("power3.out");
expect(shape.keyframes?.easeEach).toBe("power1.inOut");
const kfs = shape.keyframes?.keyframes ?? [];
expect(kfs.map((k) => k.percentage)).toEqual([0, 25, 100]);
// _auto endpoints preserved (parsed back as an _auto property).
expect(kfs.find((k) => k.percentage === 0)!.properties).toEqual({ opacity: 0, _auto: 1 });
expect(kfs.find((k) => k.percentage === 100)!.properties).toEqual({ opacity: 1, _auto: 1 });
// Per-keyframe ease on the interior keyframe survives the re-key.
const interior = kfs.find((k) => k.percentage === 25)!;
expect(interior.properties).toEqual({ opacity: 0.5 });
expect(interior.ease).toBe("power2.in");
});

it(`${label}: no-ops on unknown id`, () => {
expect(resize(RESIZE_KF_SCRIPT, "bad-id", 0.2, 2, RESIZE_REMAP)).toBe(RESIZE_KF_SCRIPT);
});
}

it("parity: both writers reparse to the same model", () => {
const id = acornId(RESIZE_KF_SCRIPT);
expect(modelOf(resizeKeyframedTweenAcorn(RESIZE_KF_SCRIPT, id, 0.2, 2, RESIZE_REMAP))).toEqual(
modelOf(resizeKeyframedTweenRecast(RESIZE_KF_SCRIPT, id, 0.2, 2, RESIZE_REMAP)),
);
});
});

// ── addAnimationWithKeyframesToScript parity (recast vs acorn) ───────────────
// WS-3.C add path: both writers insert a new keyframed tl.to() call. The
// inserted statement's authored model (selector, keyframes, duration, ease,
Expand Down
100 changes: 100 additions & 0 deletions packages/parsers/src/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,10 @@ const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
// treated as the same keyframe (merge), not a new insert.
const PCT_TOLERANCE = 2;

// Below this (tween-%) a retime resolves onto its own source keyframe → skip the
// write. Mirrors the drag layer's NOOP_EPSILON so a deliberate 1% retime commits.
const MOVE_NOOP_EPSILON_PCT = 0.05;

function percentageFromKey(key: string): number {
const m = PERCENTAGE_KEY_RE.exec(key);
return m ? Number.parseFloat(m[1] ?? "0") : Number.NaN;
Expand Down Expand Up @@ -1191,6 +1195,102 @@ export function removeKeyframeFromScript(
return ms.toString();
}

/**
* 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.
*/
export function moveKeyframeInScript(
script: string,
animationId: string,
fromPercentage: number,
toPercentage: number,
): string {
const located = locateWithKeyframes(script, animationId);
if (!located) return script;
const { kfNode } = located;

const match = findKfPropByPct(kfNode, fromPercentage);
if (!match) return script;
// No-op ONLY for a negligible move (matches the drag's NOOP_EPSILON). The old
// `collision.prop === match.prop` guard dropped EVERY sub-PCT_TOLERANCE (2%)
// retime, because findKfPropByPct resolves the destination back onto the
// from-keyframe — so a deliberate 1% drag committed nothing.
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.
const dest = findKfPropByPct(kfNode, toPercentage);
const collision = dest && dest.prop !== match.prop ? dest : null;

// Rebuild the keyframes object: drop the moved keyframe (and any keyframe at
// the destination it overwrites), re-key the moved record to toPercentage,
// then re-sort. recordToCode round-trips properties + per-keyframe ease + _auto.
const entries: Array<{ pct: number; record: Record<string, number | string> }> = [];
for (const prop of percentagePropsOf(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, record: valueNodeToRecord(prop.value, script) });
}
entries.push({ pct: toPercentage, record: valueNodeToRecord(match.prop.value, script) });
entries.sort((a, b) => a.pct - b.pct);

const body = entries
.map((e) => `${JSON.stringify(`${e.pct}%`)}: ${recordToCode(e.record)}`)
.join(", ");
const ms = new MagicString(script);
ms.overwrite(kfNode.start, kfNode.end, `{ ${body} }`);
return ms.toString();
}

/**
* Resize a keyframed tween's window (boundary drag-to-retime): set the tween's
* `position` (trailing position arg) + `duration`, and RE-KEY each existing
* keyframe to its new percentage via `pctRemap` (each `{ from, to }` matches an
* existing keyframe by its current tween-% and re-keys it to `to`).
*
* Unlike replace-with-keyframes (which rebuilds the keyframes object from a plain
* array and loses author intent), this re-keys the percentage KEY in place and
* leaves every value node, each keyframe's `_auto` + per-keyframe `ease`, the
* keyframes-object `easeEach`, and the OUTER tween `ease` byte-for-byte verbatim.
* No-op when the animation/keyframes can't be located.
*/
export function resizeKeyframedTweenInScript(
script: string,
animationId: string,
newPosition: number,
newDuration: number,
pctRemap: ReadonlyArray<{ from: number; to: number }>,
): string {
const located = locateWithKeyframes(script, animationId);
if (!located) return script;
const { target, kfNode } = located;

// Resolve every re-key against the ORIGINAL AST first (offsets stay stable),
// then splice — distinct key nodes, so the overwrites never overlap. A Set
// guards the degenerate case where two remaps resolve to the same key.
const edits: Array<{ keyNode: Node; to: number }> = [];
const seen = new Set<Node>();
for (const { from, to } of pctRemap) {
const match = findKfPropByPct(kfNode, from);
if (!match || seen.has(match.prop.key)) continue;
seen.add(match.prop.key);
edits.push({ keyNode: match.prop.key, to });
}

const ms = new MagicString(script);
for (const { keyNode, to } of edits) {
ms.overwrite(keyNode.start, keyNode.end, JSON.stringify(`${to}%`));
}
overwritePosition(ms, target.call, newPosition);
upsertProp(ms, target.call.varsArg, "duration", newDuration);
return ms.toString();
}

export function removePropertyFromAnimation(
script: string,
animationId: string,
Expand Down
Loading
Loading