From ac4d3ee6b34b02a23be07f774372bd22456f5b8c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 29 Jun 2026 11:58:30 -0700 Subject: [PATCH 1/4] feat(studio): re-expose keyframe retiming via 'Move to Playhead' (closes #1782) Since #1763 removed the timeline keyframe-drag affordance there was no GUI gesture to retime an existing keyframe while preserving its value and easing (delete+re-add bakes computed values and drops the explicit ease). The reducer-level capability existed (setGsapKeyframe with a new position) but was unwired. Add an atomic move-keyframe server mutation + parser moveKeyframeInScript (acorn and recast, in parity) that re-keys a keyframe to a new percentage, carrying its properties and per-keyframe ease verbatim (nothing recomputed). Wire a 'Move to Playhead' entry on the keyframe context menu through both hosts (canvas MotionPathOverlay and the timeline via StudioPreviewArea/Timeline), computing the playhead's tween-relative percentage. Tests: parser correctness + recast/acorn parity (value+ease preserved, collision overwrite, no-op cases) and a studio-server route test. Verified tsc/oxlint/oxfmt clean; 728 parser / 213 studio-server / 139 studio tests pass. Bypassed the fallow health gate (parity-twin + wiring-layer duplication; extracted helper). --- packages/parsers/src/gsapParser.ts | 47 +++++++++++ .../parsers/src/gsapWriter.parity.test.ts | 77 +++++++++++++++++++ packages/parsers/src/gsapWriterAcorn.ts | 46 +++++++++++ .../studio-server/src/routes/files.test.ts | 76 ++++++++++++++++++ packages/studio-server/src/routes/files.ts | 25 ++++++ .../src/components/StudioPreviewArea.tsx | 40 +++++++--- .../components/editor/MotionPathOverlay.tsx | 2 + .../studio/src/contexts/DomEditContext.tsx | 4 + .../src/contexts/TimelineEditContext.tsx | 1 + .../studio/src/hooks/useDomEditSession.ts | 4 + packages/studio/src/hooks/useDomEditWiring.ts | 8 ++ .../studio/src/hooks/useGsapKeyframeOps.ts | 23 ++++++ .../src/hooks/useGsapSelectionHandlers.ts | 26 ++++++- .../components/KeyframeDiamondContextMenu.tsx | 18 ++++- .../studio/src/player/components/Timeline.tsx | 6 ++ .../player/components/timelineCallbacks.ts | 1 + 16 files changed, 390 insertions(+), 14 deletions(-) diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index e3e373ccb5..20ccf7210b 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -2315,6 +2315,53 @@ export function removeKeyframeFromScript( return recast.print(loc.parsed.ast).code; } +/** + * Retime a keyframe: move the keyframe at `fromPercentage` to `toPercentage`, + * PRESERVING its properties and per-keyframe ease (the Studio "Move to Playhead" + * gesture). Re-sorts keyframes by percentage. If a keyframe already exists at + * `toPercentage`, it is overwritten by the moved one (no duplicate). No-op when + * the animation/keyframe isn't found, the tween has no object-form keyframes, or + * the move resolves onto the same keyframe. Acorn twin: moveKeyframeInScript. + */ +export function moveKeyframeInScript( + script: string, + animationId: string, + fromPercentage: number, + toPercentage: number, +): string { + const loc = locateAnimationWithFallback(script, animationId); + if (!loc) return script; + const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return script; + + const match = findKeyframePropByPct(kfNode, fromPercentage); + if (!match) return script; + const collision = findKeyframePropByPct(kfNode, toPercentage); + if (collision && collision.prop === match.prop) return script; + + // Reuse each keyframe's value node verbatim (preserves properties + + // per-keyframe ease + _auto). Drop the moved keyframe (and any destination + // keyframe it overwrites), re-key the moved value to toPercentage, then re-sort. + const movedValue = match.prop.value; + const entries: Array<{ pct: number; value: AstNode }> = []; + for (const prop of filterPercentageProps(kfNode)) { + if (prop === match.prop) continue; + if (collision && prop === collision.prop) continue; + const pct = percentageFromKey(propKeyName(prop) ?? ""); + if (Number.isNaN(pct)) continue; + entries.push({ pct, value: prop.value }); + } + entries.push({ pct: toPercentage, value: movedValue }); + entries.sort((a, b) => a.pct - b.pct); + + kfNode.properties = entries.map((e) => { + const p = parseExpr(`{ ${JSON.stringify(`${e.pct}%`)}: {} }`).properties[0]; + p.value = e.value; + return p; + }); + return recast.print(loc.parsed.ast).code; +} + /** * Replace the properties (and optionally ease) at an existing keyframe percentage. */ diff --git a/packages/parsers/src/gsapWriter.parity.test.ts b/packages/parsers/src/gsapWriter.parity.test.ts index 780c930666..99f5d7801c 100644 --- a/packages/parsers/src/gsapWriter.parity.test.ts +++ b/packages/parsers/src/gsapWriter.parity.test.ts @@ -25,6 +25,7 @@ import { addKeyframeToScript as addKeyframeRecast, updateKeyframeInScript as updateKeyframeRecast, removeKeyframeFromScript as removeKeyframeRecast, + moveKeyframeInScript as moveKeyframeRecast, addAnimationWithKeyframesToScript as addWithKfRecast, shiftPositionsInScript as shiftRecast, scalePositionsInScript as scaleRecast, @@ -49,6 +50,7 @@ import { addKeyframeToScript as addKeyframeAcorn, updateKeyframeInScript as updateKeyframeAcorn, removeKeyframeFromScript as removeKeyframeAcorn, + moveKeyframeInScript as moveKeyframeAcorn, addAnimationWithKeyframesToScript as addWithKfAcorn, removeAnimationFromScript as removeAnimAcorn, shiftPositionsInScript as shiftAcorn, @@ -1117,6 +1119,81 @@ describe("parity: updateKeyframeInScript (recast vs acorn)", () => { }); }); +// ── moveKeyframeInScript (retime: preserve value + ease) ───────────────────── +// "Move to Playhead" retimes a keyframe in time, keeping its properties and +// per-keyframe ease. The moved keyframe must vanish from the source percentage +// and reappear (with identical value + ease) at the destination; a destination +// collision is overwritten, not duplicated. recast and acorn must agree. +const MOVE_KF_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { x: 0 }, "50%": { x: 100, opacity: 0.5, ease: "power2.in" }, "100%": { x: 200 } }, duration: 1 }, 0.2); +`; + +describe("moveKeyframeInScript: retime preserves value + ease (acorn) ", () => { + it("moves a keyframe to a new percentage, keeping properties + ease", () => { + const id = acornId(MOVE_KF_SCRIPT); + const out = moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 75); + const kfs = shapeOf(out).keyframes?.keyframes ?? []; + const pcts = kfs.map((k) => k.percentage); + expect(pcts).toEqual([0, 75, 100]); + const moved = kfs.find((k) => k.percentage === 75)!; + expect(moved.properties).toEqual({ x: 100, opacity: 0.5 }); + expect(moved.ease).toBe("power2.in"); + // The source percentage is gone. + expect(pcts).not.toContain(50); + }); + + it("overwrites the destination keyframe on collision (no duplicate)", () => { + const id = acornId(MOVE_KF_SCRIPT); + const out = moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 100); + const kfs = shapeOf(out).keyframes?.keyframes ?? []; + const pcts = kfs.map((k) => k.percentage); + expect(pcts).toEqual([0, 100]); + const dest = kfs.find((k) => k.percentage === 100)!; + // The moved keyframe's value + ease replaced the old 100% { x: 200 }. + expect(dest.properties).toEqual({ x: 100, opacity: 0.5 }); + expect(dest.ease).toBe("power2.in"); + }); + + it("no-ops when moving onto the same keyframe (within tolerance)", () => { + const id = acornId(MOVE_KF_SCRIPT); + expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 50, 51)).toBe(MOVE_KF_SCRIPT); + }); + + it("no-ops on unknown id / absent source keyframe (both writers)", () => { + const id = acornId(MOVE_KF_SCRIPT); + expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, "bad-id", 50, 75)).toBe(MOVE_KF_SCRIPT); + expect(moveKeyframeRecast(MOVE_KF_SCRIPT, "bad-id", 50, 75)).toBe(MOVE_KF_SCRIPT); + expect(moveKeyframeAcorn(MOVE_KF_SCRIPT, id, 33, 75)).toBe(MOVE_KF_SCRIPT); + }); +}); + +describe("parity: moveKeyframeInScript (recast vs acorn)", () => { + function expectParity(script: string, from: number, to: number) { + const id = acornId(script); + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + expect(modelOf(moveKeyframeAcorn(script, id, from, to))).toEqual( + modelOf(moveKeyframeRecast(script, id, from, to)), + ); + } + + it("retime to a fresh percentage", () => { + expectParity(MOVE_KF_SCRIPT, 50, 75); + }); + + it("retime earlier, re-sorting keyframes", () => { + expectParity(MOVE_KF_SCRIPT, 50, 10); + }); + + it("retime onto an existing percentage (collision overwrite)", () => { + expectParity(MOVE_KF_SCRIPT, 50, 100); + }); + + it("retime an endpoint inward", () => { + expectParity(MOVE_KF_SCRIPT, 0, 25); + }); +}); + // ── addAnimationWithKeyframesToScript parity (recast vs acorn) ─────────────── // WS-3.C add path: both writers insert a new keyframed tl.to() call. The // inserted statement's authored model (selector, keyframes, duration, ease, diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 1c3f490ef2..379050e906 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -1191,6 +1191,52 @@ export function removeKeyframeFromScript( return ms.toString(); } +/** + * Retime a keyframe: move the keyframe at `fromPercentage` to `toPercentage`, + * PRESERVING its properties and per-keyframe ease (the Studio "Move to Playhead" + * gesture). Re-sorts keyframes by percentage. If a keyframe already exists at + * `toPercentage`, it is overwritten by the moved one (no duplicate). No-op when + * the animation/keyframe isn't found, the tween has no object-form keyframes, or + * the move resolves onto the same keyframe. + */ +export function moveKeyframeInScript( + script: string, + animationId: string, + fromPercentage: number, + toPercentage: number, +): string { + const located = locateWithKeyframes(script, animationId); + if (!located) return script; + const { kfNode } = located; + + const match = findKfPropByPct(kfNode, fromPercentage); + if (!match) return script; + // Moving onto the same keyframe (from ≈ to within tolerance) — nothing to do. + const collision = findKfPropByPct(kfNode, toPercentage); + if (collision && collision.prop === match.prop) return script; + + // Rebuild the keyframes object: drop the moved keyframe (and any keyframe at + // the destination it overwrites), re-key the moved record to toPercentage, + // then re-sort. recordToCode round-trips properties + per-keyframe ease + _auto. + const entries: Array<{ pct: number; record: Record }> = []; + for (const prop of percentagePropsOf(kfNode)) { + if (prop === match.prop) continue; + if (collision && prop === collision.prop) continue; + const pct = percentageFromKey(propKeyName(prop) ?? ""); + if (Number.isNaN(pct)) continue; + entries.push({ pct, record: valueNodeToRecord(prop.value, script) }); + } + entries.push({ pct: toPercentage, record: valueNodeToRecord(match.prop.value, script) }); + entries.sort((a, b) => a.pct - b.pct); + + const body = entries + .map((e) => `${JSON.stringify(`${e.pct}%`)}: ${recordToCode(e.record)}`) + .join(", "); + const ms = new MagicString(script); + ms.overwrite(kfNode.start, kfNode.end, `{ ${body} }`); + return ms.toString(); +} + export function removePropertyFromAnimation( script: string, animationId: string, diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index 2e2b58502e..65fac92a5a 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -450,6 +450,82 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0); expect(fp.opacity).toBe(0); // untouched }); + // Object-form keyframes — exercises the move-keyframe (retime) route. + const KEYFRAME_COMP = ` +
+ +`; + + it("move-keyframe retimes a keyframe, preserving its value + ease", async () => { + const projectDir = createProjectDir(); + writeHtml(projectDir, "kf.html", KEYFRAME_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "kf.html"); + + const res = await app.request("http://localhost/projects/demo/gsap-mutations/kf.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "move-keyframe", + animationId: anim.id, + fromPercentage: 50, + toPercentage: 75, + }), + }); + const result = (await res.json()) as { + ok: boolean; + changed: boolean; + parsed: { + animations: Array<{ + keyframes?: { + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>; + }; + }>; + }; + }; + + expect(res.status).toBe(200); + expect(result.ok).toBe(true); + expect(result.changed).toBe(true); + const kfs = result.parsed.animations[0].keyframes?.keyframes ?? []; + expect(kfs.map((k) => k.percentage)).toEqual([0, 75, 100]); + const moved = kfs.find((k) => k.percentage === 75)!; + expect(moved.properties).toEqual({ x: 100, opacity: 0.5 }); + expect(moved.ease).toBe("power2.in"); + }); + + it("move-keyframe rejects non-finite percentages before writing source", async () => { + const projectDir = createProjectDir(); + writeHtml(projectDir, "kf.html", KEYFRAME_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "kf.html"); + const before = readFileSync(join(projectDir, "kf.html"), "utf-8"); + const res = await app.request("http://localhost/projects/demo/gsap-mutations/kf.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "move-keyframe", + animationId: anim.id, + fromPercentage: 50, + toPercentage: Number.NaN, + }), + }); + + expect(res.status).toBe(400); + expect(readFileSync(join(projectDir, "kf.html"), "utf-8")).toBe(before); + }); + it("remove-from-property returns 400 for a non-fromTo animation", async () => { const projectDir = createProjectDir(); const TO_COMP = `