Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c605a79
test(studio): bench a plain element animated through auto-record and …
miguel-heygen Oct 4, 2026
8383bfa
fix(studio): seed a first tween from the offset a plain move left in …
miguel-heygen Oct 5, 2026
d01d5d6
test(studio): bench resize then drag on a GSAP tween, root and nested
miguel-heygen Oct 5, 2026
0e3b4f9
fix(studio): a resize under auto-record keys size at the playhead, he…
miguel-heygen Oct 5, 2026
acfaced
fix(studio): keyframe tweens Studio writes end on their last key, so …
miguel-heygen Oct 5, 2026
41d2a58
test(studio): keep the plain-to-keyframes moves inside the nested frame
miguel-heygen Oct 5, 2026
0fb90b8
fix(studio): a resize moves its anchor from the drag stamp it read at…
miguel-heygen Oct 5, 2026
a9cf379
fix(studio): an undo shown in place is no longer replaced by an older…
miguel-heygen Oct 5, 2026
aac53ba
refactor: split the trailing keyframe trim and drop an unused GSAP re…
miguel-heygen Oct 5, 2026
5f2c542
test(studio): settle the resize-then-drag bench steps so a two-save r…
miguel-heygen Oct 5, 2026
8fb18d6
fix(studio): a preview reload waits for an undo shown before its writ…
miguel-heygen Oct 5, 2026
f0037c8
fix(studio): Add keyframe reads a subtracted calc() translate with it…
miguel-heygen Oct 5, 2026
3270963
refactor: tidy the keyframe hold, trim and resize code after review
miguel-heygen Oct 5, 2026
99c546d
fix(parsers): the keyframe trim leaves looping, chained and relativel…
miguel-heygen Oct 5, 2026
4a940cd
fix(parsers): a size hold pins only a lone key, so an edit never chan…
miguel-heygen Oct 5, 2026
53ea940
fix(studio): every undo applied in place counts as a preview change, …
miguel-heygen Oct 5, 2026
3c026b9
fix(studio): a resize keys size only when a move or size is keyframed…
miguel-heygen Oct 5, 2026
b22815f
test(studio): a bench step whose save never lands fails the case
miguel-heygen Oct 5, 2026
2273dbc
fix(parsers): holds and trims change only the tweens an edit touches
miguel-heygen Oct 5, 2026
ebad31f
test(studio): bank the plain-to-keyframes and resize-then-drag bench …
miguel-heygen Oct 5, 2026
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
55 changes: 46 additions & 9 deletions packages/parsers/src/gsapConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,17 +139,45 @@ function knownStart(animation: GsapAnimation): number | undefined {
return typeof animation.position === "number" ? animation.position : undefined;
}

export interface HoldScope {
touched: (animation: GsapAnimation) => boolean;
held: (selector: string) => ReadonlyMap<string, unknown>;
}

const tweenSignature = (a: GsapAnimation) =>
JSON.stringify([a.targetSelector, a.method, a.position, a.duration, a.properties, a.keyframes]);

/** The tweens an edit touched (not in `previous`; none without it), and what the script's holds already pin. */
export function holdScope(
before: readonly GsapAnimation[],
previous: readonly GsapAnimation[] | null,
): HoldScope {
const kept = new Set(previous?.map(tweenSignature));
const held = new Map<string, Map<string, unknown>>();
for (const a of before.filter((b) => b.method === "set" && b.properties?.data === "hf-hold")) {
const props = held.get(a.targetSelector) ?? new Map<string, unknown>();
for (const [property, value] of Object.entries(a.properties)) props.set(property, value);
held.set(a.targetSelector, props);
}
return {
touched: (a) => previous !== null && !kept.has(tweenSignature(a)),
held: (selector) => held.get(selector) ?? new Map(),
};
}

