Skip to content

Commit 5c7f11a

Browse files
fix(studio): plain elements keep their place and size when keyframed (#5045)
* test(studio): bench a plain element animated through auto-record and Add keyframe * fix(studio): seed a first tween from the offset a plain move left in CSS translate * test(studio): bench resize then drag on a GSAP tween, root and nested * fix(studio): a resize under auto-record keys size at the playhead, held at all times * fix(studio): keyframe tweens Studio writes end on their last key, so a seek matches playback * test(studio): keep the plain-to-keyframes moves inside the nested frame * fix(studio): a resize moves its anchor from the drag stamp it read at release * fix(studio): an undo shown in place is no longer replaced by an older reload * refactor: split the trailing keyframe trim and drop an unused GSAP reader * test(studio): settle the resize-then-drag bench steps so a two-save resize is one undo * fix(studio): a preview reload waits for an undo shown before its write lands * fix(studio): Add keyframe reads a subtracted calc() translate with its sign * refactor: tidy the keyframe hold, trim and resize code after review * fix(parsers): the keyframe trim leaves looping, chained and relatively placed tweens alone * fix(parsers): a size hold pins only a lone key, so an edit never changes frames left alone * fix(studio): every undo applied in place counts as a preview change, script-only ones too * fix(studio): a resize keys size only when a move or size is keyframed, not a fade * test(studio): a bench step whose save never lands fails the case * fix(parsers): holds and trims change only the tweens an edit touches * test(studio): bank the plain-to-keyframes and resize-then-drag bench cases
1 parent 061a5c0 commit 5c7f11a

34 files changed

Lines changed: 3118 additions & 2187 deletions

‎packages/parsers/src/gsapConstants.ts‎

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,17 +139,45 @@ function knownStart(animation: GsapAnimation): number | undefined {
139139
return typeof animation.position === "number" ? animation.position : undefined;
140140
}
141141

142+
export interface HoldScope {
143+
touched: (animation: GsapAnimation) => boolean;
144+
held: (selector: string) => ReadonlyMap<string, unknown>;
145+
}
146+
147+
const tweenSignature = (a: GsapAnimation) =>
148+
JSON.stringify([a.targetSelector, a.method, a.position, a.duration, a.properties, a.keyframes]);
149+
150+
/** The tweens an edit touched (not in `previous`; none without it), and what the script's holds already pin. */
151+
export function holdScope(
152+
before: readonly GsapAnimation[],
153+
previous: readonly GsapAnimation[] | null,
154+
): HoldScope {
155+
const kept = new Set(previous?.map(tweenSignature));
156+
const held = new Map<string, Map<string, unknown>>();
157+
for (const a of before.filter((b) => b.method === "set" && b.properties?.data === "hf-hold")) {
158+
const props = held.get(a.targetSelector) ?? new Map<string, unknown>();
159+
for (const [property, value] of Object.entries(a.properties)) props.set(property, value);
160+
held.set(a.targetSelector, props);
161+
}
162+
return {
163+
touched: (a) => previous !== null && !kept.has(tweenSignature(a)),
164+
held: (selector) => held.get(selector) ?? new Map(),
165+
};
166+
}
167+
142168
/**
143-
* What a Studio hold pins from t=0 before a later keyframed tween: its 0% keyframe's position props,
144-
* minus those an earlier timeline tween on the target writes (a global `gsap.set` is a base value).
169+
* What a Studio hold pins from t=0: a keyframed tween's 0% position props before a later start, and the position and
170+
* size of a lone key (GSAP renders none) only where this edit made it or a hold already pins it, minus earlier tweens.
145171
*/
146-
export function positionHoldForAnimation(
172+
export function keyframeHoldForAnimation(
147173
animation: GsapAnimation,
148174
animations: readonly GsapAnimation[],
175+
scope: HoldScope,
149176
): Record<string, number> | null {
150177
if (!animation.keyframes) return null;
151-
const start = knownStart(animation) ?? 0;
152-
if (!(start > 0.001)) return null;
178+
const start = knownStart(animation);
179+
const lone = animation.keyframes.keyframes.length === 1;
180+
if (start === undefined || (!(start > 0.001) && !lone)) return null;
153181
const atStart = animation.keyframes.keyframes.find((keyframe) => keyframe.percentage === 0);
154182
if (!atStart) return null;
155183
// A tween whose start the parser could not resolve (a label, say) is not known to come first.
@@ -163,13 +191,22 @@ export function positionHoldForAnimation(
163191
otherStart < start - 0.001
164192
);
165193
});
166-
const position: Record<string, number> = {};
194+
const touched = scope.touched(animation);
195+
const pinned = scope.held(animation.targetSelector);
196+
// An untouched tween keeps only the hold it made: one with its own first value.
197+
const kept = (property: string, value: unknown) =>
198+
touched ? pinned.has(property) : pinned.get(property) === value;
199+
const pins = (group: PropertyGroupName, property: string, value: unknown) =>
200+
(group === "position" && start > 0.001) ||
201+
((group === "position" || group === "size") && ((lone && touched) || kept(property, value)));
202+
const hold: Record<string, number> = {};
167203
for (const [property, value] of Object.entries(atStart.properties)) {
168-
if (classifyPropertyGroup(property) !== "position" || typeof value !== "number") continue;
204+
if (!pins(classifyPropertyGroup(property), property, value) || typeof value !== "number")
205+
continue;
169206
if (earlier.some((other) => writesProperty(other, property))) continue;
170-
position[property] = value;
207+
hold[property] = value;
171208
}
172-
return Object.keys(position).length > 0 ? position : null;
209+
return Object.keys(hold).length > 0 ? hold : null;
173210
}
174211

