feat(studio): motion editing — speed-curve editor, class-tween attribution, per-keyframe size & ease (#1705)

Speed-curve editor: a fixed-square cubic-bezier graph (grid, linear reference,
draggable handles, live preview) for editing eases; conventional preset grid.

Class/selector tweens: attribute `gsap.from(".dot", …)`-style tweens to every
matching element so they surface in the inspector and keep their timeline
keyframe diamonds when the clip is selected.

Apply-to-all easing: a "Set all…" control sets easeEach and strips every
per-keyframe ease override in one mutation (AE select-all + F9). Implemented in
BOTH gsap writers — the acorn writer and the recast writer (the default server
path); the recast side was missing resetKeyframeEases, so "Set all" set easeEach
but left per-keyframe eases in place.

Per-keyframe size: resizing an animated element writes a width/height keyframe
at the playhead — other keyframes keep their size — instead of a global
gsap.set hold; static elements keep the simple global resize. The extra size
tween exposed a motion-path bug (the overlay read whichever tween contained the
playhead), fixed with an opt-in requireChannels filter so the path only reads
the positional tween.

Inferred Timing: derive Start/End/Duration from an element's animations when it
has no authored clip range, instead of showing 0.00s.

Ease labels now surface the raw GSAP token (power2.out, back.out, …) instead of
invented names ("Smooth slowdown") that confused authors.

Also pass the preview iframe to the inspector's animation hook so element
resolution runs, and remove the unused editDebugLog facility.
This commit is contained in:
Miguel Ángel
2026-06-24 23:38:13 -04:00
committed by GitHub
parent 814c96cefa
commit 364992203e
31 changed files with 769 additions and 220 deletions
@@ -577,6 +577,23 @@ describe("stagger/yoyo/repeat round-trip", () => {
expect(updatedScript).toContain("stagger: 0.1");
expect(updatedScript).toContain("opacity: 0.5");
});
it("apply-to-all (resetKeyframeEases) sets easeEach and strips every per-keyframe ease", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#card", { keyframes: { "0%": { x: 0 }, "30%": { x: 50, ease: "custom(M0,0 C0.333,0 0.667,1 1,1)" }, "70%": { x: 80, ease: "power2.in" }, "100%": { x: 100 }, easeEach: "power2.out" }, duration: 1 }, 0);
`;
const parsed = parseGsapScript(script);
const animId = parsed.animations[0].id;
const result = updateAnimationInScript(script, animId, {
easeEach: "back.out",
resetKeyframeEases: true,
});
expect(result).toContain('easeEach: "back.out"');
// Every per-keyframe override is gone — the single easeEach governs all segments.
expect(result).not.toContain('ease: "custom');
expect(result).not.toContain('ease: "power2.in"');
});
});
describe("unresolvable value round-trip", () => {
+17 -2
View File
@@ -1243,9 +1243,23 @@ function applyEaseUpdate(varsArg: AstNode, ease: string): void {
}
}
/**
* "Apply to all segments": drop every per-keyframe `ease` override so the single
* `easeEach` governs all segments uniformly (AE select-all + F9). Mirrors the
* acorn writer's resetKeyframeEases branch.
*/
function stripKeyframeEases(varsArg: AstNode): void {
const kfNode = findKeyframesObjectNode(varsArg);
const props = kfNode?.properties;
if (!Array.isArray(props)) return;
for (const entry of props) {
if (isObjectProperty(entry)) removeVarsKey(entry.value, "ease");
}
}
function applyUpdatesToCall(
call: TweenCallInfo,
updates: Partial<GsapAnimation> & { easeEach?: string },
updates: Partial<GsapAnimation> & { easeEach?: string; resetKeyframeEases?: boolean },
): void {
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
@@ -1254,6 +1268,7 @@ function applyUpdatesToCall(
if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration);
if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach);
else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
if (updates.resetKeyframeEases) stripKeyframeEases(call.varsArg);
if (updates.position !== undefined) {
const posIdx = call.method === "fromTo" ? 3 : 2;
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
@@ -1315,7 +1330,7 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
export function updateAnimationInScript(
script: string,
animationId: string,
updates: Partial<GsapAnimation> & { easeEach?: string },
updates: Partial<GsapAnimation> & { easeEach?: string; resetKeyframeEases?: boolean },
): string {
let parsed: ParsedGsapAst;
try {
@@ -14,6 +14,7 @@ import {
updateAnimationInScript,
updateKeyframeInScript,
} from "./gsapWriterAcorn.js";
import { parseGsapScript } from "./gsapParser.js";
// ---------------------------------------------------------------------------
// Fixture scripts
@@ -272,6 +273,27 @@ describe("T6c — keyframe write ops", () => {
expect(result).toContain("{ x: 1480, y: 160 }");
});
it("updateAnimationInScript apply-to-all sets easeEach and strips per-keyframe eases", () => {
const script =
"const tl = gsap.timeline();\n" +
'tl.to("#box", { keyframes: { "0%": { x: 0 }, "50%": { x: 50, ease: "power2.in" }, "100%": { x: 100, ease: "back.out" }, easeEach: "none" }, duration: 1 }, 0);';
const id = parseGsapScript(script).animations[0]!.id;
const result = updateAnimationInScript(script, id, {
easeEach: "power2.out",
resetKeyframeEases: true,
});
// easeEach updated to the chosen ease …
expect(result).toContain('easeEach: "power2.out"');
// … and every per-keyframe override is gone, so all segments use easeEach.
expect(result).not.toContain('ease: "power2.in"');
expect(result).not.toContain('ease: "back.out"');
// keyframe property values are preserved.
const kf = parseGsapScript(result).animations[0]!.keyframes!;
expect(kf.easeEach).toBe("power2.out");
expect(kf.keyframes.every((k) => k.ease === undefined)).toBe(true);
expect(kf.keyframes.map((k) => k.properties.x)).toEqual([0, 50, 100]);
});
it("addKeyframeToScript — ARRAY-form normalizes to object form + inserts 50%", () => {
const script =
"const tl = gsap.timeline();\n" +
+17 -3
View File
@@ -299,7 +299,7 @@ function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
export function updateAnimationInScript(
script: string,
animationId: string,
updates: Partial<GsapAnimation> & { easeEach?: string },
updates: Partial<GsapAnimation> & { easeEach?: string; resetKeyframeEases?: boolean },
): string {
if (!Object.keys(updates).length) return script;
const parsed = parseGsapScriptAcornForWrite(script);
@@ -327,8 +327,22 @@ export function updateAnimationInScript(
const easeValue = updates.easeEach ?? updates.ease;
if (easeValue !== undefined) {
const kfNode = keyframesObjectNode(call.varsArg);
if (kfNode) upsertProp(ms, kfNode, "easeEach", easeValue);
else upsertProp(ms, call.varsArg, "ease", easeValue);
if (kfNode) {
upsertProp(ms, kfNode, "easeEach", easeValue);
// "Apply to all segments": drop every per-keyframe `ease` override so the
// single easeEach governs all segments uniformly (AE select-all + F9).
if (updates.resetKeyframeEases) {
for (const kfEntry of kfNode.properties ?? []) {
if (!isObjectProperty(kfEntry)) continue;
const val = kfEntry.value;
if (val?.type !== "ObjectExpression") continue;
const easeNode = findPropertyNode(val, "ease");
if (easeNode) removeProp(ms, easeNode, val.properties);
}
}
} else {
upsertProp(ms, call.varsArg, "ease", easeValue);
}
}
if (updates.extras) {
for (const [key, value] of Object.entries(updates.extras)) {
+7 -1
View File
@@ -437,7 +437,13 @@ type GsapMutationRequest =
| {
type: "update-meta";
animationId: string;
updates: { duration?: number; ease?: string; easeEach?: string; position?: number };
updates: {
duration?: number;
ease?: string;
easeEach?: string;
position?: number;
resetKeyframeEases?: boolean;
};
}
| {
type: "add";