/**
* What a Studio hold pins from t=0 before a later keyframed tween: its 0% keyframe's position props,
* minus those an earlier timeline tween on the target writes (a global `gsap.set` is a base value).
* What a Studio hold pins from t=0: a keyframed tween's 0% position props before a later start, and the position and
* size of a lone key (GSAP renders none) only where this edit made it or a hold already pins it, minus earlier tweens.
*/
export function positionHoldForAnimation(
export function keyframeHoldForAnimation(
animation: GsapAnimation,
animations: readonly GsapAnimation[],
scope: HoldScope,
): Record<string, number> | null {
if (!animation.keyframes) return null;
const start = knownStart(animation) ?? 0;
if (!(start > 0.001)) return null;
const start = knownStart(animation);
const lone = animation.keyframes.keyframes.length === 1;
if (start === undefined || (!(start > 0.001) && !lone)) return null;
const atStart = animation.keyframes.keyframes.find((keyframe) => keyframe.percentage === 0);
if (!atStart) return null;
// A tween whose start the parser could not resolve (a label, say) is not known to come first.
Expand All @@ -163,13 +191,22 @@ export function positionHoldForAnimation(
otherStart < start - 0.001
);
});
const position: Record<string, number> = {};
const touched = scope.touched(animation);
const pinned = scope.held(animation.targetSelector);
// An untouched tween keeps only the hold it made: one with its own first value.
const kept = (property: string, value: unknown) =>
touched ? pinned.has(property) : pinned.get(property) === value;
const pins = (group: PropertyGroupName, property: string, value: unknown) =>
(group === "position" && start > 0.001) ||
((group === "position" || group === "size") && ((lone && touched) || kept(property, value)));
const hold: Record<string, number> = {};
for (const [property, value] of Object.entries(atStart.properties)) {
if (classifyPropertyGroup(property) !== "position" || typeof value !== "number") continue;
if (!pins(classifyPropertyGroup(property), property, value) || typeof value !== "number")
continue;
if (earlier.some((other) => writesProperty(other, property))) continue;
position[property] = value;
hold[property] = value;
}
return Object.keys(position).length > 0 ? position : null;
return Object.keys(hold).length > 0 ? hold : null;
}

