Skip to content

Commit 2273dbc

Browse files
committed
fix(parsers): holds and trims change only the tweens an edit touches
1 parent b22815f commit 2273dbc

10 files changed

Lines changed: 278 additions & 60 deletions

‎packages/parsers/src/gsapConstants.ts‎

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,40 @@ 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 under a lone keyframe (GSAP renders none) or before a later keyframed tween:
144-
* its 0% keyframe's position props, and size props of a lone key only, minus those an earlier tween writes.
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
*/
146172
export function keyframeHoldForAnimation(
147173
animation: GsapAnimation,
148174
animations: readonly GsapAnimation[],
175+
scope: HoldScope,
149176
): Record<string, number> | null {
150177
if (!animation.keyframes) return null;
151178
const start = knownStart(animation);
@@ -164,15 +191,22 @@ export function keyframeHoldForAnimation(
164191
otherStart < start - 0.001
165192
);
166193
});
167-
const held: 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> = {};
168203
for (const [property, value] of Object.entries(atStart.properties)) {
169-
const group = classifyPropertyGroup(property);
170-
if (!(group === "position" || (group === "size" && lone)) || typeof value !== "number")
204+
if (!pins(classifyPropertyGroup(property), property, value) || typeof value !== "number")
171205
continue;
172206
if (earlier.some((other) => writesProperty(other, property))) continue;
173-
held[property] = value;
207+
hold[property] = value;
174208
}
175-
return Object.keys(held).length > 0 ? held : null;
209+
return Object.keys(hold).length > 0 ? hold : null;
176210
}
177211

