diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index 9d4a3c5244..fa06032dd5 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -2499,6 +2499,21 @@ export function updateKeyframeInScript( return recast.print(arrLoc.parsed.ast).code; } + // motionPath waypoints are exposed to Studio as synthetic keyframes, while + // their source representation owns one tween-level ease. Route an ease-only + // segment edit to that authored owner instead of silently no-oping because a + // literal keyframes block does not exist. + if ( + arrLoc && + !arrVal && + ease !== undefined && + Object.keys(properties).length === 0 && + findPropertyNode(arrLoc.target.call.varsArg, "motionPath") + ) { + applyUpdatesToCall(arrLoc.target.call, { ease }); + return recast.print(arrLoc.parsed.ast).code; + } + const ctx = locateKeyframeCtx(script, animationId, percentage); if (!ctx) return script; const { loc, kfNode } = ctx; diff --git a/packages/parsers/src/gsapWriter.parity.test.ts b/packages/parsers/src/gsapWriter.parity.test.ts index 856701a851..f9e3abaf10 100644 --- a/packages/parsers/src/gsapWriter.parity.test.ts +++ b/packages/parsers/src/gsapWriter.parity.test.ts @@ -1049,6 +1049,10 @@ const UPD_ARRAY_SCRIPT = ` const tl = gsap.timeline({ paused: true }); tl.to("#dot", { keyframes: [{ x: 0, y: 0 }, { x: 50, y: 80 }, { x: 100, y: 0 }], duration: 1 }, 0.2); `; +const UPD_MOTION_PATH_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#title", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: 40 }] }, duration: 1, ease: "power1.inOut" }, 0.2); +`; describe("parity: updateKeyframeInScript (recast vs acorn)", () => { function expectParity( @@ -1100,6 +1104,17 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => { ); }); + it("updates the authored tween ease for synthetic motion-path keyframes", () => { + const id = acornId(UPD_MOTION_PATH_SCRIPT); + const ease = "spring(0.42)"; + const acorn = updateKeyframeAcorn(UPD_MOTION_PATH_SCRIPT, id, 100, {}, ease); + const recast = updateKeyframeRecast(UPD_MOTION_PATH_SCRIPT, id, 100, {}, ease); + + expect(modelOf(acorn)).toEqual(modelOf(recast)); + expect(parseGsapScript(acorn).animations[0]?.ease).toBe(ease); + expect(parseGsapScript(recast).animations[0]?.ease).toBe(ease); + }); + // KNOWN DIVERGENCE (acorn-array bug, follow-up — NOT a test artifact): // For PARTIAL props on ARRAY-form keyframes the two writers disagree. recast's // array branch (gsapParser.updateKeyframeInScript) does a whole-value REPLACE diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index e41b8805f8..de9d6a676e 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -879,7 +879,21 @@ export function updateKeyframeInScript( if (!target) return script; const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); - if (!kfPropNode) return script; + if (!kfPropNode) { + // motionPath waypoints are exposed to Studio as synthetic keyframes, but + // GSAP authors one ease for the whole motionPath tween. An ease-only edit + // therefore belongs on the tween instead of a nonexistent keyframes block. + if ( + ease !== undefined && + Object.keys(properties).length === 0 && + findPropertyNode(target.call.varsArg, "motionPath") + ) { + const ms = new MagicString(script); + upsertProp(ms, target.call.varsArg, "ease", ease); + return ms.toString(); + } + return script; + } // Array-form keyframes (`keyframes: [{x,y}, ...]`) carry no explicit percentages // — GSAP distributes them evenly, and the runtime read assigns even percentages diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index d4f7f4d4ab..9f829a972c 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -1539,6 +1539,47 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0); expect(result.after).not.toContain("data-hf-studio-rotation"); }); + it("replace-with-keyframes preserves per-segment easing for exact temporal keyframes", async () => { + const projectDir = createProjectDir(); + const PATH_COMP = ` +
+ +`; + writeHtml(projectDir, "path.html", PATH_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "path.html"); + const res = await app.request("http://localhost/projects/demo/gsap-mutations/path.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: "#box", + position: 12.17, + duration: 16.055, + keyframes: [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 23.2, properties: { x: 25, y: 30 } }, + { percentage: 100, properties: { x: 100, y: 100 } }, + ], + ease: "none", + }), + }); + const result = (await res.json()) as { ok: boolean; after: string }; + + expect(res.status).toBe(200); + expect(result.ok).toBe(true); + expect(result.after).toContain('"23.2%"'); + expect(result.after).toContain('easeEach: "power1.inOut"'); + expect(result.after).toContain('ease: "none"'); + expect(result.after).not.toContain("motionPath"); + }); + it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => { const projectDir = createProjectDir(); writeComp(projectDir, "scene.html", TEMPLATE_COMP); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index fab9795378..1330588aa6 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -936,6 +936,7 @@ type GsapMutationRequest = auto?: boolean; }>; ease?: string; + easeEach?: string; } | { type: "split-animations"; @@ -991,6 +992,18 @@ type GsapMutationRequest = type GsapMutationResult = string | { script: string; skippedSelectors: string[] }; +function resolveReplacementEaseEach( + scriptText: string, + request: { animationId: string; easeEach?: string }, +): string | undefined { + if (request.easeEach !== undefined) return request.easeEach; + const original = parseGsapScriptAcorn(scriptText).animations.find( + (animation) => animation.id === request.animationId, + ); + if (!original?.arcPath?.enabled) return undefined; + return original?.keyframes?.easeEach ?? original?.ease; +} + // Mutations that can change a position tween's first keyframe (value/existence/timing) // and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards. // `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts @@ -1396,6 +1409,7 @@ function executeGsapMutationAcorn( body.duration, body.keyframes, body.ease, + resolveReplacementEaseEach(block.scriptText, body), ); return added.script; } @@ -1750,6 +1764,7 @@ async function executeGsapMutationRecast( body.duration, body.keyframes, body.ease, + body.easeEach, ); return result.script; } @@ -1765,6 +1780,7 @@ async function executeGsapMutationRecast( body.duration, body.keyframes, body.ease, + resolveReplacementEaseEach(block.scriptText, body), ); return added.script; } diff --git a/packages/studio/src/hooks/gsapDragPositionCommit.ts b/packages/studio/src/hooks/gsapDragPositionCommit.ts index ac1d760126..b941eabdaa 100644 --- a/packages/studio/src/hooks/gsapDragPositionCommit.ts +++ b/packages/studio/src/hooks/gsapDragPositionCommit.ts @@ -259,6 +259,54 @@ export async function commitGsapPositionFromDrag( const backfillDefaults: Record = { x: baseGsapX, y: baseGsapY }; const ct = usePlayerStore.getState().currentTime; + if (anim.arcPath?.enabled) { + const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); + const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); + const keyframes = anim.keyframes?.keyframes ?? []; + const pointIndex = keyframes.findIndex((kf) => Math.abs(kf.percentage - pct) < 0.05); + if (pointIndex >= 0) { + await callbacks.commitMutation( + selection, + { + type: "update-motion-path-point", + animationId: anim.id, + pointIndex, + x: newX, + y: newY, + }, + { label: "Move layer (waypoint)", softReload: true, beforeReload: restoreOffset }, + ); + setActiveKeyframePct(null); + parkPlayheadOnKeyframe(anim, pct); + return; + } + + const tweenStart = resolveTweenStart(anim); + const tweenDuration = resolveTweenDuration(anim); + if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return; + const temporalKeyframes = [ + ...keyframes.map((kf) => ({ + percentage: kf.percentage, + properties: { ...kf.properties }, + ...(kf.ease ? { ease: kf.ease } : {}), + })), + { percentage: pct, properties: { x: newX, y: newY } }, + ].sort((a, b) => a.percentage - b.percentage); + await callbacks.commitMutation( + selection, + { + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, + position: roundTo3(tweenStart), + duration: roundTo3(tweenDuration), + keyframes: temporalKeyframes, + ease: "none", + }, + { label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset }, + ); + return; + } if (anim.keyframes) { const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); const effectiveAnim = newId ? { ...anim, id: newId } : anim; diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts index 7c78c05872..078f184c6c 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts @@ -280,3 +280,103 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => { expect(types).not.toContain("replace-with-keyframes"); }); }); + +describe("tryGsapDragIntercept — motion paths", () => { + const motionPathAnim = { + id: "#puck-b-to-12170-position", + targetSelector: "#puck-b", + propertyGroup: "position", + method: "to", + position: 12.17, + resolvedStart: 12.17, + duration: 16.055, + ease: "power1.inOut", + properties: {}, + keyframes: { + keyframes: [ + { percentage: 0, properties: { x: -184, y: 326 } }, + { percentage: 50, properties: { x: 416, y: 804 } }, + { percentage: 100, properties: { x: 796, y: 237 } }, + ], + }, + arcPath: { + enabled: true, + autoRotate: false, + segments: [{ curviness: 1 }, { curviness: 1 }], + }, + } as unknown as GsapAnimation; + const liveTween = { + targets: () => [{ id: "puck-b" }], + vars: { motionPath: { path: [] }, duration: 16.055 }, + duration: () => 16.055, + startTime: () => 12.17, + }; + + async function dragMotionPath(activeKeyframePct: number | null) { + usePlayerStore.setState({ + autoKeyframeEnabled: true, + activeKeyframePct, + currentTime: 15.9, + }); + const commitMutation = vi.fn(); + const handled = await tryGsapDragIntercept( + selection, + { x: -50, y: 30 }, + [motionPathAnim], + fakeIframe("puck-b", [liveTween]), + commitMutation, + ); + return { commitMutation, handled }; + } + + afterEach(() => { + usePlayerStore.setState({ activeKeyframePct: null }); + }); + + it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => { + const { commitMutation, handled } = await dragMotionPath(null); + + expect(handled).toBe(true); + expect(commitMutation).toHaveBeenCalledWith( + selection, + { + type: "replace-with-keyframes", + animationId: motionPathAnim.id, + targetSelector: "#puck-b", + position: 12.17, + duration: 16.055, + keyframes: [ + { percentage: 0, properties: { x: -184, y: 326 } }, + { percentage: 23.2, properties: { x: -50, y: 30 } }, + { percentage: 50, properties: { x: 416, y: 804 } }, + { percentage: 100, properties: { x: 796, y: 237 } }, + ], + ease: "none", + }, + expect.objectContaining({ label: "Move layer (new keyframe)", softReload: true }), + ); + expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain( + "add-motion-path-point", + ); + }); + + it("keeps an explicitly selected path waypoint as a spatial edit", async () => { + const { commitMutation, handled } = await dragMotionPath(50); + + expect(handled).toBe(true); + expect(commitMutation).toHaveBeenCalledWith( + selection, + { + type: "update-motion-path-point", + animationId: motionPathAnim.id, + pointIndex: 1, + x: -50, + y: 30, + }, + expect.objectContaining({ label: "Move layer (waypoint)", softReload: true }), + ); + expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain( + "replace-with-keyframes", + ); + }); +}); diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts index 98c263f2ad..459ab37d6d 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts @@ -12,6 +12,7 @@ import { import type { TimelineElement } from "../player/store/playerStore"; import { usePlayerStore } from "../player/store/playerStore"; import type { CommitMutationOptions } from "./gsapScriptCommitTypes"; +import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity"; afterEach(() => { usePlayerStore.getState().reset(); @@ -316,4 +317,51 @@ describe("deleteSelectedKeyframes", () => { expect(options[1]).not.toHaveProperty("softReload"); expect(options[2]).not.toHaveProperty("skipReload"); }); + + it("deletes two expanded lanes through their own animation and tween percentages", () => { + usePlayerStore.setState({ + selectedElementId: "card", + selectedKeyframes: new Set([ + timelineKeyframeSelectionKey("card", { + percentage: 30, + tweenPercentage: 20, + propertyGroup: "position", + animationId: "card-position", + }), + timelineKeyframeSelectionKey("card", { + percentage: 70, + tweenPercentage: 80, + propertyGroup: "visual", + animationId: "card-visual", + }), + ]), + }); + const handleGsapRemoveKeyframe = + vi.fn<(animId: string, pct: number, options?: Partial) => void>(); + + deleteSelectedKeyframes({ + selectedGsapAnimations: [ + { id: "card-position", keyframes: {} }, + { id: "card-visual", keyframes: {} }, + ], + handleGsapRemoveKeyframe, + }); + + expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(2); + expect( + handleGsapRemoveKeyframe.mock.calls.map(([animationId, percentage]) => [ + animationId, + percentage, + ]), + ).toEqual([ + ["card-position", 20], + ["card-visual", 80], + ]); + expect(handleGsapRemoveKeyframe.mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ skipReload: true }), + ); + expect(handleGsapRemoveKeyframe.mock.calls[1]?.[2]).toEqual( + expect.objectContaining({ softReload: true }), + ); + }); }); diff --git a/packages/studio/src/hooks/useDomEditSession.test.tsx b/packages/studio/src/hooks/useDomEditSession.test.tsx index fda24c9592..ab84bf915c 100644 --- a/packages/studio/src/hooks/useDomEditSession.test.tsx +++ b/packages/studio/src/hooks/useDomEditSession.test.tsx @@ -314,7 +314,7 @@ describe("bulk segment ease commits", () => { gsapCommitMutation.mockClear(); gsapCommitMutation.batch.mockClear(); let updateSegmentEase: - | ((animationIds: string[], tweenPct: number, ease: string) => void) + | ((targets: Array<{ animationId: string; tweenPercentage: number }>, ease: string) => void) | undefined; function Probe() { @@ -362,17 +362,22 @@ describe("bulk segment ease commits", () => { expect(updateSegmentEase).toBeTypeOf("function"); if (!updateSegmentEase) return; const options = { label: "Update segment ease", softReload: true }; - updateSegmentEase(["move-x", "move-y", "fade"], 100, "power2.inOut"); + const targets = [ + { animationId: "move-x", tweenPercentage: 20 }, + { animationId: "move-y", tweenPercentage: 50 }, + { animationId: "fade", tweenPercentage: 80 }, + ]; + updateSegmentEase(targets, "power2.inOut"); expect(gsapCommitMutation).not.toHaveBeenCalled(); expect(gsapCommitMutation.batch).toHaveBeenCalledTimes(1); expect(gsapCommitMutation.batch).toHaveBeenCalledWith( - ["move-x", "move-y", "fade"].map((animationId) => ({ + targets.map(({ animationId, tweenPercentage }) => ({ selection, mutation: { type: "update-keyframe", animationId, - percentage: 100, + percentage: tweenPercentage, properties: {}, ease: "power2.inOut", }, @@ -383,7 +388,7 @@ describe("bulk segment ease commits", () => { gsapCommitMutation.mockClear(); gsapCommitMutation.batch.mockClear(); - updateSegmentEase(["fade"], 25, "none"); + updateSegmentEase([{ animationId: "fade", tweenPercentage: 25 }], "none"); expect(gsapCommitMutation).toHaveBeenCalledTimes(1); expect(gsapCommitMutation).toHaveBeenCalledWith( selection, @@ -399,7 +404,7 @@ describe("bulk segment ease commits", () => { expect(gsapCommitMutation.batch).not.toHaveBeenCalled(); gsapCommitMutation.mockClear(); - updateSegmentEase([], 50, "linear"); + updateSegmentEase([], "linear"); expect(gsapCommitMutation).not.toHaveBeenCalled(); expect(gsapCommitMutation.batch).not.toHaveBeenCalled(); } finally { diff --git a/packages/studio/src/player/components/timelineKeyframeIdentity.test.ts b/packages/studio/src/player/components/timelineKeyframeIdentity.test.ts new file mode 100644 index 0000000000..5b774715ae --- /dev/null +++ b/packages/studio/src/player/components/timelineKeyframeIdentity.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + timelineKeyframeSelectionKey, + timelineKeyframeTargetFromSelectionKey, +} from "./timelineKeyframeIdentity"; + +describe("timeline keyframe selection identity", () => { + it("round-trips an expanded lane with colon-bearing identities", () => { + const key = timelineKeyframeSelectionKey("comp#a:child", { + percentage: 75, + tweenPercentage: 40, + propertyGroup: "position", + animationId: "child:position", + }); + + expect(timelineKeyframeTargetFromSelectionKey("comp#a:child", key)).toEqual({ + percentage: 75, + tweenPercentage: 40, + propertyGroup: "position", + animationId: "child:position", + }); + }); + + it("does not confuse an expanded lane whose element id extends the active id", () => { + const key = timelineKeyframeSelectionKey("comp#a:child", { + percentage: 75, + tweenPercentage: 40, + propertyGroup: "position", + animationId: "child-position", + }); + + expect(timelineKeyframeTargetFromSelectionKey("comp#a", key)).toBeNull(); + }); + + it("retains the collapsed key fallback and rejects malformed percentages", () => { + expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:30")).toEqual({ + percentage: 30, + }); + expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:NaN")).toBeNull(); + expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#b:30")).toBeNull(); + }); +});