export const SUPPORTED_EASES = [
Expand Down
55 changes: 55 additions & 0 deletions packages/parsers/src/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1792,6 +1792,61 @@ describe("keyframe mutations", () => {
expect(syncPositionHoldsBeforeKeyframes(posTweenAt(0))).not.toContain("hf-hold");
});

it.each([
["recast", syncPositionHoldsBeforeKeyframes],
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
])("%s: holds a lone size keyframe from t=0, wherever its tween starts", (_, sync) => {
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
for (const start of [0, 1.5]) {
const script =
`const tl = gsap.timeline({ paused: true });\n` +
`tl.to("#s", { keyframes: { "0%": { width: 440, height: 294 } }, duration: 3 }, ${start});`;
const hold = parseGsapScript(sync(script, timeline)).animations.find(
(a) => a.method === "set",
);
expect(hold!.position).toBe(0);
expect(hold!.properties).toEqual({ width: 440, height: 294, data: "hf-hold" });
}
});

it.each([
["recast", syncPositionHoldsBeforeKeyframes],
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
])(
"%s: pins no size from t=0 for a tween of two size keys or a lone key after a size tween",
(_, sync) => {
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
const keys = `"0%": { width: 100 }, "100%": { width: 400 }`;
const twoKeys = `${timeline}tl.to("#s", { keyframes: { ${keys} }, duration: 1 }, 2);`;
expect(sync(twoKeys, timeline)).not.toContain("hf-hold");
const atLabel = `${timeline}tl.to("#s", { width: 300, duration: 1 }, 1);\ntl.to("#s", { keyframes: { "0%": { width: 500 } }, duration: 1 }, "later");`;
expect(sync(atLabel, timeline)).not.toContain("hf-hold");
},
);

it.each([
["recast", syncPositionHoldsBeforeKeyframes],
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
])(
"%s: keeps a size hold when its lone key gains a second, and adds none to a tween left alone",
(_, sync) => {
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
const held = `tl.set("#s", { width: 440, height: 294, data: "hf-hold" }, 0);\n`;
const lone = `tl.to("#s", { keyframes: { "0%": { width: 440, height: 294 } }, duration: 2 }, 3);`;
const two = `tl.to("#s", { keyframes: { "0%": { width: 380, height: 250 }, "100%": { width: 440, height: 294 } }, duration: 1 }, 2);`;
const hold = parseGsapScript(
sync(timeline + held + two, timeline + held + lone),
).animations.find((a) => a.method === "set");
expect(hold?.properties).toEqual({ width: 380, height: 250, data: "hf-hold" });

const untouched = `${timeline}tl.to("#u", { keyframes: { "0%": { width: 500 } }, duration: 1 }, 1);\n`;
const edited = `${untouched}tl.to("#v", { opacity: 1, duration: 1 }, 0);`;
const deleted = `${timeline}${held}tl.to("#s", { keyframes: { "0%": { width: 600 }, "100%": { width: 700 } }, duration: 1 }, 6);`;
expect(sync(deleted, deleted + `\n${lone}`)).not.toContain("hf-hold");
expect(sync(edited, untouched)).not.toContain("hf-hold");
},
);

it("adds no hold for an opacity-only keyframed tween (position-scoped)", () => {
const opacity =
`const tl = gsap.timeline({ paused: true });\n` +
Expand Down
27 changes: 20 additions & 7 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ import {
classifyTweenPropertyGroup,
GSAP_DEFAULT_DURATION,
isXYPositionWrite,
positionHoldForAnimation,
holdScope,
keyframeHoldForAnimation,
} from "./gsapConstants";
import type { PropertyGroupName } from "./gsapConstants";
import { BUILTIN_VAR_KEYS, DROPPED_VAR_KEYS, EXTRAS_KEYS, isTweenConfigKey } from "./gsapConstants";
Expand Down Expand Up @@ -1862,11 +1863,19 @@ export function isStudioHoldSet(anim: GsapAnimation): boolean {
* so this pass owns it: every call wipes the prior holds and recomputes from the
* current keyframes, keeping them in sync as keyframes are added/moved/deleted.
*
* Idempotent. Only position props (x/y/xPercent/yPercent) are held — opacity/scale
* keep their authored pre-tween behavior. A tween already starting at 0 needs no
* hold (no gap before it).
* Idempotent. Only position and size props are held — opacity/scale keep their
* authored pre-tween behavior. A tween already starting at 0 needs no hold, unless
* it is a lone keyframe, which GSAP never renders by itself.
*/
export function syncPositionHoldsBeforeKeyframes(script: string): string {
function animationsOf(script: string): GsapAnimation[] | null {
try {
return parseGsapScript(script).animations;
} catch {
return null;
}
}

export function syncPositionHoldsBeforeKeyframes(script: string, previous?: string): string {
let parsed: ParsedGsap;
try {
parsed = parseGsapScript(script);
Expand All @@ -1878,15 +1887,19 @@ export function syncPositionHoldsBeforeKeyframes(script: string): string {
const staleHoldIds = parsed.animations.filter(isStudioHoldSet).map((a) => a.id);
for (const id of staleHoldIds) result = removeAnimationFromScript(result, id);

// 2. Re-add a hold for each position-keyframed tween that starts after t=0.
// 2. Re-add a hold for each keyframed tween keyframeHoldForAnimation pins.
let reparsed: ParsedGsap;
try {
reparsed = parseGsapScript(result);
} catch {
return result;
}
const scope = holdScope(
parsed.animations,
previous === undefined ? null : animationsOf(previous),
);
for (const anim of reparsed.animations) {
const posProps = positionHoldForAnimation(anim, reparsed.animations);
const posProps = keyframeHoldForAnimation(anim, reparsed.animations, scope);
if (!posProps) continue;
result = insertInheritedStateSet(result, anim.targetSelector, 0, {
...posProps,
Expand Down
82 changes: 82 additions & 0 deletions packages/parsers/src/gsapWriterAcorn.trailingSpan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
import { trimTrailingKeyframeSpans } from "./gsapWriterAcorn.js";

const timeline = `const tl = gsap.timeline({ paused: true });\n`;
const tween = (keys: string, duration = 3, extra = "") =>
`tl.to("#t", { keyframes: { ${keys} }, duration: ${duration}${extra} }, 1);`;
const shortOfEnd = tween(`"0%": { x: 300 }, "33.333%": { x: 297 }, "66.667%": { x: 48 }`);

const keyed = (script: string) => {
const anim = parseGsapScriptAcorn(script).animations.find((a) => a.keyframes)!;
return {
duration: anim.duration,
keys: anim.keyframes!.keyframes.map((k) => [k.percentage, k.properties.x]),
};
};

describe("trimTrailingKeyframeSpans", () => {
it("ends a tween this mutation wrote on its last key, keeping each key's time", () => {
const out = trimTrailingKeyframeSpans(timeline, timeline + shortOfEnd);
expect(keyed(out)).toEqual({
duration: 2,
keys: [
[0, 300],
[49.999, 297],
[100, 48],
],
});
});

it("trims beside a static gsap.set, which no tween's length moves", () => {
const out = trimTrailingKeyframeSpans(
timeline,
`${timeline}gsap.set("#g", { x: 5 });\n${shortOfEnd}`,
);
expect(keyed(out).duration).toBe(2);
});

it("leaves a looping timeline, whose cycle the tail is part of", () => {
const looping = `const tl = gsap.timeline({ paused: true, repeat: -1 });\n`;
expect(trimTrailingKeyframeSpans(looping, looping + shortOfEnd)).toBe(looping + shortOfEnd);
});

it("leaves a chained tween the mutation did not touch when it edits the link before", () => {
const chain = (x: number) =>
`tl.to("#a", { x: ${x}, duration: 1 }, 0).to("#t", { keyframes: { "0%": { x: 300 }, "50%": { x: 48 } }, duration: 2 }, 1);`;
expect(trimTrailingKeyframeSpans(timeline + chain(1), timeline + chain(2))).toBe(
timeline + chain(2),
);
});

it("leaves a tween the mutation did not touch, and the bytes around it", () => {
const script = timeline + shortOfEnd;
expect(trimTrailingKeyframeSpans(script, script)).toBe(script);
});

it.each([
["a lone key, which the hold renders", tween(`"0%": { x: 300 }`)],
["a tween that already ends on its last key", tween(`"0%": { x: 300 }, "100%": { x: 48 }`)],
[
"an outer ease, whose curve the tail is part of",
tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, `, ease: "power2.in"`),
],
[
"a looping tween, whose tail is part of every cycle",
tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, ", repeat: -1"),
],
[
"every tween when one is placed after another's end",
`${shortOfEnd}\ntl.to("#u", { opacity: 1, duration: 1 }, ">");`,
],
["a yoyo tween", tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, ", yoyo: true")],
[
"a tween whose repeat key is quoted",
tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, `, "repeat": -1`),
],
["every tween when a label sits at the timeline's end", `${shortOfEnd}\ntl.addLabel("end");`],
["every tween when the timeline is set to repeat later", `${shortOfEnd}\ntl.repeat(-1);`],
])("leaves %s", (_, written) => {
expect(trimTrailingKeyframeSpans(timeline, timeline + written)).toBe(timeline + written);
});
});
86 changes: 83 additions & 3 deletions packages/parsers/src/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import {
classifyPropertyGroup,
isTweenConfigKey,
isXYPositionWrite,
positionHoldForAnimation,
holdScope,
keyframeHoldForAnimation,
} from "./gsapConstants.js";
import type { PropertyGroupName } from "./gsapConstants.js";
import {
Expand Down Expand Up @@ -2490,15 +2491,21 @@ function removeStudioHoldSets(script: string, parsed: ParsedGsapAcornForWrite):
}

/** Acorn-native, byte-preserving hold synchronization used after mutations. */
export function syncPositionHoldsBeforeKeyframes(script: string): string {
export function syncPositionHoldsBeforeKeyframes(script: string, previous?: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const tweensOf = (text: string) =>
parseGsapScriptAcornForWrite(text)?.located.map((entry) => entry.animation) ?? null;
const scope = holdScope(
parsed.located.map((entry) => entry.animation),
previous === undefined ? null : tweensOf(previous),
);
let result = removeStudioHoldSets(script, parsed);
const current = parseGsapScriptAcornForWrite(result);
if (!current) return result;
const animations = current.located.map((entry) => entry.animation);
for (const animation of animations) {
const position = positionHoldForAnimation(animation, animations);
const position = keyframeHoldForAnimation(animation, animations, scope);
if (!position) continue;
result = insertInheritedStateSetInScript(result, animation.targetSelector, 0, {
...position,
Expand All @@ -2508,6 +2515,79 @@ export function syncPositionHoldsBeforeKeyframes(script: string): string {
return result;
}

const roundPct = (pct: number) => Math.round(pct * 1000) / 1000;

const LINEAR_RUN = new Set(["none", "linear"]);
// ponytail: any .add( .duration( repeat: or yoyo: in the script skips the trim, classList.add included.
const PLACED_BY_TIMELINE =
/\.(?:add|addLabel|addPause|call|repeat|yoyo|duration|timeScale)\s*\(|\b(?:repeat|yoyo)\s*:/;

/** A percentage keyframe list whose timing is known and whose run is linear, so a trim keeps its render. */
function linearPercentageKeys(animation: GsapAnimation) {
const data = animation.keyframes;
if (data?.format !== "percentage" || data.fromMotionPath || animation.arcPath) return null;
if (animation.durationUnresolved || !LINEAR_RUN.has(animation.ease || "none")) return null;
return data.keyframes;
}

const loops = (animation: GsapAnimation) =>
Boolean(animation.extras?.repeat || animation.extras?.yoyo);

function trimmableKeyframes(animation: GsapAnimation) {
const keyframes = loops(animation) ? null : linearPercentageKeys(animation);
const { duration, position } = animation;
if (!keyframes || typeof position !== "number" || typeof duration !== "number") return null;
return duration > 0 ? { keyframes, duration, position } : null;
}

/** The keyless tail of a keyframe tween, as the remap that ends it on its last key; null when it has none. */
function trailingSpanTrim(animation: GsapAnimation) {
const tween = trimmableKeyframes(animation);
if (!tween) return null;
const last = Math.max(...tween.keyframes.map((keyframe) => keyframe.percentage));
if (tween.keyframes.length < 2 || !(last > 0) || last >= 99.999) return null;
return {
position: tween.position,
duration: Math.round(tween.duration * last * 10) / 1000,
pctRemap: tween.keyframes.map(({ percentage: from }) => ({
from,
to: roundPct((from / last) * 100),
})),
};
}

/** GSAP 3.15 stretches a keyframe tween that stops short of 100% on its first render, unlike once played:
* each tween this mutation wrote or edited ends on its last key instead. Tweens it left alone keep their tail. */
export function trimTrailingKeyframeSpans(previous: string, script: string): string {
if (script === previous) return script;
const located = parseGsapScriptAcornForWrite(script)?.located ?? [];
// A shorter tween moves what is placed after its end ("+=", ">", no position, add/call/addLabel).
const placedAfter = ({ animation: a }: { animation: GsapAnimation }) =>
!a.global && (a.implicitPosition || typeof a.position !== "number");
if (PLACED_BY_TIMELINE.test(script) || located.some(placedAfter)) return script;
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const trims = located.flatMap((entry) => {
const trim = trailingSpanTrim(entry.animation);
return trim ? [{ entry, trim }] : [];
});
if (!trims.length) return script;
const source = (text: string, { call }: { call: TweenCallInfo }) =>
`${call.method}${text.slice(call.node.arguments[0]?.start, call.node.end)}`;
const previousTweens = parseGsapScriptAcornForWrite(previous)?.located ?? [];
const untouched = new Set(previousTweens.map((entry) => source(previous, entry)));
let result = script;
for (const { entry, trim } of trims) {
if (untouched.has(source(script, entry))) continue;
result = resizeKeyframedTweenInScript(
result,
entry.id,
trim.position,
trim.duration,
trim.pctRemap,
);
}
return result;
}

/**
* Compute, in forward (timeline) order, the inherited-props baseline available
* BEFORE each matching tween, plus the final cumulative state at the split point.
Expand Down
Loading
Loading