178212
export const SUPPORTED_EASES = [

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

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,11 +1796,14 @@ describe("keyframe mutations", () => {
17961796
["recast", syncPositionHoldsBeforeKeyframes],
17971797
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
17981798
])("%s: holds a lone size keyframe from t=0, wherever its tween starts", (_, sync) => {
1799+
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
17991800
for (const start of [0, 1.5]) {
18001801
const script =
18011802
`const tl = gsap.timeline({ paused: true });\n` +
18021803
`tl.to("#s", { keyframes: { "0%": { width: 440, height: 294 } }, duration: 3 }, ${start});`;
1803-
const hold = parseGsapScript(sync(script)).animations.find((a) => a.method === "set");
1804+
const hold = parseGsapScript(sync(script, timeline)).animations.find(
1805+
(a) => a.method === "set",
1806+
);
18041807
expect(hold!.position).toBe(0);
18051808
expect(hold!.properties).toEqual({ width: 440, height: 294, data: "hf-hold" });
18061809
}
@@ -1814,11 +1817,33 @@ describe("keyframe mutations", () => {
18141817
(_, sync) => {
18151818
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
18161819
const keys = `"0%": { width: 100 }, "100%": { width: 400 }`;
1817-
expect(
1818-
sync(`${timeline}tl.to("#s", { keyframes: { ${keys} }, duration: 1 }, 2);`),
1819-
).not.toContain("hf-hold");
1820+
const twoKeys = `${timeline}tl.to("#s", { keyframes: { ${keys} }, duration: 1 }, 2);`;
1821+
expect(sync(twoKeys, timeline)).not.toContain("hf-hold");
18201822
const atLabel = `${timeline}tl.to("#s", { width: 300, duration: 1 }, 1);\ntl.to("#s", { keyframes: { "0%": { width: 500 } }, duration: 1 }, "later");`;
1821-
expect(sync(atLabel)).not.toContain("hf-hold");
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");
18221847
},
18231848
);
18241849

‎packages/parsers/src/gsapParser.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
classifyTweenPropertyGroup,
5656
GSAP_DEFAULT_DURATION,
5757
isXYPositionWrite,
58+
holdScope,
5859
keyframeHoldForAnimation,
5960
} from "./gsapConstants";
6061
import type { PropertyGroupName } from "./gsapConstants";
@@ -1866,7 +1867,15 @@ export function isStudioHoldSet(anim: GsapAnimation): boolean {
18661867
* authored pre-tween behavior. A tween already starting at 0 needs no hold, unless
18671868
* 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);
@@ -1885,8 +1894,12 @@ export function syncPositionHoldsBeforeKeyframes(script: string): string {
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 = keyframeHoldForAnimation(anim, reparsed.animations);
1902+
const posProps = keyframeHoldForAnimation(anim, reparsed.animations, scope);
18901903
if (!posProps) continue;
18911904
result = insertInheritedStateSet(result, anim.targetSelector, 0, {
18921905
...posProps,

‎packages/parsers/src/gsapWriterAcorn.trailingSpan.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,19 @@ describe("trimTrailingKeyframeSpans", () => {
2828
});
2929
});
3030

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+
3144
it("leaves a chained tween the mutation did not touch when it edits the link before", () => {
3245
const chain = (x: number) =>
3346
`tl.to("#a", { x: ${x}, duration: 1 }, 0).to("#t", { keyframes: { "0%": { x: 300 }, "50%": { x: 48 } }, duration: 2 }, 1);`;
@@ -56,6 +69,13 @@ describe("trimTrailingKeyframeSpans", () => {
5669
"every tween when one is placed after another's end",
5770
`${shortOfEnd}\ntl.to("#u", { opacity: 1, duration: 1 }, ">");`,
5871
],
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);`],
5979
])("leaves %s", (_, written) => {
6080
expect(trimTrailingKeyframeSpans(timeline, timeline + written)).toBe(timeline + written);
6181
});

‎packages/parsers/src/gsapWriterAcorn.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
classifyPropertyGroup,
3030
isTweenConfigKey,
3131
isXYPositionWrite,
32+
holdScope,
3233
keyframeHoldForAnimation,
3334
} from "./gsapConstants.js";
3435
import type { PropertyGroupName } from "./gsapConstants.js";
@@ -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 = keyframeHoldForAnimation(animation, animations);
2508+
const position = keyframeHoldForAnimation(animation, animations, scope);
25022509
if (!position) continue;
25032510
result = insertInheritedStateSetInScript(result, animation.targetSelector, 0, {
25042511
...position,
@@ -2511,6 +2518,9 @@ export function syncPositionHoldsBeforeKeyframes(script: string): string {
25112518
const roundPct = (pct: number) => Math.round(pct * 1000) / 1000;
25122519

25132520
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*:/;
25142524

25152525
/** A percentage keyframe list whose timing is known and whose run is linear, so a trim keeps its render. */
25162526
function linearPercentageKeys(animation: GsapAnimation) {
@@ -2551,9 +2561,10 @@ function trailingSpanTrim(animation: GsapAnimation) {
25512561
export function trimTrailingKeyframeSpans(previous: string, script: string): string {
25522562
if (script === previous) return script;
25532563
const located = parseGsapScriptAcornForWrite(script)?.located ?? [];
2554-
// A shorter tween moves every tween placed after its end ("+=", ">", no position).
2555-
if (located.some(({ animation: a }) => a.implicitPosition || typeof a.position !== "number"))
2556-
return script;
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;
25572568
const trims = located.flatMap((entry) => {
25582569
const trim = trailingSpanTrim(entry.animation);
25592570
return trim ? [{ entry, trim }] : [];

‎packages/studio-server/src/helpers/compositionInputs.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { readFileSync } from "node:fs";
22
import { createHash } from "node:crypto";
3+
import { parseHTML } from "linkedom";
34
import { createProjectSignature } from "./projectSignature.js";
45
import { rootHeadContent } from "./subComposition.js";
56
import { resolveWithinProject } from "./safePath.js";
7+
import { descendants } from "./compositionInsertion.js";
68

79
const ROOT_COMPOSITION = "index.html";
810
// A regex, not a parser: a stray match (say, inside a script string) only widens what a
@@ -49,6 +51,33 @@ function closureOf(read: SourceReader, compPath: string): Set<string> {
4951
return closure;
5052
}
5153

54+
/** Whether `compPath` lasts as long as its own timeline (so a trim there could end the film early). */
55+
export function lengthIsTimeline(projectDir: string, compPath: string): boolean {
56+
const read = projectReader(projectDir);
57+
const documents = new Map<string, Document>();
58+
const elements = (path: string, selector: string) => {
59+
let document = documents.get(path);
60+
if (!document) documents.set(path, (document = parseHTML(read(path) ?? "").document));
61+
return descendants(document, selector);
62+
};
63+
const comp = normalizeSource(compPath) ?? compPath;
64+
const unsized = (path: string) => {
65+
const [root] = elements(path, "[data-composition-id]");
66+
return Boolean(root && !root.hasAttribute("data-duration"));
67+
};
68+
// An unsized root lasts as long as every timeline it plays, its compositions' included.
69+
if (unsized(ROOT_COMPOSITION)) return true;
70+
const hosts = [...closureOf(read, ROOT_COMPOSITION)].flatMap((path) =>
71+
elements(path, "[data-composition-src]").filter(
72+
(host) => normalizeSource(host.getAttribute("data-composition-src") ?? "") === comp,
73+
),
74+
);
75+
if (comp === ROOT_COMPOSITION || hosts.length === 0) return unsized(comp);
76+
return hosts.some(
77+
(host) => !host.hasAttribute("data-duration") && !host.hasAttribute("data-end"),
78+
);
79+
}
80+
5281
// ponytail: this and projectSignature's per-exclusion cache keep one small entry per (project,
5382
// composition) ever thumbnailed, never evicted; LRU them if a server ever holds thousands.
5483
const inputSignatures = new Map<string, { projectSignature: string; inputSignature: string }>();

‎packages/studio-server/src/helpers/compositionInsertion.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export class CompositionInsertionError extends Error {
1515
}
1616
}
1717

18-
function descendants(root: Document | Element, selector: string): Element[] {
18+
export function descendants(root: Document | Element, selector: string): Element[] {
1919
const found = Array.from(root.querySelectorAll(selector));
2020
for (const template of root.querySelectorAll("template")) {
2121
found.push(...descendants(template, selector));

0 commit comments

Comments
 (0)