refactor(studio): extract shared timeline components and deduplicate code (#1329)

Extract shared utilities to reduce duplication across timeline components:

- PlayheadIndicator: shared playhead rendering (was duplicated in
  TimelineCanvas and TimelineEditorNotice)
- useContextMenuDismiss: outside-click/Escape dismiss pattern (was
  duplicated in ClipContextMenu and KeyframeDiamondContextMenu)
- TimelineCallbacks: shared callback interfaces for drop and edit
  operations (was duplicated in NLELayout and Timeline props)
- useTimelineZoom: consolidated zoom store selectors
- timelineElementSplit: shared canSplitElement, buildPatchTarget, and
  readFileContent utilities
- gsapParser.test-helpers: shared test utilities for parser specs
This commit is contained in:
Miguel Ángel
2026-06-10 23:45:03 -04:00
committed by GitHub
parent 06426b5014
commit ab08260201
12 changed files with 373 additions and 157 deletions
@@ -7,6 +7,13 @@ import {
removeAnimationFromScript,
} from "./gsapParser.js";
import type { ParsedGsap } from "./gsapParser.js";
import {
parseAndSerialize,
parseSingleAnimation,
expectStaggerRaw,
expectRawWithResolvable,
expectSingleAnimPosition,
} from "./gsapParser.test-helpers.js";
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -232,16 +239,15 @@ describe("3. Extreme values", () => {
});
it("Infinity literal", () => {
const script = `
expectRawWithResolvable(
`
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: Infinity, y: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Infinity is an Identifier, not a NumericLiteral — should be __raw
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
expect(result.animations[0].properties.y).toBe(50);
`,
"x",
"y",
50,
);
});
});
@@ -298,16 +304,8 @@ describe("5. Deeply nested objects", () => {
const tl = gsap.timeline({ paused: true });
tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].extras).toBeDefined();
expect(result.animations[0].extras!.stagger).toBeDefined();
// stagger should be __raw: containing the nested object source
const stagger = String(result.animations[0].extras!.stagger);
expect(stagger.startsWith("__raw:")).toBe(true);
expect(stagger).toContain("amount");
expect(stagger).toContain("grid");
expect(stagger).toContain("center");
const anim = parseSingleAnimation(script);
expectStaggerRaw(anim, "amount", "grid", "center");
});
it("complex stagger survives round-trip serialization", () => {
@@ -315,11 +313,7 @@ describe("5. Deeply nested objects", () => {
const tl = gsap.timeline({ paused: true });
tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0);
`;
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
const { serialized } = parseAndSerialize(script);
expect(serialized).toContain("stagger:");
expect(serialized).toContain("amount");
expect(serialized).toContain("grid");
@@ -380,17 +374,16 @@ describe("7. Template literals in values", () => {
});
it("template literal with expression becomes __raw", () => {
const script = `
expectRawWithResolvable(
`
const val = 100;
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: \`\${val}px\`, y: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Template literal with expressions is not resolvable
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
expect(result.animations[0].properties.y).toBe(50);
`,
"x",
"y",
50,
);
});
});
@@ -472,17 +465,15 @@ describe("9. Comments everywhere", () => {
describe("10. Arrow functions as values", () => {
it("arrow function property becomes __raw", () => {
const script = `
expectRawWithResolvable(
`
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Arrow function is not resolvable
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
// Resolvable values still work
expect(result.animations[0].properties.opacity).toBe(1);
`,
"x",
"opacity",
1,
);
});
it("arrow function in stagger becomes __raw extra", () => {
@@ -490,10 +481,8 @@ describe("10. Arrow functions as values", () => {
const tl = gsap.timeline({ paused: true });
tl.to(".items", { opacity: 1, duration: 0.5, stagger: (i) => i * 0.1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].extras).toBeDefined();
const stagger = String(result.animations[0].extras!.stagger);
expect(stagger.startsWith("__raw:")).toBe(true);
const anim = parseSingleAnimation(script);
expectStaggerRaw(anim);
});
it("arrow function round-trips via serialization", () => {
@@ -501,11 +490,7 @@ describe("10. Arrow functions as values", () => {
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0);
`;
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
const { serialized } = parseAndSerialize(script);
// The raw arrow function should be emitted without quotes
expect(serialized).toContain("(i) => i * 50");
expect(serialized).not.toContain('"(i) => i * 50"');
@@ -534,28 +519,26 @@ describe("11. Spread operator", () => {
describe("12. Conditional expressions", () => {
it("ternary expression becomes __raw", () => {
const script = `
expectRawWithResolvable(
`
const condition = true;
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: condition ? 100 : 200, y: 50, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// ConditionalExpression is not handled by resolveNode
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
expect(result.animations[0].properties.y).toBe(50);
`,
"x",
"y",
50,
);
});
it("conditional in position argument defaults to 0", () => {
const script = `
expectSingleAnimPosition(
`
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, someCondition ? 0 : 2);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// Position can't be resolved — falls back to 0
expect(result.animations[0].position).toBe(0);
`,
0,
);
});
});
@@ -930,17 +913,16 @@ describe("Additional edge cases", () => {
});
it("scope resolution: binary expression with one unresolvable side", () => {
const script = `
expectRawWithResolvable(
`
const BASE = 100;
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: BASE + unknownVar, y: BASE * 2, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
// BASE + unknownVar: left is 100, right is undefined => result is undefined => __raw
const xVal = result.animations[0].properties.x;
expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true);
// BASE * 2: both resolved => 200
expect(result.animations[0].properties.y).toBe(200);
`,
"x",
"y",
200,
);
});
it("negative position in ID generation", () => {
@@ -954,13 +936,12 @@ describe("Additional edge cases", () => {
});
it("fromTo with no position arg defaults to 0", () => {
const script = `
expectSingleAnimPosition(
`
const tl = gsap.timeline({ paused: true });
tl.fromTo("#el", { opacity: 0 }, { opacity: 1, duration: 1 });
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
// For fromTo, position is args[3] which is undefined => defaults to 0
expect(result.animations[0].position).toBe(0);
`,
0,
);
});
});
@@ -0,0 +1,131 @@
// fallow-ignore-file dead-code
import { expect } from "vitest";
import {
parseGsapScript,
serializeGsapAnimations,
convertToKeyframesInScript,
} from "./gsapParser.js";
import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapParser.js";
/**
* Parse a script and serialize the result, returning both the parsed output
* and the serialized string for assertion. Shared across gsapParser.test.ts
* and gsapParser.stress.test.ts.
*/
export function parseAndSerialize(script: string) {
const parsed = parseGsapScript(script);
const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
return { parsed, serialized };
}
/**
* Parse a script expecting exactly one animation, and return it directly.
*/
export function parseSingleAnimation(script: string): GsapAnimation {
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
return result.animations[0]!;
}
/**
* Assert that a parsed animation's stagger extra exists and contains
* the expected substrings (as a __raw: prefixed string).
*/
export function expectStaggerRaw(anim: GsapAnimation, ...expectedSubstrings: string[]): void {
expect(anim.extras).toBeDefined();
expect(anim.extras!.stagger).toBeDefined();
const stagger = String(anim.extras!.stagger);
expect(stagger.startsWith("__raw:")).toBe(true);
for (const sub of expectedSubstrings) {
expect(stagger).toContain(sub);
}
}
/**
* Assert a single keyframe's percentage, properties, and optional ease.
*/
export function expectKeyframe(
kf: GsapPercentageKeyframe,
percentage: number,
properties: Record<string, number | string>,
ease?: string,
): void {
expect(kf.percentage).toBe(percentage);
for (const [key, value] of Object.entries(properties)) {
expect(kf.properties[key]).toBe(value);
}
if (ease !== undefined) {
expect(kf.ease).toBe(ease);
}
}
/**
* Assert that an animation has a defined keyframes block with the expected format
* and count, and return the keyframes array for further assertions.
*/
export function expectKeyframesFormat(
anim: GsapAnimation,
format: string,
count: number,
): GsapPercentageKeyframe[] {
expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.format).toBe(format);
expect(anim.keyframes!.keyframes).toHaveLength(count);
return anim.keyframes!.keyframes;
}
/**
* Parse a script expecting one animation, assert that `rawProp` is a __raw: string
* and `resolvableProp` has the expected value.
*/
export function expectRawWithResolvable(
script: string,
rawProp: string,
resolvableProp: string,
resolvableValue: number | string,
): void {
const anim = parseSingleAnimation(script);
const val = anim.properties[rawProp];
expect(typeof val === "string" && val.startsWith("__raw:")).toBe(true);
expect(anim.properties[resolvableProp]).toBe(resolvableValue);
}
/**
* Parse a script expecting one animation, assert that `position` matches the expected value.
*/
export function expectSingleAnimPosition(script: string, position: number): void {
const anim = parseSingleAnimation(script);
expect(anim.position).toBe(position);
}
/**
* Parse a script, get the first animation id, run convertToKeyframesInScript,
* reparse, and return the first animation for assertion.
*/
export function convertAndReparse(
script: string,
runtimeValues?: Record<string, number | string>,
): GsapAnimation {
const id = parseSingleAnimation(script).id;
const updated = convertToKeyframesInScript(script, id, runtimeValues);
return parseSingleAnimation(updated);
}
/**
* Parse a script, return the first animation and run a split-related reparse.
* Asserts the reparse result has exactly `expectedCount` animations and returns
* the selector of the first animation.
*/
export function parseSplitAndAssert(
script: string,
splitFn: (s: string) => string,
expectedCount: number,
): string[] {
const result = splitFn(script);
const parsed = parseGsapScript(result);
expect(parsed.animations).toHaveLength(expectedCount);
return parsed.animations.map((a) => a.targetSelector);
}