175212
export const SUPPORTED_EASES = [

‎packages/parsers/src/gsapParser.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1792,6 +1792,61 @@ describe("keyframe mutations", () => {
17921792
expect(syncPositionHoldsBeforeKeyframes(posTweenAt(0))).not.toContain("hf-hold");
17931793
});
17941794

1795+
it.each([
1796+
["recast", syncPositionHoldsBeforeKeyframes],
1797+
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
1798+
])("%s: holds a lone size keyframe from t=0, wherever its tween starts", (_, sync) => {
1799+
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
1800+
for (const start of [0, 1.5]) {
1801+
const script =
1802+
`const tl = gsap.timeline({ paused: true });\n` +
1803+
`tl.to("#s", { keyframes: { "0%": { width: 440, height: 294 } }, duration: 3 }, ${start});`;
1804+
const hold = parseGsapScript(sync(script, timeline)).animations.find(
1805+
(a) => a.method === "set",
1806+
);
1807+
expect(hold!.position).toBe(0);
1808+
expect(hold!.properties).toEqual({ width: 440, height: 294, data: "hf-hold" });
1809+
}
1810+
});
1811+
1812+
it.each([
1813+
["recast", syncPositionHoldsBeforeKeyframes],
1814+
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
1815+
])(
1816+
"%s: pins no size from t=0 for a tween of two size keys or a lone key after a size tween",
1817+
(_, sync) => {
1818+
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
1819+
const keys = `"0%": { width: 100 }, "100%": { width: 400 }`;
1820+
const twoKeys = `${timeline}tl.to("#s", { keyframes: { ${keys} }, duration: 1 }, 2);`;
1821+
expect(sync(twoKeys, timeline)).not.toContain("hf-hold");
1822+
const atLabel = `${timeline}tl.to("#s", { width: 300, duration: 1 }, 1);\ntl.to("#s", { keyframes: { "0%": { width: 500 } }, duration: 1 }, "later");`;
1823+
expect(sync(atLabel, timeline)).not.toContain("hf-hold");
1824+
},
1825+
);
1826+
1827+
it.each([
1828+
["recast", syncPositionHoldsBeforeKeyframes],
1829+
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
1830+
])(
1831+
"%s: keeps a size hold when its lone key gains a second, and adds none to a tween left alone",
1832+
(_, sync) => {
1833+
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
1834+
const held = `tl.set("#s", { width: 440, height: 294, data: "hf-hold" }, 0);\n`;
1835+
const lone = `tl.to("#s", { keyframes: { "0%": { width: 440, height: 294 } }, duration: 2 }, 3);`;
1836+
const two = `tl.to("#s", { keyframes: { "0%": { width: 380, height: 250 }, "100%": { width: 440, height: 294 } }, duration: 1 }, 2);`;
1837+
const hold = parseGsapScript(
1838+
sync(timeline + held + two, timeline + held + lone),
1839+
).animations.find((a) => a.method === "set");
1840+
expect(hold?.properties).toEqual({ width: 380, height: 250, data: "hf-hold" });
1841+
1842+
const untouched = `${timeline}tl.to("#u", { keyframes: { "0%": { width: 500 } }, duration: 1 }, 1);\n`;
1843+
const edited = `${untouched}tl.to("#v", { opacity: 1, duration: 1 }, 0);`;
1844+
const deleted = `${timeline}${held}tl.to("#s", { keyframes: { "0%": { width: 600 }, "100%": { width: 700 } }, duration: 1 }, 6);`;
1845+
expect(sync(deleted, deleted + `\n${lone}`)).not.toContain("hf-hold");
1846+
expect(sync(edited, untouched)).not.toContain("hf-hold");
1847+
},
1848+
);
1849+
17951850
it("adds no hold for an opacity-only keyframed tween (position-scoped)", () => {
17961851
const opacity =
17971852
`const tl = gsap.timeline({ paused: true });\n` +

‎packages/parsers/src/gsapParser.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ import {
5555
classifyTweenPropertyGroup,
5656
GSAP_DEFAULT_DURATION,
5757
isXYPositionWrite,
58-
positionHoldForAnimation,
58+
holdScope,
59+
keyframeHoldForAnimation,
5960
} from "./gsapConstants";
6061
import type { PropertyGroupName } from "./gsapConstants";
6162
import { BUILTIN_VAR_KEYS, DROPPED_VAR_KEYS, EXTRAS_KEYS, isTweenConfigKey } from "./gsapConstants";
@@ -1862,11 +1863,19 @@ export function isStudioHoldSet(anim: GsapAnimation): boolean {
18621863
* so this pass owns it: every call wipes the prior holds and recomputes from the
18631864
* current keyframes, keeping them in sync as keyframes are added/moved/deleted.
18641865
*
1865-
* Idempotent. Only position props (x/y/xPercent/yPercent) are held — opacity/scale
1866-
* keep their authored pre-tween behavior. A tween already starting at 0 needs no
1867-
* hold (no gap before it).
1866+
* Idempotent. Only position and size props are held — opacity/scale keep their
1867+
* authored pre-tween behavior. A tween already starting at 0 needs no hold, unless
1868+
* it is a lone keyframe, which GSAP never renders by itself.
18681869
*/
1869-
export function syncPositionHoldsBeforeKeyframes(script: string): string {
1870+
function animationsOf(script: string): GsapAnimation[] | null {
1871+
try {
1872+
return parseGsapScript(script).animations;
1873+
} catch {
1874+
return null;
1875+
}
1876+
}
1877+
1878+
export function syncPositionHoldsBeforeKeyframes(script: string, previous?: string): string {
18701879
let parsed: ParsedGsap;
18711880
try {
18721881
parsed = parseGsapScript(script);
@@ -1878,15 +1887,19 @@ export function syncPositionHoldsBeforeKeyframes(script: string): string {
18781887
const staleHoldIds = parsed.animations.filter(isStudioHoldSet).map((a) => a.id);
18791888
for (const id of staleHoldIds) result = removeAnimationFromScript(result, id);
18801889

1881-
// 2. Re-add a hold for each position-keyframed tween that starts after t=0.
1890+
// 2. Re-add a hold for each keyframed tween keyframeHoldForAnimation pins.
18821891
let reparsed: ParsedGsap;
18831892
try {
18841893
reparsed = parseGsapScript(result);
18851894
} catch {
18861895
return result;
18871896
}
1897+
const scope = holdScope(
1898+
parsed.animations,
1899+
previous === undefined ? null : animationsOf(previous),
1900+
);
18881901
for (const anim of reparsed.animations) {
1889-
const posProps = positionHoldForAnimation(anim, reparsed.animations);
1902+
const posProps = keyframeHoldForAnimation(anim, reparsed.animations, scope);
18901903
if (!posProps) continue;
18911904
result = insertInheritedStateSet(result, anim.targetSelector, 0, {
18921905
...posProps,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
3+
import { trimTrailingKeyframeSpans } from "./gsapWriterAcorn.js";
4+
5+
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
6+
const tween = (keys: string, duration = 3, extra = "") =>
7+
`tl.to("#t", { keyframes: { ${keys} }, duration: ${duration}${extra} }, 1);`;
8+
const shortOfEnd = tween(`"0%": { x: 300 }, "33.333%": { x: 297 }, "66.667%": { x: 48 }`);
9+
10+
const keyed = (script: string) => {
11+
const anim = parseGsapScriptAcorn(script).animations.find((a) => a.keyframes)!;
12+
return {
13+
duration: anim.duration,
14+
keys: anim.keyframes!.keyframes.map((k) => [k.percentage, k.properties.x]),
15+
};
16+
};
17+
18+
describe("trimTrailingKeyframeSpans", () => {
19+
it("ends a tween this mutation wrote on its last key, keeping each key's time", () => {
20+
const out = trimTrailingKeyframeSpans(timeline, timeline + shortOfEnd);
21+
expect(keyed(out)).toEqual({
22+
duration: 2,
23+
keys: [
24+
[0, 300],
25+
[49.999, 297],
26+
[100, 48],
27+
],
28+
});
29+
});
30+
31+
it("trims beside a static gsap.set, which no tween's length moves", () => {
32+
const out = trimTrailingKeyframeSpans(
33+
timeline,
34+
`${timeline}gsap.set("#g", { x: 5 });\n${shortOfEnd}`,
35+
);
36+
expect(keyed(out).duration).toBe(2);
37+
});
38+
39+
it("leaves a looping timeline, whose cycle the tail is part of", () => {
40+
const looping = `const tl = gsap.timeline({ paused: true, repeat: -1 });\n`;
41+
expect(trimTrailingKeyframeSpans(looping, looping + shortOfEnd)).toBe(looping + shortOfEnd);
42+
});
43+
44+
it("leaves a chained tween the mutation did not touch when it edits the link before", () => {
45+
const chain = (x: number) =>
46+
`tl.to("#a", { x: ${x}, duration: 1 }, 0).to("#t", { keyframes: { "0%": { x: 300 }, "50%": { x: 48 } }, duration: 2 }, 1);`;
47+
expect(trimTrailingKeyframeSpans(timeline + chain(1), timeline + chain(2))).toBe(
48+
timeline + chain(2),
49+
);
50+
});
51+
52+
it("leaves a tween the mutation did not touch, and the bytes around it", () => {
53+
const script = timeline + shortOfEnd;
54+
expect(trimTrailingKeyframeSpans(script, script)).toBe(script);
55+
});
56+
57+
it.each([
58+
["a lone key, which the hold renders", tween(`"0%": { x: 300 }`)],
59+
["a tween that already ends on its last key", tween(`"0%": { x: 300 }, "100%": { x: 48 }`)],
60+
[
61+
"an outer ease, whose curve the tail is part of",
62+
tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, `, ease: "power2.in"`),
63+
],
64+
[
65+
"a looping tween, whose tail is part of every cycle",
66+
tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, ", repeat: -1"),
67+
],
68+
[
69+
"every tween when one is placed after another's end",
70+
`${shortOfEnd}\ntl.to("#u", { opacity: 1, duration: 1 }, ">");`,
71+
],
72+
["a yoyo tween", tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, ", yoyo: true")],
73+
[
74+
"a tween whose repeat key is quoted",
75+
tween(`"0%": { x: 300 }, "50%": { x: 48 }`, 3, `, "repeat": -1`),
76+
],
77+
["every tween when a label sits at the timeline's end", `${shortOfEnd}\ntl.addLabel("end");`],
78+
["every tween when the timeline is set to repeat later", `${shortOfEnd}\ntl.repeat(-1);`],
79+
])("leaves %s", (_, written) => {
80+
expect(trimTrailingKeyframeSpans(timeline, timeline + written)).toBe(timeline + written);
81+
});
82+
});

‎packages/parsers/src/gsapWriterAcorn.ts‎

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import {
2929
classifyPropertyGroup,
3030
isTweenConfigKey,
3131
isXYPositionWrite,
32-
positionHoldForAnimation,
32+
holdScope,
33+
keyframeHoldForAnimation,
3334
} from "./gsapConstants.js";
3435
import type { PropertyGroupName } from "./gsapConstants.js";
3536
import {
@@ -2490,15 +2491,21 @@ function removeStudioHoldSets(script: string, parsed: ParsedGsapAcornForWrite):
24902491
}
24912492

24922493
/** Acorn-native, byte-preserving hold synchronization used after mutations. */
2493-
export function syncPositionHoldsBeforeKeyframes(script: string): string {
2494+
export function syncPositionHoldsBeforeKeyframes(script: string, previous?: string): string {
24942495
const parsed = parseGsapScriptAcornForWrite(script);
24952496
if (!parsed) return script;
2497+
const tweensOf = (text: string) =>
2498+
parseGsapScriptAcornForWrite(text)?.located.map((entry) => entry.animation) ?? null;
2499+
const scope = holdScope(
2500+
parsed.located.map((entry) => entry.animation),
2501+
previous === undefined ? null : tweensOf(previous),
2502+
);
24962503
let result = removeStudioHoldSets(script, parsed);
24972504
const current = parseGsapScriptAcornForWrite(result);
24982505
if (!current) return result;
24992506
const animations = current.located.map((entry) => entry.animation);
25002507
for (const animation of animations) {
2501-
const position = positionHoldForAnimation(animation, animations);
2508+
const position = keyframeHoldForAnimation(animation, animations, scope);
25022509
if (!position) continue;
25032510
result = insertInheritedStateSetInScript(result, animation.targetSelector, 0, {
25042511
...position,
@@ -2508,6 +2515,79 @@ export function syncPositionHoldsBeforeKeyframes(script: string): string {
25082515
return result;
25092516
}
25102517

2518+
const roundPct = (pct: number) => Math.round(pct * 1000) / 1000;
2519+
2520+
const LINEAR_RUN = new Set(["none", "linear"]);
2521+
// ponytail: any .add( .duration( repeat: or yoyo: in the script skips the trim, classList.add included.
2522+
const PLACED_BY_TIMELINE =
2523+
/\.(?:add|addLabel|addPause|call|repeat|yoyo|duration|timeScale)\s*\(|\b(?:repeat|yoyo)\s*:/;
2524+
2525+
/** A percentage keyframe list whose timing is known and whose run is linear, so a trim keeps its render. */
2526+
function linearPercentageKeys(animation: GsapAnimation) {
2527+
const data = animation.keyframes;
2528+
if (data?.format !== "percentage" || data.fromMotionPath || animation.arcPath) return null;
2529+
if (animation.durationUnresolved || !LINEAR_RUN.has(animation.ease || "none")) return null;
2530+
return data.keyframes;
2531+
}
2532+
2533+
const loops = (animation: GsapAnimation) =>
2534+
Boolean(animation.extras?.repeat || animation.extras?.yoyo);
2535+
2536+
function trimmableKeyframes(animation: GsapAnimation) {
2537+
const keyframes = loops(animation) ? null : linearPercentageKeys(animation);
2538+
const { duration, position } = animation;
2539+
if (!keyframes || typeof position !== "number" || typeof duration !== "number") return null;
2540+
return duration > 0 ? { keyframes, duration, position } : null;
2541+
}
2542+
2543+
/** The keyless tail of a keyframe tween, as the remap that ends it on its last key; null when it has none. */
2544+
function trailingSpanTrim(animation: GsapAnimation) {
2545+
const tween = trimmableKeyframes(animation);
2546+
if (!tween) return null;
2547+
const last = Math.max(...tween.keyframes.map((keyframe) => keyframe.percentage));
2548+
if (tween.keyframes.length < 2 || !(last > 0) || last >= 99.999) return null;
2549+
return {
2550+
position: tween.position,
2551+
duration: Math.round(tween.duration * last * 10) / 1000,
2552+
pctRemap: tween.keyframes.map(({ percentage: from }) => ({
2553+
from,
2554+
to: roundPct((from / last) * 100),
2555+
})),
2556+
};
2557+
}
2558+
2559+
/** GSAP 3.15 stretches a keyframe tween that stops short of 100% on its first render, unlike once played:
2560+
* each tween this mutation wrote or edited ends on its last key instead. Tweens it left alone keep their tail. */
2561+
export function trimTrailingKeyframeSpans(previous: string, script: string): string {
2562+
if (script === previous) return script;
2563+
const located = parseGsapScriptAcornForWrite(script)?.located ?? [];
2564+
// A shorter tween moves what is placed after its end ("+=", ">", no position, add/call/addLabel).
2565+
const placedAfter = ({ animation: a }: { animation: GsapAnimation }) =>
2566+
!a.global && (a.implicitPosition || typeof a.position !== "number");
2567+
if (PLACED_BY_TIMELINE.test(script) || located.some(placedAfter)) return script;
2568+
const trims = located.flatMap((entry) => {
2569+
const trim = trailingSpanTrim(entry.animation);
2570+
return trim ? [{ entry, trim }] : [];
2571+
});
2572+
if (!trims.length) return script;
2573+
const source = (text: string, { call }: { call: TweenCallInfo }) =>
2574+
`${call.method}${text.slice(call.node.arguments[0]?.start, call.node.end)}`;
2575+
const previousTweens = parseGsapScriptAcornForWrite(previous)?.located ?? [];
2576+
const untouched = new Set(previousTweens.map((entry) => source(previous, entry)));
2577+
let result = script;
2578+
for (const { entry, trim } of trims) {
2579+
if (untouched.has(source(script, entry))) continue;
2580+
result = resizeKeyframedTweenInScript(
2581+
result,
2582+
entry.id,
2583+
trim.position,
2584+
trim.duration,
2585+
trim.pctRemap,
2586+
);
2587+
}
2588+
return result;
2589+
}
2590+
25112591
/**
25122592
* Compute, in forward (timeline) order, the inherited-props baseline available
25132593
* BEFORE each matching tween, plus the final cumulative state at the split point.

0 commit comments

Comments
 (0)