diff --git a/packages/core/src/parsers/__goldens__/complex.parsed.json b/packages/core/src/parsers/__goldens__/complex.parsed.json index 92c5def44..af592ef9d 100644 --- a/packages/core/src/parsers/__goldens__/complex.parsed.json +++ b/packages/core/src/parsers/__goldens__/complex.parsed.json @@ -1,55 +1,62 @@ { "animations": [ { - "targetSelector": ".ambient-line", + "targetSelector": ".headline span", "method": "from", - "position": 0.16, + "position": 0.05, "properties": { - "scaleX": 0, + "y": 46, "opacity": 0 }, - "duration": 0.42, + "duration": 0.38, + "ease": "back.out(1.35)", "extras": { - "stagger": "__raw:0.08" + "stagger": "__raw:0.055" }, - "id": ".ambient-line-from-160" + "resolvedStart": 0.05, + "id": ".headline span-from-50" }, { - "targetSelector": ".ambient-word", + "targetSelector": ".headline .sub", "method": "from", - "position": 0.08, + "position": 0.2, "properties": { - "scale": 0.92, + "y": 20, "opacity": 0 }, - "duration": 0.5, - "id": ".ambient-word-from-80" + "duration": 0.28, + "ease": "power3.out", + "resolvedStart": 0.2, + "id": ".headline .sub-from-200" }, { - "targetSelector": ".headline .sub", + "targetSelector": ".ambient-word", "method": "from", - "position": 0.2, + "position": 0.08, "properties": { - "y": 20, + "scale": 0.92, "opacity": 0 }, - "duration": 0.28, - "id": ".headline .sub-from-200" + "duration": 0.5, + "ease": "power3.out", + "resolvedStart": 0.08, + "id": ".ambient-word-from-80" }, { - "targetSelector": ".headline span", + "targetSelector": ".ambient-line", "method": "from", - "position": 0.05, + "position": 0.16, "properties": { - "y": 46, + "scaleX": 0, "opacity": 0 }, - "duration": 0.38, - "ease": "back.out(1.35)", + "duration": 0.42, + "ease": "power3.out", "extras": { - "stagger": "__raw:0.055" + "stagger": "__raw:0.08" }, - "id": ".headline span-from-50" + "resolvedStart": 0.16, + "id": ".ambient-line-from-160" } ], "timelineVar": "tl", diff --git a/packages/core/src/parsers/__goldens__/complex.serialized.js b/packages/core/src/parsers/__goldens__/complex.serialized.js index 2a1516fee..16e8313db 100644 --- a/packages/core/src/parsers/__goldens__/complex.serialized.js +++ b/packages/core/src/parsers/__goldens__/complex.serialized.js @@ -3,8 +3,8 @@ gsap.defaults({ force3D: true }); const tl = gsap.timeline({ paused: true, defaults: { duration: 0.45, ease: "power3.out" } }); tl.from(".headline span", { y: 46, opacity: 0, duration: 0.38, ease: "back.out(1.35)", stagger: 0.055 }, 0.05); - tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5 }, 0.08); - tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, stagger: 0.08 }, 0.16); - tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28 }, 0.2); + tl.from(".ambient-word", { scale: 0.92, opacity: 0, duration: 0.5, ease: "power3.out" }, 0.08); + tl.from(".ambient-line", { scaleX: 0, opacity: 0, duration: 0.42, ease: "power3.out", stagger: 0.08 }, 0.16); + tl.from(".headline .sub", { y: 20, opacity: 0, duration: 0.28, ease: "power3.out" }, 0.2); window.__timelines["vpn-youtube-spot"] = tl; \ No newline at end of file diff --git a/packages/core/src/parsers/__goldens__/fromto.parsed.json b/packages/core/src/parsers/__goldens__/fromto.parsed.json index 1431064c3..8c8637d49 100644 --- a/packages/core/src/parsers/__goldens__/fromto.parsed.json +++ b/packages/core/src/parsers/__goldens__/fromto.parsed.json @@ -14,6 +14,7 @@ }, "duration": 0.6, "ease": "power3.out", + "resolvedStart": 0.1, "id": "#hero-fromTo-100" }, { @@ -29,6 +30,7 @@ "opacity": 0 }, "duration": 0.45, + "resolvedStart": 0.5, "id": "#caption-fromTo-500" } ], diff --git a/packages/core/src/parsers/__goldens__/minimal.parsed.json b/packages/core/src/parsers/__goldens__/minimal.parsed.json index f20fb8fb7..cbfa33de9 100644 --- a/packages/core/src/parsers/__goldens__/minimal.parsed.json +++ b/packages/core/src/parsers/__goldens__/minimal.parsed.json @@ -10,6 +10,7 @@ }, "duration": 0.5, "ease": "power3.out", + "resolvedStart": 0.2, "id": "#notification-to-200" }, { @@ -22,6 +23,7 @@ }, "duration": 0.3, "ease": "power3.in", + "resolvedStart": 4.2, "id": "#notification-to-4200" } ], diff --git a/packages/core/src/parsers/__goldens__/moderate.parsed.json b/packages/core/src/parsers/__goldens__/moderate.parsed.json index 1c4e39127..8edb3f350 100644 --- a/packages/core/src/parsers/__goldens__/moderate.parsed.json +++ b/packages/core/src/parsers/__goldens__/moderate.parsed.json @@ -10,6 +10,7 @@ }, "duration": 0.5, "ease": "power3.out", + "resolvedStart": 0.1, "id": "#card-to-100" }, { @@ -21,7 +22,9 @@ }, "duration": 0.15, "ease": "power2.out", - "id": "#subscribe-btn-to-1000" + "propertyGroup": "scale", + "resolvedStart": 1, + "id": "#subscribe-btn-to-1000-scale" }, { "targetSelector": "#subscribe-btn", @@ -32,7 +35,9 @@ }, "duration": 0.4, "ease": "elastic.out(1, 0.4)", - "id": "#subscribe-btn-to-1150" + "propertyGroup": "scale", + "resolvedStart": 1.15, + "id": "#subscribe-btn-to-1150-scale" }, { "targetSelector": "#btn-subscribe", @@ -43,7 +48,9 @@ }, "duration": 0.08, "ease": "none", - "id": "#btn-subscribe-to-1150" + "propertyGroup": "visual", + "resolvedStart": 1.15, + "id": "#btn-subscribe-to-1150-visual" }, { "targetSelector": "#btn-subscribed", @@ -54,7 +61,9 @@ }, "duration": 0.08, "ease": "none", - "id": "#btn-subscribed-to-1180" + "propertyGroup": "visual", + "resolvedStart": 1.18, + "id": "#btn-subscribed-to-1180-visual" }, { "targetSelector": "#card", @@ -66,6 +75,7 @@ }, "duration": 0.25, "ease": "power3.in", + "resolvedStart": 3.8, "id": "#card-to-3800" } ], diff --git a/packages/core/src/parsers/gsapConstants.ts b/packages/core/src/parsers/gsapConstants.ts index 9cae4cc9f..015362395 100644 --- a/packages/core/src/parsers/gsapConstants.ts +++ b/packages/core/src/parsers/gsapConstants.ts @@ -45,6 +45,46 @@ export const SUPPORTED_PROPS = [ "innerText", ]; +// ── Property Groups ───────────────────────────────────────────────────────── +// Each group maps to an independent GSAP tween so editing one property +// (e.g. drag → x/y) never contaminates another (e.g. scale, rotation). + +export type PropertyGroupName = "position" | "scale" | "size" | "rotation" | "visual" | "other"; + +export const PROPERTY_GROUPS: Record> = { + position: new Set(["x", "y", "xPercent", "yPercent"]), + scale: new Set(["scale", "scaleX", "scaleY"]), + size: new Set(["width", "height"]), + rotation: new Set(["rotation", "skewX", "skewY"]), + visual: new Set(["opacity", "autoAlpha"]), + other: new Set(), +}; + +const PROP_TO_GROUP = new Map(); +for (const [group, props] of Object.entries(PROPERTY_GROUPS) as [ + PropertyGroupName, + ReadonlySet, +][]) { + for (const p of props) PROP_TO_GROUP.set(p, group); +} + +export function classifyPropertyGroup(prop: string): PropertyGroupName { + return PROP_TO_GROUP.get(prop) ?? "other"; +} + +export function classifyTweenPropertyGroup( + properties: Record, +): PropertyGroupName | undefined { + const groups = new Set(); + for (const key of Object.keys(properties)) { + if (key === "transformOrigin") continue; + const g = classifyPropertyGroup(key); + groups.add(g); + } + if (groups.size === 1) return groups.values().next().value; + return undefined; +} + export const SUPPORTED_EASES = [ "none", "power1.in", diff --git a/packages/core/src/parsers/gsapParser.stress.test.ts b/packages/core/src/parsers/gsapParser.stress.test.ts index 95c1ef047..c99b88415 100644 --- a/packages/core/src/parsers/gsapParser.stress.test.ts +++ b/packages/core/src/parsers/gsapParser.stress.test.ts @@ -658,9 +658,9 @@ describe("14. ID collision", () => { const ids = result.animations.map((a) => a.id); // All IDs must be unique expect(new Set(ids).size).toBe(3); - expect(ids[0]).toBe("#el-to-0"); - expect(ids[1]).toBe("#el-to-0-2"); - expect(ids[2]).toBe("#el-to-0-3"); + expect(ids[0]).toBe("#el-to-0-visual"); + expect(ids[1]).toBe("#el-to-0-position"); + expect(ids[2]).toBe("#el-to-0-position-2"); }); it("disambiguated IDs are stable across parses", () => { @@ -932,7 +932,7 @@ describe("Additional edge cases", () => { `; const result = parseGsapScript(script); // ID uses Math.round(position * 1000) for numeric positions - expect(result.animations[0].id).toBe("#el-to--2500"); + expect(result.animations[0].id).toBe("#el-to--2500-position"); }); it("fromTo with no position arg defaults to 0", () => { diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index 85c204118..0ef8797b6 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -18,8 +18,10 @@ import { removeAllKeyframesFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, + splitIntoPropertyGroups, } from "./gsapParser.js"; import type { GsapAnimation } from "./gsapParser.js"; +import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js"; import type { Keyframe } from "../core.types"; import { parseAndSerialize, @@ -260,8 +262,8 @@ describe("parseGsapScript", () => { expect(result1.animations[1].id).toBe(result2.animations[1].id); // IDs encode selector, method, and position - expect(result1.animations[0].id).toBe("#el1-to-0"); - expect(result1.animations[1].id).toBe("#el2-to-1000"); + expect(result1.animations[0].id).toBe("#el1-to-0-visual"); + expect(result1.animations[1].id).toBe("#el2-to-1000-position"); }); it("disambiguates colliding IDs with a suffix", () => { @@ -272,8 +274,8 @@ describe("parseGsapScript", () => { `; const result = parseGsapScript(script); - expect(result.animations[0].id).toBe("#el1-to-0"); - expect(result.animations[1].id).toBe("#el1-to-0-2"); + expect(result.animations[0].id).toBe("#el1-to-0-visual"); + expect(result.animations[1].id).toBe("#el1-to-0-visual-2"); }); it("uses string position in ID for relative positions", () => { @@ -283,7 +285,219 @@ describe("parseGsapScript", () => { `; const result = parseGsapScript(script); - expect(result.animations[0].id).toBe("#el1-to-+=1"); + expect(result.animations[0].id).toBe("#el1-to-+=1-visual"); + }); +}); + +describe("resolvedStart — timeline position resolution", () => { + it("resolves chained from() tweens with relative positions (sdk-test pattern)", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out" } }); + tl.from("#headline", { duration: 0.6, scale: 0.92, transformOrigin: "left center" }) + .from("#subtext", { duration: 0.5, scale: 0.92, transformOrigin: "left center" }, "-=0.3") + .from("#box", { duration: 0.5, scale: 0.5, transformOrigin: "center center" }, "-=0.3"); + `; + const result = parseGsapScript(script); + + expect(result.animations).toHaveLength(3); + // Execution order: #headline, #subtext, #box + expect(result.animations[0].targetSelector).toBe("#headline"); + expect(result.animations[1].targetSelector).toBe("#subtext"); + expect(result.animations[2].targetSelector).toBe("#box"); + + // #headline: implicit position → starts at 0, ends at 0.6 + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[0].implicitPosition).toBe(true); + + // #subtext: "-=0.3" from cursor (0.6) → 0.6 - 0.3 = 0.3 + expect(result.animations[1].resolvedStart).toBe(0.3); + + // #box: "-=0.3" from cursor (max(0.6, 0.3+0.5=0.8) = 0.8) → 0.8 - 0.3 = 0.5 + expect(result.animations[2].resolvedStart).toBe(0.5); + }); + + it("resolves += and < positions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, "+=1"); + tl.to("#el2", { x: 100, duration: 1 }, "<"); + tl.to("#el3", { y: 50, duration: 0.3 }, "-=0.5"); + `; + const result = parseGsapScript(script); + + // #el1: "+=1" from cursor (0) → 0 + 1 = 1, ends at 1.5 + expect(result.animations[0].resolvedStart).toBe(1); + + // #el2: "<" = previous start → 1 + expect(result.animations[1].resolvedStart).toBe(1); + + // #el3: "-=0.5" from cursor (max(1.5, 1+1=2) = 2) → 2 - 0.5 = 1.5 + expect(result.animations[2].resolvedStart).toBe(1.5); + }); + + it("resolves numeric positions directly", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }, 0); + tl.to("#el2", { x: 100, duration: 1 }, 2); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[1].resolvedStart).toBe(2); + }); + + it("resolves implicit sequential positions", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.5 }) + .to("#el2", { x: 100, duration: 1 }) + .to("#el3", { y: 50, duration: 0.3 }); + `; + const result = parseGsapScript(script); + + // #el1: implicit → cursor=0, ends at 0.5 + expect(result.animations[0].resolvedStart).toBe(0); + expect(result.animations[0].implicitPosition).toBe(true); + + // #el2: implicit → cursor=0.5, ends at 1.5 + expect(result.animations[1].resolvedStart).toBe(0.5); + expect(result.animations[1].implicitPosition).toBe(true); + + // #el3: implicit → cursor=1.5, ends at 1.8 + expect(result.animations[2].resolvedStart).toBe(1.5); + expect(result.animations[2].implicitPosition).toBe(true); + }); + + it("clamps negative resolvedStart to 0", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1, duration: 0.2 }); + tl.to("#el2", { x: 100, duration: 1 }, "-=5"); + `; + const result = parseGsapScript(script); + + expect(result.animations[1].resolvedStart).toBe(0); + }); + + it("uses GSAP default duration (0.5) for tweens with no explicit duration", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#el1", { opacity: 1 }) + .to("#el2", { x: 100 }); + `; + const result = parseGsapScript(script); + + // #el1: starts at 0, duration defaults to 0.5 → cursor at 0.5 + expect(result.animations[0].resolvedStart).toBe(0); + // #el2: starts at cursor = 0.5 + expect(result.animations[1].resolvedStart).toBe(0.5); + }); + + it("treats set() as zero-duration", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.set("#el1", { opacity: 0 }); + tl.to("#el2", { opacity: 1, duration: 1 }); + `; + const result = parseGsapScript(script); + + // set() at 0, zero duration → cursor stays at 0 + expect(result.animations[0].resolvedStart).toBe(0); + // next tween starts at cursor = 0 + expect(result.animations[1].resolvedStart).toBe(0); + }); +}); + +describe("timeline defaults inheritance", () => { + it("inherits ease and duration from timeline defaults onto tweens", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } }); + tl.from("#headline", { scale: 0.92, transformOrigin: "left center" }) + .from("#subtext", { scale: 0.92 }, "-=0.3"); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].ease).toBe("power3.out"); + expect(result.animations[0].duration).toBe(0.6); + expect(result.animations[1].ease).toBe("power3.out"); + expect(result.animations[1].duration).toBe(0.6); + }); + + it("does not override explicit ease/duration on individual tweens", () => { + const script = ` + const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 0.6 } }); + tl.to("#el1", { opacity: 1, duration: 1, ease: "none" }); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].ease).toBe("none"); + expect(result.animations[0].duration).toBe(1); + }); + + it("uses inherited duration for position resolution", () => { + const script = ` + const tl = gsap.timeline({ defaults: { duration: 0.8 } }); + tl.from("#a", { scale: 0.5 }) + .from("#b", { scale: 0.5 }); + `; + const result = parseGsapScript(script); + + // #a starts at 0, duration 0.8 → cursor at 0.8 + expect(result.animations[0].resolvedStart).toBe(0); + // #b starts at cursor = 0.8 + expect(result.animations[1].resolvedStart).toBe(0.8); + }); +}); + +describe("property group classification", () => { + it("classifies individual properties into groups", () => { + expect(classifyPropertyGroup("x")).toBe("position"); + expect(classifyPropertyGroup("y")).toBe("position"); + expect(classifyPropertyGroup("xPercent")).toBe("position"); + expect(classifyPropertyGroup("scale")).toBe("scale"); + expect(classifyPropertyGroup("scaleX")).toBe("scale"); + expect(classifyPropertyGroup("width")).toBe("size"); + expect(classifyPropertyGroup("height")).toBe("size"); + expect(classifyPropertyGroup("rotation")).toBe("rotation"); + expect(classifyPropertyGroup("skewX")).toBe("rotation"); + expect(classifyPropertyGroup("opacity")).toBe("visual"); + expect(classifyPropertyGroup("autoAlpha")).toBe("visual"); + expect(classifyPropertyGroup("borderRadius")).toBe("other"); + expect(classifyPropertyGroup("fontSize")).toBe("other"); + }); + + it("classifies a pure position tween", () => { + expect(classifyTweenPropertyGroup({ x: 100, y: 50 })).toBe("position"); + }); + + it("classifies a pure scale tween", () => { + expect(classifyTweenPropertyGroup({ scale: 0.5 })).toBe("scale"); + }); + + it("classifies scale + transformOrigin as scale (transformOrigin follows group)", () => { + expect(classifyTweenPropertyGroup({ scale: 0.5, transformOrigin: "center center" })).toBe( + "scale", + ); + }); + + it("returns undefined for mixed-group tweens", () => { + expect(classifyTweenPropertyGroup({ x: 100, scale: 0.5 })).toBeUndefined(); + expect(classifyTweenPropertyGroup({ x: 100, opacity: 0 })).toBeUndefined(); + }); + + it("classifies tweens during parsing", () => { + const script = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#a", { x: 100, y: 50, duration: 1 }, 0); + tl.to("#b", { scale: 0.5, duration: 0.5 }, 0); + tl.to("#c", { x: 100, scale: 0.5, opacity: 0, duration: 1 }, 0); + `; + const result = parseGsapScript(script); + + expect(result.animations[0].propertyGroup).toBe("position"); + expect(result.animations[1].propertyGroup).toBe("scale"); + expect(result.animations[2].propertyGroup).toBeUndefined(); }); }); @@ -1223,11 +1437,12 @@ describe("native GSAP keyframes parsing", () => { const kfs = expectKeyframesFormat(anim, "object-array", 3); // Total duration = 0.5 + 1 + 0.8 = 2.3 - expectKeyframe(kfs[0], 0, { x: 0, opacity: 1 }); - // Second: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22 - expectKeyframe(kfs[1], 22, { x: 100 }, "power2.out"); - // Third: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65 - expectKeyframe(kfs[2], 65, { x: 200 }); + // First: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22 + expectKeyframe(kfs[0], 22, { x: 0, opacity: 1 }); + // Second: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65 + expectKeyframe(kfs[1], 65, { x: 100 }, "power2.out"); + // Third: cumulative = 2.3, pct = round(2.3/2.3 * 100) = 100 + expectKeyframe(kfs[2], 100, { x: 200 }); }); it("parses simple array keyframes format", () => { @@ -1944,3 +2159,119 @@ tl.to("#el1", { y: 200, duration: 1 }, 3);`; expect(result.skippedSelectors).toEqual([".el1"]); }); }); + +describe("splitIntoPropertyGroups", () => { + const baseScript = `const tl = gsap.timeline({ paused: true });`; + + it("splits flat to({x, y, scale, rotation}) into 3 group tweens", () => { + const script = `${baseScript}\ntl.to("#el", { x: 100, y: 50, scale: 1.5, rotation: 45, duration: 1 }, 0);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + // Should produce 3 tweens: position (x,y), scale, rotation + expect(reParsed.animations).toHaveLength(3); + expect(result.ids).toHaveLength(3); + + const groups = new Set(reParsed.animations.map((a) => a.propertyGroup)); + expect(groups.has("position")).toBe(true); + expect(groups.has("scale")).toBe(true); + expect(groups.has("rotation")).toBe(true); + + const posAnim = reParsed.animations.find((a) => a.propertyGroup === "position")!; + expect(posAnim.properties.x).toBe(100); + expect(posAnim.properties.y).toBe(50); + expect(posAnim.properties.scale).toBeUndefined(); + + const scaleAnim = reParsed.animations.find((a) => a.propertyGroup === "scale")!; + expect(scaleAnim.properties.scale).toBe(1.5); + expect(scaleAnim.properties.x).toBeUndefined(); + + const rotAnim = reParsed.animations.find((a) => a.propertyGroup === "rotation")!; + expect(rotAnim.properties.rotation).toBe(45); + }); + + it("splits flat from({scale, opacity}) into 2 group tweens", () => { + const script = `${baseScript}\ntl.from("#el", { scale: 0.5, opacity: 0, duration: 0.5 }, 1);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + expect(reParsed.animations).toHaveLength(2); + expect(result.ids).toHaveLength(2); + + const groups = new Set(reParsed.animations.map((a) => a.propertyGroup)); + expect(groups.has("scale")).toBe(true); + expect(groups.has("visual")).toBe(true); + }); + + it("returns same ID for single-group tween (no split)", () => { + const script = `${baseScript}\ntl.to("#el", { x: 100, y: 50, duration: 1 }, 0);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + expect(result.ids).toEqual([animId]); + // Script should be unchanged + const reParsed = parseGsapScript(result.script); + expect(reParsed.animations).toHaveLength(1); + }); + + it("preserves position, duration, ease on split tweens", () => { + const script = `${baseScript}\ntl.to("#el", { x: 100, scale: 2, duration: 0.8, ease: "power2.out" }, 1.5);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + expect(reParsed.animations).toHaveLength(2); + for (const anim of reParsed.animations) { + expect(anim.position).toBe(1.5); + expect(anim.duration).toBe(0.8); + expect(anim.ease).toBe("power2.out"); + } + }); + + it("splits keyframed tween: each group gets only its properties per keyframe", () => { + const script = `${baseScript}\ntl.to("#el", { keyframes: { "0%": { x: 0, scale: 1 }, "50%": { x: 50, scale: 1.5 }, "100%": { x: 100, scale: 2 } }, duration: 2 }, 0);`; + const parsed = parseGsapScript(script); + const animId = parsed.animations[0]!.id; + + const result = splitIntoPropertyGroups(script, animId); + const reParsed = parseGsapScript(result.script); + + expect(reParsed.animations).toHaveLength(2); + expect(result.ids).toHaveLength(2); + + // Both tweens are keyframed — identify them by the properties inside their keyframes. + const xAnim = reParsed.animations.find((a) => + a.keyframes?.keyframes.some((kf) => "x" in kf.properties), + )!; + const scaleAnim = reParsed.animations.find((a) => + a.keyframes?.keyframes.some((kf) => "scale" in kf.properties), + )!; + + expect(xAnim).toBeDefined(); + expect(xAnim.keyframes).toBeDefined(); + expect(xAnim.keyframes!.keyframes).toHaveLength(3); + // Position keyframes should have x but not scale + for (const kf of xAnim.keyframes!.keyframes) { + expect(kf.properties.x).toBeDefined(); + expect(kf.properties.scale).toBeUndefined(); + } + + expect(scaleAnim).toBeDefined(); + expect(scaleAnim.keyframes).toBeDefined(); + expect(scaleAnim.keyframes!.keyframes).toHaveLength(3); + // Scale keyframes should have scale but not x + for (const kf of scaleAnim.keyframes!.keyframes) { + expect(kf.properties.scale).toBeDefined(); + expect(kf.properties.x).toBeUndefined(); + } + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index e9f6ea6d8..eaf962675 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -39,6 +39,14 @@ export { SUPPORTED_PROPS, SUPPORTED_EASES, } from "./gsapSerialize"; +export type { PropertyGroupName } from "./gsapConstants"; +export { + PROPERTY_GROUPS, + classifyPropertyGroup, + classifyTweenPropertyGroup, +} from "./gsapConstants"; +import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants"; +import type { PropertyGroupName } from "./gsapConstants"; export { generateSpringEaseData, SPRING_PRESETS } from "./springEase"; export type { SpringPreset } from "./springEase"; @@ -343,19 +351,47 @@ function isGsapTimelineCall(node: any): boolean { ); } +interface TimelineDefaults { + ease?: string; + duration?: number; +} + interface TimelineDetection { timelineVar: string | null; timelineCount: number; + defaults?: TimelineDefaults; } -function findTimelineVar(ast: any): TimelineDetection { +function extractTimelineDefaults( + callNode: any, + scope: ScopeBindings, +): TimelineDefaults | undefined { + const arg = callNode.arguments?.[0]; + if (!arg || arg.type !== "ObjectExpression") return undefined; + const defaultsProp = arg.properties?.find( + (p: any) => isObjectProperty(p) && propKeyName(p) === "defaults", + ); + if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression") return undefined; + const record = objectExpressionToRecord(defaultsProp.value, scope); + const result: TimelineDefaults = {}; + if (typeof record.ease === "string") result.ease = record.ease; + if (typeof record.duration === "number") result.duration = record.duration; + return Object.keys(result).length > 0 ? result : undefined; +} + +function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection { let timelineVar: string | null = null; let timelineCount = 0; + let defaults: TimelineDefaults | undefined; + const emptyScope: ScopeBindings = scope ?? new Map(); recast.types.visit(ast, { visitVariableDeclarator(path: any) { if (isGsapTimelineCall(path.node.init)) { timelineCount += 1; - if (!timelineVar) timelineVar = path.node.id?.name ?? null; + if (!timelineVar) { + timelineVar = path.node.id?.name ?? null; + defaults = extractTimelineDefaults(path.node.init, emptyScope); + } } this.traverse(path); }, @@ -365,12 +401,13 @@ function findTimelineVar(ast: any): TimelineDetection { if (!timelineVar) { const left = path.node.left; if (left?.type === "Identifier") timelineVar = left.name; + defaults = extractTimelineDefaults(path.node.right, emptyScope); } } this.traverse(path); }, }); - return { timelineVar, timelineCount }; + return { timelineVar, timelineCount, defaults }; } // ── Find All Tween Calls ──────────────────────────────────────────────────── @@ -632,13 +669,13 @@ function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyfram if (totalDuration > 0) { let cumulative = 0; for (const entry of raw) { + cumulative += entry.duration ?? 0; const percentage = Math.round((cumulative / totalDuration) * 100); keyframes.push({ percentage, properties: entry.properties, ...(entry.ease ? { ease: entry.ease } : {}), }); - cumulative += entry.duration ?? 0; } } else { for (let i = 0; i < raw.length; i++) { @@ -872,7 +909,8 @@ function tweenCallToAnimation( } } - const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0; + const hasPositionArg = !!call.positionArg; + const posVal = hasPositionArg ? extractLiteralValue(call.positionArg, scope) : 0; const position: number | string = typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0; let duration = typeof vars.duration === "number" ? vars.duration : undefined; @@ -891,6 +929,16 @@ function tweenCallToAnimation( duration, ease, }; + if (!hasPositionArg) anim.implicitPosition = true; + let group = classifyTweenPropertyGroup(properties); + if (!group && keyframesData) { + const kfProps: Record = {}; + for (const kf of keyframesData.keyframes) { + for (const k of Object.keys(kf.properties)) kfProps[k] = true; + } + group = classifyTweenPropertyGroup(kfProps); + } + if (group) anim.propertyGroup = group; if (Object.keys(extras).length > 0) anim.extras = extras; if (keyframesData) anim.keyframes = keyframesData; if (motionPathResult) anim.arcPath = motionPathResult.arcPath; @@ -899,8 +947,96 @@ function tweenCallToAnimation( return anim; } +// ── Timeline Position Resolution ────────────────────────────────────────── + +const GSAP_DEFAULT_DURATION = 0.5; + +// NOTE: Label-based positions (e.g. "myLabel+=0.5") are not yet resolved — +// they fall through to parseFloat which returns null for non-numeric strings. +function resolvePositionString(pos: string, cursor: number, prevStart: number): number | null { + const trimmed = pos.trim(); + if (trimmed === "") return cursor; + if (trimmed.startsWith("+=")) { + const n = Number.parseFloat(trimmed.slice(2)); + return Number.isFinite(n) ? cursor + n : null; + } + if (trimmed.startsWith("-=")) { + const n = Number.parseFloat(trimmed.slice(2)); + return Number.isFinite(n) ? cursor - n : null; + } + if (trimmed === "<") return prevStart; + if (trimmed === ">") return cursor; + if (trimmed.startsWith("<")) { + const n = Number.parseFloat(trimmed.slice(1)); + return Number.isFinite(n) ? prevStart + n : null; + } + if (trimmed.startsWith(">")) { + const n = Number.parseFloat(trimmed.slice(1)); + return Number.isFinite(n) ? cursor + n : null; + } + const n = Number.parseFloat(trimmed); + return Number.isFinite(n) ? n : null; +} + +function applyTimelineDefaults( + anims: Omit[], + defaults?: TimelineDefaults, +): void { + if (!defaults) return; + for (const anim of anims) { + if (anim.method === "set") continue; + if (anim.duration === undefined && defaults.duration !== undefined) { + anim.duration = defaults.duration; + } + if (anim.ease === undefined && defaults.ease !== undefined) { + anim.ease = defaults.ease; + } + } +} + +function resolveTimelinePositions(anims: Omit[]): void { + let cursor = 0; + let prevStart = 0; + for (const anim of anims) { + const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION); + let start: number | null; + + if (anim.implicitPosition) { + start = cursor; + } else if (typeof anim.position === "number") { + start = anim.position; + } else if (typeof anim.position === "string") { + start = resolvePositionString(anim.position, cursor, prevStart); + } else { + start = cursor; + } + + if (start != null) { + anim.resolvedStart = Math.max(0, start); + prevStart = anim.resolvedStart; + cursor = Math.max(cursor, anim.resolvedStart + duration); + } + } +} + +function sortBySourcePosition(calls: TweenCallInfo[]): void { + calls.sort((a, b) => { + const aLoc = a.node.callee?.property?.loc?.start; + const bLoc = b.node.callee?.property?.loc?.start; + if (!aLoc || !bLoc) return 0; + return aLoc.line - bLoc.line || aLoc.column - bLoc.column; + }); +} + // ── Stable ID Generation ─────────────────────────────────────────────────── +/** + * IDs are transient — recomputed on every parse, never persisted across sessions. + * They exist only in ephemeral request/response payloads, React component state, + * and the in-memory keyframe cache (rebuilt on every page load). No database, + * localStorage, or file stores animation IDs, so changing the ID format (e.g. + * adding a `-scale`/`-position` suffix) is safe. + */ function assignStableIds(anims: Omit[]): GsapAnimation[] { const counts = new Map(); return anims.map((anim) => { @@ -908,7 +1044,8 @@ function assignStableIds(anims: Omit[]): GsapAnimation[] { typeof anim.position === "number" ? String(Math.round(anim.position * 1000)) : String(anim.position); - const base = `${anim.targetSelector}-${anim.method}-${posKey}`; + const groupSuffix = anim.propertyGroup ? `-${anim.propertyGroup}` : ""; + const base = `${anim.targetSelector}-${anim.method}-${posKey}${groupSuffix}`; const count = (counts.get(base) ?? 0) + 1; counts.set(base, count); const id = count === 1 ? base : `${base}-${count}`; @@ -937,10 +1074,14 @@ function parseGsapAst(script: string): ParsedGsapAst { const ast = parseScript(script); const scope = collectScopeBindings(ast); const targetBindings = collectTargetBindings(ast, scope); - const detection = findTimelineVar(ast); + const detection = findTimelineVar(ast, scope); const timelineVar = detection.timelineVar ?? "tl"; const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings); - const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope))); + sortBySourcePosition(calls); + const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope)); + applyTimelineDefaults(rawAnims, detection.defaults); + resolveTimelinePositions(rawAnims); + const animations = assignStableIds(rawAnims); const located = animations.map((animation, i) => ({ id: animation.id, call: calls[i]!, @@ -1300,7 +1441,11 @@ export function removeAnimationFromScript(script: string, animationId: string): console.warn("[gsap-parser] removeAnimationFromScript parse failed:", e); return script; } - const target = parsed.located.find((l) => l.id === animationId); + let target = parsed.located.find((l) => l.id === animationId); + if (!target) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + target = parsed.located.find((l) => l.id === convertedId); + } if (!target) return script; const node = target.call.node; const stmtPath = findStatementPath(target.call.path); @@ -1514,6 +1659,21 @@ function percentageFromKey(key: string): number { return m ? Number.parseFloat(m[1]!) : Number.NaN; } +const PCT_TOLERANCE = 2; + +function findKeyframePropByPct(kfNode: any, percentage: number): { idx: number; prop: any } | null { + const props = kfNode.properties; + for (let i = 0; i < props.length; i++) { + if (!isObjectProperty(props[i])) continue; + const key = propKeyName(props[i]); + if (typeof key !== "string") continue; + const parsed = percentageFromKey(key); + if (Number.isNaN(parsed)) continue; + if (Math.abs(parsed - percentage) <= PCT_TOLERANCE) return { idx: i, prop: props[i] }; + } + return null; +} + /** Build a keyframe value AST node from properties and optional ease. */ function buildKeyframeValueNode(properties: Record, ease?: string): any { const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); @@ -1571,7 +1731,11 @@ function collapseKeyframesToFlat(varsArg: any, record: Record): * updateKeyframeInScript. */ function locateKeyframeCtx(script: string, animationId: string, percentage: number) { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return null; const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); if (!kfNode) return null; @@ -1613,12 +1777,20 @@ export function addKeyframeToScript( const newValueNode = buildKeyframeValueNode(properties, ease); - // Replace if this percentage already exists - const existingIdx = kfNode.properties.findIndex( - (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, - ); - if (existingIdx !== -1) { - kfNode.properties[existingIdx].value = newValueNode; + // Merge into existing keyframe at this percentage, or insert new + const existing = findKeyframePropByPct(kfNode, percentage); + if (existing) { + if (existing.prop.value?.type === "ObjectExpression") { + const existingRecord = objectExpressionToRecord(existing.prop.value, loc.parsed.scope); + const merged = { ...existingRecord }; + for (const [k, v] of Object.entries(properties)) merged[k] = v; + existing.prop.value = buildKeyframeValueNode( + merged as Record, + ease ?? (typeof existingRecord.ease === "string" ? existingRecord.ease : undefined), + ); + } else { + existing.prop.value = newValueNode; + } } else { // Build the new property node with a quoted percentage key const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0]; @@ -1703,12 +1875,11 @@ export function removeKeyframeFromScript( ): string { const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; - const { loc, kfNode, pctKey } = ctx; + const { loc, kfNode } = ctx; - const removeIdx = kfNode.properties.findIndex( - (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, - ); - if (removeIdx === -1) return script; + const match = findKeyframePropByPct(kfNode, percentage); + if (!match) return script; + const removeIdx = match.idx; kfNode.properties.splice(removeIdx, 1); @@ -1736,14 +1907,12 @@ export function updateKeyframeInScript( ): string { const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; - const { loc, kfNode, pctKey } = ctx; + const { loc, kfNode } = ctx; - const existing = kfNode.properties.find( - (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, - ); - if (!existing) return script; + const match = findKeyframePropByPct(kfNode, percentage); + if (!match) return script; - existing.value = buildKeyframeValueNode(properties, ease); + match.prop.value = buildKeyframeValueNode(properties, ease); return recast.print(loc.parsed.ast).code; } @@ -1760,32 +1929,47 @@ function cssIdentityValue(prop: string): number { return CSS_IDENTITY[prop] ?? 0; } +/** + * Resolve the 0% (from) and 100% (to) property maps for a tween being + * converted to percentage keyframes. + * + * @param resolvedFromValues — Despite the "from" in the name (historical), these + * are runtime-captured DOM values that override the conversion endpoint: + * - For to(): overrides fromProps (the 0% state / where the element is now). + * - For from(): overrides toProps (the 100% state / where the element rests). + * - For fromTo(): merges into toProps (the 100% endpoint the user is editing). + */ function resolveConversionProps( anim: GsapAnimation, resolvedFromValues?: Record, ): { fromProps: Record; toProps: Record } { if (anim.method === "to") { - if (resolvedFromValues) { - return { fromProps: resolvedFromValues, toProps: { ...anim.properties } }; - } const identityFrom: Record = {}; for (const [key, val] of Object.entries(anim.properties)) { if (val != null) identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val; } - return { fromProps: identityFrom, toProps: { ...anim.properties } }; + const fromProps = resolvedFromValues + ? { ...identityFrom, ...resolvedFromValues } + : identityFrom; + return { fromProps, toProps: { ...anim.properties } }; } if (anim.method === "from") { - if (resolvedFromValues) { - return { fromProps: { ...anim.properties }, toProps: resolvedFromValues }; - } const identityTo: Record = {}; for (const [key, val] of Object.entries(anim.properties)) { if (val != null) identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val; } - return { fromProps: { ...anim.properties }, toProps: identityTo }; + const toProps = resolvedFromValues ? { ...identityTo, ...resolvedFromValues } : identityTo; + return { fromProps: { ...anim.properties }, toProps }; } - // fromTo - return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps: { ...anim.properties } }; + // fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state), + // anim.properties = toVars (100% state). resolvedFromValues contains the + // current DOM position from a drag — it represents the NEW destination, so + // it merges into toProps (the 100% endpoint the user is editing), NOT into + // fromProps. This is intentional and not inverted. + const toProps = resolvedFromValues + ? { ...anim.properties, ...resolvedFromValues } + : { ...anim.properties }; + return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps }; } /** Strip editable properties and ease/keyframes keys from a varsArg. */ @@ -1827,7 +2011,11 @@ export function convertToKeyframesInScript( animationId: string, resolvedFromValues?: Record, ): string { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return script; const anim = loc.target.animation; @@ -1858,7 +2046,11 @@ export function convertToKeyframesInScript( * last keyframe's properties. */ export function removeAllKeyframesFromScript(script: string, animationId: string): string { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return script; const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); if (!kfNode) return script; @@ -1894,7 +2086,11 @@ export function materializeKeyframesInScript( easeEach?: string, resolvedSelector?: string, ): string { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return script; const varsArg = loc.target.call.varsArg; @@ -2143,6 +2339,156 @@ export function removeArcPathFromScript(script: string, animationId: string): st }); } +// ── Split Into Property Groups ──────────────────────────────────────────── + +/** + * Split a multi-group tween into separate per-group tweens. Each resulting + * tween contains only properties belonging to one property group (position, + * scale, rotation, visual, etc.). `transformOrigin` stays with the group that + * has the most properties. If the tween already belongs to a single group, + * returns the script unchanged with the original ID. + */ +// fallow-ignore-next-line complexity +export function splitIntoPropertyGroups( + script: string, + animationId: string, +): { script: string; ids: string[] } { + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } + if (!loc) return { script, ids: [animationId] }; + + const anim = loc.target.animation; + + // Collect the properties to partition. For keyframed tweens, gather the + // union of all properties across all keyframes. For flat tweens, use the + // tween's own properties map. + const allPropKeys = new Set(); + if (anim.keyframes) { + for (const kf of anim.keyframes.keyframes) { + for (const k of Object.keys(kf.properties)) allPropKeys.add(k); + } + } else { + for (const k of Object.keys(anim.properties)) allPropKeys.add(k); + } + + // Partition properties into groups (excluding transformOrigin — handled below). + const groupProps = new Map(); + for (const key of allPropKeys) { + if (key === "transformOrigin") continue; + const group = classifyPropertyGroup(key); + let arr = groupProps.get(group); + if (!arr) { + arr = []; + groupProps.set(group, arr); + } + arr.push(key); + } + + // Only one group (or zero) — no split needed. + if (groupProps.size <= 1) return { script, ids: [anim.id] }; + + // Assign transformOrigin to the group with the most properties. + if (allPropKeys.has("transformOrigin")) { + let largestGroup: PropertyGroupName | undefined; + let largestCount = 0; + for (const [group, props] of groupProps) { + if (props.length > largestCount) { + largestCount = props.length; + largestGroup = group; + } + } + if (largestGroup) { + groupProps.get(largestGroup)!.push("transformOrigin"); + } + } + + // Build per-group tweens and insert them, then remove the original. + let result = script; + + // Remove the original tween first. + result = removeAnimationFromScript(result, anim.id); + + // Insert one tween per group. Iteration order of the Map follows insertion + // order, which mirrors the order properties were encountered. + for (const [, props] of groupProps) { + const propSet = new Set(props); + + if (anim.keyframes) { + // Build keyframes containing only this group's properties per keyframe. + const groupKeyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + auto?: boolean; + }> = []; + + for (const kf of anim.keyframes.keyframes) { + const filtered: Record = {}; + for (const [k, v] of Object.entries(kf.properties)) { + if (propSet.has(k)) filtered[k] = v; + } + // Skip keyframes where this group has zero properties. + if (Object.keys(filtered).length === 0) continue; + groupKeyframes.push({ + percentage: kf.percentage, + properties: filtered, + ...(kf.ease ? { ease: kf.ease } : {}), + }); + } + + if (groupKeyframes.length === 0) continue; + + const addResult = addAnimationWithKeyframesToScript( + result, + anim.targetSelector, + typeof anim.position === "number" ? anim.position : 0, + anim.duration ?? 0.5, + groupKeyframes, + anim.keyframes.easeEach ?? anim.ease, + ); + result = addResult.script; + } else { + // Flat tween — filter properties to this group. + const groupProperties: Record = {}; + for (const [k, v] of Object.entries(anim.properties)) { + if (propSet.has(k)) groupProperties[k] = v; + } + if (Object.keys(groupProperties).length === 0) continue; + + let fromProperties: Record | undefined; + if (anim.method === "fromTo" && anim.fromProperties) { + fromProperties = {}; + for (const [k, v] of Object.entries(anim.fromProperties)) { + if (propSet.has(k)) fromProperties[k] = v; + } + } + + const addResult = addAnimationToScript(result, { + targetSelector: anim.targetSelector, + method: anim.method, + position: anim.position, + duration: anim.duration, + ease: anim.ease, + properties: groupProperties, + fromProperties, + extras: anim.extras, + }); + result = addResult.script; + } + } + + // Re-parse to collect the new IDs. + const reParsed = parseGsapAst(result); + const newIds = reParsed.located + .filter((l) => l.animation.targetSelector === anim.targetSelector) + .map((l) => l.id); + + return { script: result, ids: newIds }; +} + /** * Replace a dynamic loop that generates multiple tween calls with individual * static `tl.to()` calls — one per element. Finds the loop containing the @@ -2213,7 +2559,7 @@ export function unrollDynamicAnimations( kfEntries.push(`easeEach: ${JSON.stringify(el.easeEach)}`); } calls.push( - `tl.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`, + `${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`, ); } diff --git a/packages/core/src/parsers/gsapSerialize.ts b/packages/core/src/parsers/gsapSerialize.ts index b480190e6..fc5a6220a 100644 --- a/packages/core/src/parsers/gsapSerialize.ts +++ b/packages/core/src/parsers/gsapSerialize.ts @@ -7,6 +7,7 @@ * parsing of GSAP source lives in the Node-only `./gsapParser` module. */ import type { Keyframe, KeyframeProperties, ValidationResult } from "../core.types"; +import type { PropertyGroupName } from "./gsapConstants"; export type GsapMethod = "set" | "to" | "from" | "fromTo"; @@ -29,6 +30,13 @@ export interface GsapAnimation { hasUnresolvedKeyframes?: boolean; /** True when the tween's target selector couldn't be statically resolved (dynamic). */ hasUnresolvedSelector?: boolean; + /** Absolute start time computed by walking the timeline chain (handles +=, -=, <, >, labels). */ + resolvedStart?: number; + /** True when no position arg was authored — the tween is sequentially placed by GSAP. */ + implicitPosition?: boolean; + /** Which property group this tween belongs to (position, scale, size, rotation, visual, other). + * Undefined for legacy mixed tweens that bundle multiple groups. */ + propertyGroup?: PropertyGroupName; } export interface GsapPercentageKeyframe { @@ -77,8 +85,10 @@ export function serializeGsapAnimations( options?: { includeMediaSync?: boolean; preamble?: string; postamble?: string }, ): string { const sorted = [...animations].sort((a, b) => { - const aNum = typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER; - const bNum = typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER; + const aNum = + a.resolvedStart ?? (typeof a.position === "number" ? a.position : Number.MAX_SAFE_INTEGER); + const bNum = + b.resolvedStart ?? (typeof b.position === "number" ? b.position : Number.MAX_SAFE_INTEGER); return aNum - bNum; }); const lines = sorted.map((anim) => { diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 9fd909920..31b879bbb 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -409,6 +409,20 @@ type GsapMutationRequest = }>; ease?: string; } + | { + type: "replace-with-keyframes"; + animationId: string; + targetSelector: string; + position: number; + duration: number; + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + auto?: boolean; + }>; + ease?: string; + } | { type: "split-animations"; originalId: string; @@ -416,6 +430,10 @@ type GsapMutationRequest = splitTime: number; elementStart: number; elementDuration: number; + } + | { + type: "split-into-property-groups"; + animationId: string; }; // ── GSAP mutation executor ────────────────────────────────────────────────── @@ -445,6 +463,7 @@ async function executeGsapMutation( removeArcPathFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, + splitIntoPropertyGroups, } = parser; function requireAnimation( @@ -617,6 +636,18 @@ async function executeGsapMutation( ); return result.script; } + case "replace-with-keyframes": { + const script = removeAnimationFromScript(block.scriptText, body.animationId); + const added = addAnimationWithKeyframesToScript( + script, + body.targetSelector, + body.position, + body.duration, + body.keyframes, + body.ease, + ); + return added.script; + } case "split-animations": { if ( typeof body.originalId !== "string" || @@ -647,6 +678,10 @@ async function executeGsapMutation( elementDuration: body.elementDuration, }); } + case "split-into-property-groups": { + const result = splitIntoPropertyGroups(block.scriptText, body.animationId); + return result.script; + } default: return respond({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400); } @@ -1061,8 +1096,9 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { if (result instanceof Response) return result; const newScript = typeof result === "string" ? result : result.script; - const newHtml = block.replaceScript(newScript); - if (newHtml !== html) { + const changed = newScript !== block.scriptText; + const newHtml = changed ? block.replaceScript(newScript) : html; + if (changed) { writeFileSync(res.absPath, newHtml, "utf-8"); } @@ -1070,6 +1106,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { const freshParsed = parseGsapScript(newScript); const responsePayload: Record = { ok: true, + changed, parsed: freshParsed, before: html, after: newHtml, diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 647b125e0..46f599b88 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -32,7 +32,7 @@ import { } from "./manualEditsTypes"; import { roundRotationAngle } from "./manualEditsParsing"; import { applyStudioMotionFromDom } from "./studioMotion"; -import { gsapAnimatesProperty, gsapAnimatesTransform } from "./gsapAnimatesProperty"; +import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; /* ── Gesture tracking ─────────────────────────────────────────────── */ let studioManualEditGestureId = 0; @@ -223,7 +223,6 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean { return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1; } -// fallow-ignore-next-line complexity function stripGsapTranslateFromTransform(element: HTMLElement): void { if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return; const transform = element.style.getPropertyValue("transform"); @@ -258,18 +257,6 @@ export function applyStudioPathOffset( ): void { promoteInlineForTransform(element); writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true }); - if (gsapAnimatesTransform(element)) { - // GSAP folded the CSS translate into its transform cache at init and owns - // style.transform from then on — it zeroes the translate longhand exactly - // once (at fold time) and never re-reads it. Writing translate here would - // double-apply the offset on top of the baked transform. Keep translate - // neutral in the live DOM and push the offset into GSAP's cache instead; - // the var() expression is persisted to the source file by the patch - // builder, where a reload re-folds it. - element.style.setProperty("translate", "none"); - syncGsapOwnedTransformPosition(element); - return; - } element.style.setProperty( "translate", composeTranslateValue( @@ -281,24 +268,6 @@ export function applyStudioPathOffset( stripGsapTranslateFromTransform(element); } -/** - * After committing a new path offset on an element whose transform GSAP owns, - * GSAP's internal cache still holds the pre-drag baked translate — the next - * seek re-renders from that cache and snaps the element back. Push the new - * offset into GSAP so live scrubbing matches what was persisted. (A page - * reload re-initializes GSAP from the persisted CSS translate, so this is - * only needed for the live session.) - */ -function syncGsapOwnedTransformPosition(element: HTMLElement): void { - if (!gsapAnimatesTransform(element)) return; - const win = element.ownerDocument.defaultView as - | (Window & { gsap?: { set: (el: Element, vars: Record) => void } }) - | null; - if (!win?.gsap?.set) return; - const { x, y } = readStudioPathOffset(element); - win.gsap.set(element, { x, y }); -} - export function applyStudioPathOffsetDraft( element: HTMLElement, offset: { x: number; y: number }, @@ -306,16 +275,36 @@ export function applyStudioPathOffsetDraft( promoteInlineForTransform(element); writeStudioPathOffsetVars(element, offset, { updateBase: false }); - const isGsapAnimated = gsapAnimatesTransform(element); + const isGsapAnimated = gsapAnimatesProperty(element, "x", "y"); if (isGsapAnimated) { - // GSAP owns style.transform (see applyStudioPathOffset): position via - // gsap.set while the timeline is paused. Set translate:none explicitly to - // prevent double-counting with the baked transform. element.style.setProperty("translate", "none"); const win = element.ownerDocument.defaultView as - | (Window & { gsap?: { set: (el: Element, vars: Record) => void } }) + | (Window & { + gsap?: { + set: (el: Element, vars: Record) => void; + getProperty: (el: Element, prop: string) => number; + }; + }) | null; - win?.gsap?.set(element, { x: offset.x, y: offset.y }); + if (win?.gsap) { + const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? ""); + const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? ""); + const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? ""); + const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? ""); + const gsapBaseX = Number.isFinite(baseX) + ? baseX + : (win.gsap.getProperty(element, "x") as number); + const gsapBaseY = Number.isFinite(baseY) + ? baseY + : (win.gsap.getProperty(element, "y") as number); + if (!Number.isFinite(baseX)) + element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX)); + if (!Number.isFinite(baseY)) + element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY)); + const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0); + const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0); + win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY }); + } } else { // Non-GSAP elements: use CSS translate as before. element.style.setProperty( @@ -551,14 +540,10 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] { function reapplyPathOffsets(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) { - // Skip elements where GSAP owns the transform stack — GSAP bakes the - // CSS translate into its transform and sets translate: none every tick - // when it tweens ANY transform property (x/y, scale, rotation, ...). - // Stripping/restoring would oscillate against GSAP's rendering and - // double-apply the offset. - if (gsapAnimatesTransform(el)) continue; + const gsapSkip = gsapAnimatesProperty(el, "x", "y"); const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP); const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); + if (gsapSkip) continue; if (x || y) { applyStudioPathOffset( el, diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts index df9569ab4..afc8aa30e 100644 --- a/packages/studio/src/components/editor/manualEditsSnapshot.ts +++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts @@ -4,7 +4,6 @@ import { styleUsesStudioRotation, restoreInlineDisplay, } from "./manualEditsDom"; -import { gsapAnimatesTransform } from "./gsapAnimatesProperty"; import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, @@ -88,23 +87,7 @@ export function captureStudioRotation(element: HTMLElement): StudioRotationSnaps }; } -type GsapWindow = Window & { - gsap?: { - getProperty?: (el: Element, prop: string) => number | string; - set?: (el: Element, vars: Record) => void; - }; -}; - export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetSnapshot { - let gsapX: number | null = null; - let gsapY: number | null = null; - if (gsapAnimatesTransform(element)) { - const win = element.ownerDocument.defaultView as GsapWindow | null; - if (win?.gsap?.getProperty) { - gsapX = Number(win.gsap.getProperty(element, "x")) || 0; - gsapY = Number(win.gsap.getProperty(element, "y")) || 0; - } - } return { translate: element.style.getPropertyValue("translate"), x: element.style.getPropertyValue(STUDIO_OFFSET_X_PROP), @@ -112,8 +95,6 @@ export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetS marker: element.getAttribute(STUDIO_PATH_OFFSET_ATTR), originalTranslate: element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR), originalInlineTranslate: element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR), - gsapX, - gsapY, }; } @@ -203,11 +184,20 @@ export function restoreStudioPathOffset( previous.originalInlineTranslate, ); - // Draft positioning on GSAP-owned elements goes through gsap.set, which - // mutates GSAP's transform cache — restore it alongside the inline styles. - if (previous.gsapX != null || previous.gsapY != null) { - const win = element.ownerDocument.defaultView as GsapWindow | null; - win?.gsap?.set?.(element, { x: previous.gsapX ?? 0, y: previous.gsapY ?? 0 }); + // Restore GSAP x/y if a draft was applied via gsap.set during drag + const baseX = element.getAttribute("data-hf-drag-gsap-base-x"); + const baseY = element.getAttribute("data-hf-drag-gsap-base-y"); + if (baseX != null || baseY != null) { + const win = element.ownerDocument.defaultView as + | (Window & { gsap?: { set: (el: Element, vars: Record) => void } }) + | null; + if (win?.gsap) { + const x = Number.parseFloat(baseX ?? "0") || 0; + const y = Number.parseFloat(baseY ?? "0") || 0; + win.gsap.set(element, { x, y }); + } + element.removeAttribute("data-hf-drag-gsap-base-x"); + element.removeAttribute("data-hf-drag-gsap-base-y"); } } diff --git a/packages/studio/src/components/editor/manualEditsTypes.ts b/packages/studio/src/components/editor/manualEditsTypes.ts index 6ef53aaa1..f54071182 100644 --- a/packages/studio/src/components/editor/manualEditsTypes.ts +++ b/packages/studio/src/components/editor/manualEditsTypes.ts @@ -101,12 +101,4 @@ export interface StudioPathOffsetSnapshot { marker: string | null; originalTranslate: string | null; originalInlineTranslate: string | null; - /** - * GSAP's cached x/y at capture time, for elements whose transform GSAP - * owns. Draft positioning mutates GSAP's cache (gsap.set), which inline - * style restoration alone cannot undo. Null when GSAP does not own the - * element's transform. - */ - gsapX: number | null; - gsapY: number | null; } diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 797376408..6e23936e5 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -11,8 +11,6 @@ import { resolveTweenStart, resolveTweenDuration, } from "../utils/globalTimeCompiler"; -import { readAllAnimatedProperties } from "./gsapRuntimeReaders"; - export interface GsapDragCommitCallbacks { commitMutation: ( selection: DomEditSelection, @@ -114,7 +112,6 @@ async function extendTweenAndAddKeyframe( const newStart = Math.min(targetTime, tweenStart); const newEnd = Math.max(targetTime, tweenEnd); const newDuration = Math.max(0.01, newEnd - newStart); - const existingKfs = anim.keyframes?.keyframes ?? []; const remappedKfs: Array<{ percentage: number; properties: Record }> = []; @@ -126,20 +123,15 @@ async function extendTweenAndAddKeyframe( const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10; remappedKfs.push({ percentage: targetPct, properties }); - remappedKfs.sort((a, b) => a.percentage - b.percentage); - await callbacks.commitMutation( - selection, - { type: "delete", animationId: anim.id }, - { label: "Extend tween range", skipReload: true }, - ); + remappedKfs.sort((a, b) => a.percentage - b.percentage); - const selector = anim.targetSelector; await callbacks.commitMutation( selection, { - type: "add-with-keyframes", - targetSelector: selector, + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, position: Math.round(newStart * 1000) / 1000, duration: Math.round(newDuration * 1000) / 1000, keyframes: remappedKfs, @@ -156,8 +148,9 @@ async function commitKeyframedPosition( callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, ): Promise { - const pct = computeCurrentPercentage(selection, anim); - + const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); + const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); + if (activeKeyframePct != null) setActiveKeyframePct(null); await callbacks.commitMutation( selection, { @@ -182,10 +175,11 @@ async function commitFlatViaKeyframes( callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, ): Promise { + const coalesceKey = `gsap:convert-drag:${anim.id}`; await callbacks.commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id }, - { label: "Convert to keyframes for drag", skipReload: true }, + { label: "Convert to keyframes for drag", skipReload: true, coalesceKey }, ); const pct = computeCurrentPercentage(selection, anim); @@ -198,7 +192,7 @@ async function commitFlatViaKeyframes( percentage: pct, properties, }, - { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, + { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey }, ); } @@ -243,19 +237,20 @@ export async function commitGsapPositionFromDrag( el.removeAttribute("data-hf-drag-initial-offset-y"); }; + const ct = usePlayerStore.getState().currentTime; if (anim.keyframes) { const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); const effectiveAnim = newId ? { ...anim, id: newId } : anim; - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + const dragProps: Record = { x: newX, y: newY }; - const ct = usePlayerStore.getState().currentTime; const ts = resolveTweenStart(effectiveAnim); const td = resolveTweenDuration(effectiveAnim); - if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) { + const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); + if (outsideRange) { await extendTweenAndAddKeyframe( selection, effectiveAnim, - { ...runtimeProps, x: newX, y: newY }, + dragProps, ct, ts, td, @@ -263,32 +258,126 @@ export async function commitGsapPositionFromDrag( restoreOffset, ); } else { - await commitKeyframedPosition( + await commitKeyframedPosition(selection, effectiveAnim, dragProps, callbacks, restoreOffset); + } + } else if (anim.method === "from" || anim.method === "fromTo") { + const ct = usePlayerStore.getState().currentTime; + const ts = resolveTweenStart(anim); + const td = resolveTweenDuration(anim); + const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); + const dragProps: Record = { x: newX, y: newY }; + + if (outsideRange && ts !== null) { + // Split the original from() tween into property groups first. + await callbacks.commitMutation( selection, - effectiveAnim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - restoreOffset, + { type: "split-into-property-groups", animationId: anim.id }, + { label: "Split from() for drag", skipReload: true }, + ); + + // Check if a position-group tween already exists (e.g. from gesture recording). + // If so, extend it instead of creating a duplicate. + const allAnims = await (async () => { + const pid = selection.sourceFile || "index.html"; + try { + const r = await fetch( + `/api/projects/${encodeURIComponent(window.location.hash.match(/project\/([^?/]+)/)?.[1] ?? "")}/gsap-animations/${encodeURIComponent(pid)}`, + ); + if (!r.ok) return []; + const parsed = await r.json(); + return (parsed?.animations ?? []) as GsapAnimation[]; + } catch { + return []; + } + })(); + const existingPosAnim = allAnims.find( + (a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector, + ); + + if (existingPosAnim?.keyframes) { + // Extend the existing position tween + const posTs = resolveTweenStart(existingPosAnim); + const posTd = resolveTweenDuration(existingPosAnim); + if (posTs !== null) { + await extendTweenAndAddKeyframe( + selection, + existingPosAnim, + { x: newX, y: newY }, + ct, + posTs, + posTd, + callbacks, + restoreOffset, + ); + return; + } + } + + // No existing position tween — create one + const newStart = Math.min(ct, ts); + const newEnd = Math.max(ct, ts + td); + const newDuration = Math.max(0.01, newEnd - newStart); + const dragBefore = ct < ts; + const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10; + const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10; + + const keyframes: Array<{ percentage: number; properties: Record }> = + []; + if (dragBefore) { + keyframes.push({ percentage: 0, properties: { x: newX, y: newY } }); + if (origStartPct > 0.5 && origStartPct < 99.5) { + keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } }); + } + keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } }); + } else { + keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } }); + if (origEndPct > 0.5 && origEndPct < 99.5) { + keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } }); + } + keyframes.push({ percentage: 100, properties: { x: newX, y: newY } }); + } + keyframes.sort((a, b) => a.percentage - b.percentage); + + await callbacks.commitMutation( + selection, + { + type: "add-with-keyframes", + targetSelector: anim.targetSelector, + position: Math.round(newStart * 1000) / 1000, + duration: Math.round(newDuration * 1000) / 1000, + keyframes, + }, + { label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset }, + ); + } else { + // Inside tween range: convert then add keyframe at current time + const coalesceKey = `gsap:convert-drag:${anim.id}`; + await callbacks.commitMutation( + selection, + { + type: "convert-to-keyframes", + animationId: anim.id, + }, + { label: "Convert from() for drag", skipReload: true, coalesceKey }, + ); + const pct = computeCurrentPercentage(selection, anim); + await callbacks.commitMutation( + selection, + { + type: "add-keyframe", + animationId: anim.id, + percentage: pct, + properties: dragProps, + }, + { + label: `Move layer (keyframe ${pct}%)`, + softReload: true, + beforeReload: restoreOffset, + coalesceKey, + }, ); } - } else if (anim.method === "from" || anim.method === "fromTo") { - await callbacks.commitMutation( - selection, - { - type: "convert-to-keyframes", - animationId: anim.id, - resolvedFromValues: { x: newX, y: newY }, - }, - { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset }, - ); } else { - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); - await commitFlatViaKeyframes( - selection, - anim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - restoreOffset, - ); + await commitFlatViaKeyframes(selection, anim, { x: newX, y: newY }, callbacks, restoreOffset); } } diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index f14d68ed1..1bdfefb21 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -8,7 +8,7 @@ * absolute positions back into the GSAP script, regardless of tween type, * easing, or seek position. */ -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; @@ -18,6 +18,7 @@ import { computeCurrentPercentage, materializeIfDynamic, } from "./gsapDragCommit"; +import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import type { GsapDragCommitCallbacks } from "./gsapDragCommit"; // ── Runtime reads ────────────────────────────────────────────────────────── @@ -87,7 +88,7 @@ function findGsapPositionAnimation( if (a.keyframes) score += 5; if (selector && a.targetSelector === selector) score += 8; else if (a.targetSelector.includes(",")) score -= 5; - const pos = typeof a.position === "number" ? a.position : 0; + const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0); const dur = a.duration ?? 0; if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 4; return { anim: a, score }; @@ -104,6 +105,74 @@ function selectorForSelection(selection: DomEditSelection): string | null { return null; } +// ── Property-group tween resolution ─────────────────────────────────────── + +/** + * Find the tween for a given property group, splitting a legacy mixed tween + * if necessary. Returns the resolved animation or null if none exists. + * + * Resolution order: + * 1. Tween already tagged with `propertyGroup === group` + * 2. Legacy mixed tween (`!propertyGroup`) → split via server mutation, + * re-fetch, then return the group tween + * 3. null — caller must handle the missing-tween case + */ +async function resolveGroupTween( + group: PropertyGroupName, + animations: GsapAnimation[], + selection: DomEditSelection, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + fetchFallbackAnimations?: () => Promise, +): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> { + // 1. Already-split group tween — prefer the one with the most keyframes + // to avoid targeting a stub when a gesture-recorded tween also exists. + const groupAnims = animations.filter((a) => a.propertyGroup === group); + const groupAnim = + groupAnims.length > 1 + ? groupAnims.sort( + (a, b) => (b.keyframes?.keyframes.length ?? 0) - (a.keyframes?.keyframes.length ?? 0), + )[0] + : (groupAnims[0] ?? null); + if (groupAnim) return { anim: groupAnim, animations }; + + // 2. Legacy mixed tween — split it, then re-fetch + const legacyMixed = animations.find((a) => !a.propertyGroup); + if (legacyMixed) { + await commitMutation( + selection, + { type: "split-into-property-groups", animationId: legacyMixed.id }, + { label: "Split mixed tween into property groups", skipReload: true }, + ); + if (fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + const freshGroupAnim = fresh.find((a) => a.propertyGroup === group); + if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh }; + } + } + + // 3. Try fallback fetch (no split needed, just wasn't in the initial list) + if (!legacyMixed && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + const freshGroupAnim = fresh.find((a) => a.propertyGroup === group); + if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh }; + + // Fallback: legacy mixed in the fresh list + const freshLegacy = fresh.find((a) => !a.propertyGroup); + if (freshLegacy) { + await commitMutation( + selection, + { type: "split-into-property-groups", animationId: freshLegacy.id }, + { label: "Split mixed tween into property groups", skipReload: true }, + ); + const reFetched = await fetchFallbackAnimations(); + const reFetchedGroup = reFetched.find((a) => a.propertyGroup === group); + if (reFetchedGroup) return { anim: reFetchedGroup, animations: reFetched }; + } + } + + return null; +} + // ── High-level intercept ─────────────────────────────────────────────────── export type { GsapDragCommitCallbacks }; @@ -127,10 +196,24 @@ export async function tryGsapDragIntercept( const selector = selectorForSelection(selection); if (!selector) return false; - let posAnim = findGsapPositionAnimation(animations, selector); - if (!posAnim && fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); - posAnim = findGsapPositionAnimation(fresh, selector); + // Resolve the position-group tween, splitting legacy mixed tweens if needed. + const resolved = await resolveGroupTween( + "position", + animations, + selection, + commitMutation, + fetchFallbackAnimations, + ); + + // Fallback: use the legacy scoring heuristic for compositions that don't + // have group-tagged tweens at all (e.g. hand-written scripts). + let posAnim = resolved?.anim ?? null; + if (!posAnim) { + posAnim = findGsapPositionAnimation(animations, selector); + if (!posAnim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + posAnim = findGsapPositionAnimation(fresh, selector); + } } if (!posAnim) return false; @@ -151,6 +234,22 @@ export async function tryGsapDragIntercept( export { readGsapProperty, readAllAnimatedProperties }; +// ── Identity-prop synthesis ─────────────────────────────────────────────── + +const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]); + +/** Build identity (zero / one) values for each property in `source`. */ +function synthesizeIdentityProps( + source: Record, +): Record { + const id: Record = {}; + for (const [k, v] of Object.entries(source)) { + if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + else id[k] = v; + } + return id; +} + // ── Resize intercept ────────────────────────────────────────────────────── export async function tryGsapResizeIntercept( @@ -161,46 +260,155 @@ export async function tryGsapResizeIntercept( commitMutation: GsapDragCommitCallbacks["commitMutation"], fetchFallbackAnimations?: () => Promise, ): Promise { - let anim = animations.find( - (a) => "width" in a.properties || "height" in a.properties || a.keyframes, + // If the element already has a scale-group tween, resize should modify scale + // (the user is resizing something whose visual size is driven by scale). + // Otherwise, use the size group (width/height). + const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale"); + const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size"; + const resolved = await resolveGroupTween( + resizeGroup, + animations, + selection, + commitMutation, + fetchFallbackAnimations, ); - if (!anim && fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); - anim = fresh.find((a) => "width" in a.properties || "height" in a.properties || a.keyframes); - } - if (!anim) return false; - - const pct = computeCurrentPercentage(selection, anim); - if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { - const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); - if (newId) anim = { ...anim, id: newId }; - } else if (!anim.keyframes) { + let anim = resolved?.anim ?? null; + if (!anim) { + // No size-group tween exists — create one. Use the element's timing + // from any existing animation, or fall back to element data attributes. + const refAnim = animations[0]; + const elStart = + refAnim?.resolvedStart ?? (Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0); + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "5") || 5; + const ct = usePlayerStore.getState().currentTime; + const pct = elDuration > 0 ? Math.round(((ct - elStart) / elDuration) * 1000) / 10 : 0; + const sel = selectorForSelection(selection); + if (!sel) return false; await commitMutation( selection, - { type: "convert-to-keyframes", animationId: anim.id }, - { label: "Convert to keyframes for resize", skipReload: true }, + { + type: "add-with-keyframes", + targetSelector: sel, + position: Math.round(elStart * 1000) / 1000, + duration: Math.round(elDuration * 1000) / 1000, + keyframes: [ + { + percentage: Math.max(0, Math.min(100, pct)), + properties: { width: Math.round(size.width), height: Math.round(size.height) }, + }, + ], + }, + { label: "Resize (new size keyframe)", softReload: true }, ); + return true; } + const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); + const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); + if (activeKeyframePct != null) setActiveKeyframePct(null); + const coalesceKey = `gsap:resize:${anim.id}`; + const selector = selectorForSelection(selection); const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; - const backfillDefaults: Record = { ...runtimeProps }; - if (!("width" in runtimeProps)) { - const cssW = readGsapProperty(iframe, selector, "width"); - backfillDefaults.width = cssW ?? Math.round(size.width); + let resizeProps: Record; + if (resizeGroup === "scale") { + const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null; + // The resize draft modifies el.style.width, so read the ORIGINAL width + // saved by the draft system before it ran. + const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? ""); + const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200; + const newScale = Math.round((size.width / cssW) * 1000) / 1000; + resizeProps = { scale: newScale }; + } else { + resizeProps = { + width: Math.round(size.width), + height: Math.round(size.height), + }; + } + const ct = usePlayerStore.getState().currentTime; + const ts = resolveTweenStart(anim); + const td = resolveTweenDuration(anim); + const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes. + // Outside-range uses the extend path which handles everything atomically. + if (!outsideRange) { + if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { + const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); + if (newId) anim = { ...anim, id: newId }; + } else if (!anim.keyframes) { + const resolvedFromValues = selector + ? readAllAnimatedProperties(iframe, selector, anim) + : undefined; + await commitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues }, + { label: "Convert to keyframes for resize", skipReload: true, coalesceKey }, + ); + } } - if (!("height" in runtimeProps)) { - const cssH = readGsapProperty(iframe, selector, "height"); - backfillDefaults.height = cssH ?? Math.round(size.height); + + if (outsideRange && ts !== null) { + // For flat tweens, synthesize the keyframes from the tween's properties + const kfs = + anim.keyframes?.keyframes ?? + (() => { + const fromProps = + anim.method === "from" || anim.method === "fromTo" + ? { ...anim.properties } + : synthesizeIdentityProps(anim.properties); + const toProps = + anim.method === "from" + ? synthesizeIdentityProps(anim.properties) + : { ...anim.properties }; + return [ + { percentage: 0, properties: fromProps }, + { percentage: 100, properties: toProps }, + ]; + })(); + const newStart = Math.min(ct, ts); + const newEnd = Math.max(ct, ts + td); + const newDuration = Math.max(0.01, newEnd - newStart); + const existingKfs = kfs; + const remapped: Array<{ percentage: number; properties: Record }> = []; + for (const kf of existingKfs) { + const absTime = ts + (kf.percentage / 100) * td; + const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; + const props = { ...kf.properties }; + // Only backfill properties that the animation already had (x, y, scale). + // Don't backfill width/height — they should only appear on the resize keyframe. + for (const k of Object.keys(resizeProps)) { + if (k in props) continue; + if (k === "width" || k === "height") continue; + props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + } + remapped.push({ percentage: newPct, properties: props }); + } + const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10; + remapped.push({ percentage: targetPct, properties: resizeProps }); + remapped.sort((a, b) => a.percentage - b.percentage); + + await commitMutation( + selection, + { + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, + position: Math.round(newStart * 1000) / 1000, + duration: Math.round(newDuration * 1000) / 1000, + keyframes: remapped, + }, + { label: `Resize (extended to ${ct.toFixed(2)}s)`, softReload: true, coalesceKey }, + ); + return true; } - const properties = { - ...runtimeProps, - width: Math.round(size.width), - height: Math.round(size.height), - }; + const SIZE_PROPS = new Set(["width", "height"]); + const backfillDefaults: Record = {}; + for (const k of Object.keys(runtimeProps)) { + if (SIZE_PROPS.has(k)) continue; + backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + } await commitMutation( selection, @@ -208,10 +416,10 @@ export async function tryGsapResizeIntercept( type: "add-keyframe", animationId: anim.id, percentage: pct, - properties, + properties: resizeProps, backfillDefaults, }, - { label: `Resize (keyframe ${pct}%)`, softReload: true }, + { label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey }, ); return true; } @@ -226,10 +434,23 @@ export async function tryGsapRotationIntercept( commitMutation: GsapDragCommitCallbacks["commitMutation"], fetchFallbackAnimations?: () => Promise, ): Promise { - let anim = animations.find((a) => "rotation" in a.properties || a.keyframes); - if (!anim && fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); - anim = fresh.find((a) => "rotation" in a.properties || a.keyframes); + // Resolve the rotation-group tween, splitting legacy mixed tweens if needed. + const resolved = await resolveGroupTween( + "rotation", + animations, + selection, + commitMutation, + fetchFallbackAnimations, + ); + + // Fallback: legacy heuristic for hand-written scripts + let anim = resolved?.anim ?? null; + if (!anim) { + anim = animations.find((a) => "rotation" in a.properties || a.keyframes) ?? null; + if (!anim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null; + } } if (!anim) return false; @@ -261,14 +482,17 @@ export async function tryGsapRotationIntercept( const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); if (newId) anim = { ...anim, id: newId }; } else if (!anim.keyframes) { + const resolvedFromValues = selector + ? readAllAnimatedProperties(iframe, selector, anim, "rotation") + : undefined; await commitMutation( selection, - { type: "convert-to-keyframes", animationId: anim.id }, + { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues }, { label: "Convert to keyframes for rotation", skipReload: true }, ); } - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation"); const backfillDefaults: Record = { ...runtimeProps }; if (!("rotation" in runtimeProps)) { diff --git a/packages/studio/src/hooks/gsapRuntimeReaders.ts b/packages/studio/src/hooks/gsapRuntimeReaders.ts index 1f2898716..0cac04524 100644 --- a/packages/studio/src/hooks/gsapRuntimeReaders.ts +++ b/packages/studio/src/hooks/gsapRuntimeReaders.ts @@ -2,6 +2,7 @@ * Low-level GSAP runtime property readers shared by gsapRuntimeBridge and gsapDragCommit. */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser"; interface IframeGsap { getProperty: (el: Element, prop: string) => number; @@ -19,7 +20,8 @@ export function readGsapProperty( const el = iframe.contentDocument?.querySelector(selector); if (!el) return null; const val = Number(gsap.getProperty(el, prop)); - return Number.isFinite(val) ? Math.round(val) : null; + if (!Number.isFinite(val)) return null; + return POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000; } catch { return null; } @@ -51,6 +53,7 @@ export function readAllAnimatedProperties( iframe: HTMLIFrameElement | null, selector: string, anim: GsapAnimation, + group?: PropertyGroupName, ): Record { const result: Record = {}; if (!iframe?.contentWindow) return result; @@ -81,6 +84,13 @@ export function readAllAnimatedProperties( for (const p of Object.keys(anim.properties)) propKeys.add(p); } + // When a group filter is specified, only keep properties belonging to that group. + if (group) { + for (const p of propKeys) { + if (classifyPropertyGroup(p) !== group) propKeys.delete(p); + } + } + for (const prop of propKeys) { const val = Number(gsap.getProperty(el, prop)); if (Number.isFinite(val)) { @@ -147,9 +157,13 @@ export function readAllAnimatedProperties( sepia: 0, invert: 0, }; + // Collect all properties that ANY tween on this element explicitly targets. + // Only capture baseline values for these — GSAP reports non-default values + // (scaleZ=0, brightness=0) for untouched properties, polluting keyframes. + const allTweenedProps = new Set([...propKeys, ...otherTweenProps]); for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) { if (prop in result) continue; - if (otherTweenProps.has(prop)) continue; + if (!allTweenedProps.has(prop)) continue; const val = Number(gsap.getProperty(el, prop)); if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) { result[prop] = Math.round(val * 1000) / 1000; diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index a25064e5e..75a3d42a0 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -8,6 +8,7 @@ */ import { useCallback } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge"; @@ -38,7 +39,7 @@ interface CommitAnimatedPropertyDeps { function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): number { const currentTime = usePlayerStore.getState().currentTime; - const tweenPos = typeof anim?.position === "number" ? anim.position : 0; + const tweenPos = anim?.resolvedStart ?? (typeof anim?.position === "number" ? anim.position : 0); const tweenDur = anim?.duration ?? 0; if (tweenDur > 0) { return Math.max( @@ -56,18 +57,19 @@ function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): n function pickBestAnimation( animations: GsapAnimation[], selector: string | null, + property?: string, ): GsapAnimation | undefined { if (animations.length <= 1) return animations[0]; const currentTime = usePlayerStore.getState().currentTime; + const targetGroup = property ? classifyPropertyGroup(property) : undefined; const scored = animations.map((a) => { let score = 0; + if (targetGroup && a.propertyGroup === targetGroup) score += 20; if (a.keyframes) score += 10; - // Prefer single-element selectors over comma-separated groups if (selector && a.targetSelector === selector) score += 5; else if (a.targetSelector.includes(",")) score -= 3; - // Prefer tweens active at the current time - const pos = typeof a.position === "number" ? a.position : 0; + const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0); const dur = a.duration ?? 0; if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 8; return { anim: a, score }; @@ -102,7 +104,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { const iframe = previewIframeRef.current; const selector = selectorFor(selection); - let anim: GsapAnimation | undefined = pickBestAnimation(selectedGsapAnimations, selector); + let anim: GsapAnimation | undefined = pickBestAnimation( + selectedGsapAnimations, + selector, + property, + ); // Case 3: No animation — create one first if (!anim) { diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index a191452d9..febdb7a46 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef } from "react"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { - STUDIO_GSAP_PANEL_ENABLED, STUDIO_GSAP_DRAG_INTERCEPT_ENABLED, + STUDIO_GSAP_PANEL_ENABLED, } from "../components/editor/manualEditingAvailability"; import { type DomEditSelection } from "../components/editor/domEditing"; import { useDomEditPreviewSync } from "./useDomEditPreviewSync"; @@ -329,7 +329,11 @@ export function useDomEditSession({ // GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated. const handleGsapAwarePathOffsetCommit = useCallback( async (selection: DomEditSelection, next: { x: number; y: number }) => { - if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { + if ( + STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && + gsapCommitMutation && + usePlayerStore.getState().autoKeyframeEnabled + ) { const handled = await tryGsapDragIntercept( selection, next, @@ -375,7 +379,11 @@ export function useDomEditSession({ const handleGsapAwareBoxSizeCommit = useCallback( async (selection: DomEditSelection, next: { width: number; height: number }) => { - if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { + if ( + STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && + gsapCommitMutation && + usePlayerStore.getState().autoKeyframeEnabled + ) { const handled = await tryGsapResizeIntercept( selection, next, @@ -399,7 +407,11 @@ export function useDomEditSession({ const handleGsapAwareRotationCommit = useCallback( async (selection: DomEditSelection, next: { angle: number }) => { - if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { + if ( + STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && + gsapCommitMutation && + usePlayerStore.getState().autoKeyframeEnabled + ) { const handled = await tryGsapRotationIntercept( selection, next.angle, diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index 3978ff871..ca6ea8e19 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -52,10 +52,12 @@ function readElementPosition( const element = sel.element; if (!element?.isConnected || !gsap?.getProperty) return result; + const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]); const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"]; for (const prop of props) { const val = Number(gsap.getProperty(element, prop)); - if (Number.isFinite(val)) result[prop] = Math.round(val); + if (!Number.isFinite(val)) continue; + result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000; } return result; diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 70eee5215..998c2b2cf 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -51,6 +51,7 @@ function ensureElementAddressable(selection: DomEditSelection): { interface MutationResult { ok: boolean; + changed?: boolean; parsed?: ParsedGsap; before?: string; after?: string; @@ -131,9 +132,16 @@ export function useGsapScriptCommits({ const pid = projectIdRef.current; if (!pid) return; const targetPath = selection.sourceFile || activeCompPath || "index.html"; - const result = await mutateGsapScript(pid, targetPath, mutation); - if (!result?.ok) return; + if (!result) { + if (options.skipReload) return; + throw new Error(`Mutation failed: ${mutation.type}`); + } + + if (result.changed === false) { + if (options.skipReload) return; + return; + } domEditSaveTimestampRef.current = Date.now(); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 465f1d710..194eb592e 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -6,6 +6,10 @@ export interface KeyframeCacheEntry { format: string; keyframes: Array<{ percentage: number; + /** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */ + tweenPercentage?: number; + /** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */ + propertyGroup?: string; properties: Record; ease?: string; }>; @@ -74,6 +78,11 @@ interface PlayerState { toggleSelectedKeyframe: (key: string) => void; clearSelectedKeyframes: () => void; + /** Tween-relative percentage of the last-clicked keyframe diamond. Operations + * (drag, resize, rotate) target this instead of recomputing from playhead. */ + activeKeyframePct: number | null; + setActiveKeyframePct: (pct: number | null) => void; + /** Multi-select: additional selected elements beyond selectedElementId. */ selectedElementIds: Set; toggleSelectedElementId: (id: string) => void; @@ -170,6 +179,9 @@ export const usePlayerStore = create((set) => ({ }), clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), + activeKeyframePct: null, + setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }), + keyframeClipboard: null, setKeyframeClipboard: (data) => set({ keyframeClipboard: data }), diff --git a/packages/studio/src/utils/globalTimeCompiler.test.ts b/packages/studio/src/utils/globalTimeCompiler.test.ts index db4095cbc..963968f09 100644 --- a/packages/studio/src/utils/globalTimeCompiler.test.ts +++ b/packages/studio/src/utils/globalTimeCompiler.test.ts @@ -103,8 +103,8 @@ describe("resolveTweenDuration", () => { expect(resolveTweenDuration(makeAnim({ duration: 2 }))).toBe(2); }); - test("missing duration defaults to 1", () => { - expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(1); + test("missing duration defaults to GSAP default (0.5)", () => { + expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(0.5); }); }); diff --git a/packages/studio/src/utils/globalTimeCompiler.ts b/packages/studio/src/utils/globalTimeCompiler.ts index 9abe6c83d..3f050f925 100644 --- a/packages/studio/src/utils/globalTimeCompiler.ts +++ b/packages/studio/src/utils/globalTimeCompiler.ts @@ -27,6 +27,7 @@ export function isTimeWithinTween( } export function resolveTweenStart(animation: GsapAnimation): number | null { + if (animation.resolvedStart != null) return animation.resolvedStart; if (typeof animation.position === "number") return animation.position; const parsed = Number.parseFloat(animation.position as string); if (!Number.isNaN(parsed)) return parsed; @@ -34,7 +35,7 @@ export function resolveTweenStart(animation: GsapAnimation): number | null { } export function resolveTweenDuration(animation: GsapAnimation): number { - return animation.duration ?? 1; + return animation.duration ?? 0.5; } export function findTweenAtTime(