feat(studio): add a global toggle to stop manual edits from auto-recording keyframes

Adds a control-bar toggle (next to the Add Keyframe diamond) that, when off,
makes a manual drag/resize/rotate/panel edit on an already-keyframed element
shift the whole tween by the edit's delta instead of inserting or updating a
keyframe at the playhead. The animation's shape is preserved, just moved.

Wired into every path that can auto-record a keyframe:
- canvas drag-to-move (tryGsapDragIntercept, reuses the existing Alt-drag
  "shift whole path" behavior)
- canvas resize/rotate (tryGsapResizeIntercept, tryGsapRotationIntercept)
- design-panel property edits (useAnimatedPropertyCommit)
- motion-path keyframe-node dragging (MotionPathOverlay), the path a plain
  click-drag on a keyframed element's canvas shape actually takes, since the
  element renders exactly at its current keyframe's position

The shared shift helper reuses synthesizeFlatTweenKeyframes for materializing
a flat tween instead of hand-rolling it, and lives in its own file
(gsapWholePropertyOffsetCommit.ts) to keep gsapDragCommit.ts under the
600-line cap, mirroring the existing gsapDragPositionCommit.ts split.

Fixes #1808
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-01 19:26:03 -07:00
parent 1b8b2ac425
commit 636d5dd6c5
10 changed files with 608 additions and 40 deletions
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
import { usePlayerStore } from "../player/store/playerStore";
/**
* Regression: `selectedGsapAnimations` (and the fetch fallback) is an async
@@ -178,3 +179,68 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
expect(staleLogged).toBe(false);
});
});
// Regression (#1808): with the global auto-keyframe toggle off, dragging an
// element that already has a keyframed position tween must shift the whole
// tween (a "replace-with-keyframes" carrying every original percentage) —
// the same path Alt-drag already takes — instead of inserting a keyframe at
// the playhead.
describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
afterEach(() => {
usePlayerStore.setState({ autoKeyframeEnabled: true });
});
const keyframedPositionAnim = {
id: "#puck-b-to-position",
targetSelector: "#puck-b",
propertyGroup: "position",
method: "to",
properties: {},
position: 0,
resolvedStart: 0,
duration: 2,
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: 100, y: 0 } },
],
},
} as unknown as GsapAnimation;
it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 2 }); // playhead at 100%
const commitMutation = vi.fn();
const iframe = fakeIframe("puck-b", []);
const handled = await tryGsapDragIntercept(
selection,
{ x: -50, y: 0 },
[keyframedPositionAnim],
iframe,
commitMutation,
);
expect(handled).toBe(true);
const types = commitMutation.mock.calls.map(([, m]) => m.type);
expect(types).toContain("replace-with-keyframes");
expect(types).not.toContain("add-keyframe");
});
it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 2 });
const commitMutation = vi.fn();
const iframe = fakeIframe("puck-b", []);
const handled = await tryGsapDragIntercept(
selection,
{ x: -50, y: 0 },
[keyframedPositionAnim],
iframe,
commitMutation,
);
expect(handled).toBe(true);
const types = commitMutation.mock.calls.map(([, m]) => m.type);
expect(types).not.toContain("replace-with-keyframes");
});
});