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
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
// Regression (#1808): with auto-keyframe recording off, a manual edit on an
// element that already has a keyframed tween must shift every keyframe by
// the edit's delta (preserving the animation's shape) instead of inserting
// or updating a keyframe at the playhead.
const selection = (): DomEditSelection => ({ id: "box", selector: "#box" }) as DomEditSelection;
function recordingCallbacks(): {
mutations: Array<Record<string, unknown>>;
callbacks: GsapDragCommitCallbacks;
} {
const mutations: Array<Record<string, unknown>> = [];
return {
mutations,
callbacks: {
commitMutation: async (_sel, mutation) => {
mutations.push(mutation);
},
},
};
}
describe("commitWholePropertyOffset", () => {
it("shifts every keyframe of a keyframed tween by the delta at the nearest keyframe", async () => {
const anim = {
id: "#box-rotate",
targetSelector: "#box",
method: "to",
resolvedStart: 0,
duration: 2,
keyframes: {
keyframes: [
{ percentage: 0, properties: { rotation: 10 } },
{ percentage: 50, properties: { rotation: 20 } },
{ percentage: 100, properties: { rotation: 40 } },
],
},
} as unknown as GsapAnimation;
const { mutations, callbacks } = recordingCallbacks();
// currentPct=48 lands nearest the 50% keyframe (rotation 20) — dragging to
// 30 is a +10 delta, applied to every keyframe.
await commitWholePropertyOffset(
selection(),
anim,
{ rotation: 30 },
48,
null,
callbacks,
"Rotate",
);
expect(mutations).toHaveLength(1);
const mutation = mutations[0]!;
expect(mutation.type).toBe("replace-with-keyframes");
const keyframes = mutation.keyframes as Array<{
percentage: number;
properties: Record<string, number>;
}>;
expect(keyframes.map((k) => k.percentage)).toEqual([0, 50, 100]);
expect(keyframes.map((k) => k.properties.rotation)).toEqual([20, 30, 50]);
});
it("materializes a flat tween into a 2-point range before shifting", async () => {
const anim = {
id: "#box-fade",
targetSelector: "#box",
method: "fromTo",
resolvedStart: 0,
duration: 1,
properties: { opacity: 1 },
fromProperties: { opacity: 0 },
} as unknown as GsapAnimation;
const { mutations, callbacks } = recordingCallbacks();
// Nearest keyframe to pct=100 is the synthesized 100% stop (opacity 1) —
// dragging opacity to 0.5 is a -0.5 delta, applied to both stops.
await commitWholePropertyOffset(
selection(),
anim,
{ opacity: 0.5 },
100,
null,
callbacks,
"Fade",
);
expect(mutations).toHaveLength(1);
const keyframes = mutations[0]!.keyframes as Array<{
percentage: number;
properties: Record<string, number>;
}>;
expect(keyframes).toEqual([
{ percentage: 0, properties: { opacity: -0.5 } },
{ percentage: 100, properties: { opacity: 0.5 } },
]);
});
it("preserves keyframes' other properties and per-keyframe ease untouched", async () => {
const anim = {
id: "#box-move",
targetSelector: "#box",
method: "to",
resolvedStart: 0,
duration: 1,
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: 0, opacity: 1 }, ease: "power1.in" },
{ percentage: 100, properties: { x: 100, opacity: 0.5 } },
],
},
} as unknown as GsapAnimation;
const { mutations, callbacks } = recordingCallbacks();
await commitWholePropertyOffset(selection(), anim, { x: 120 }, 100, null, callbacks, "Move");
const keyframes = mutations[0]!.keyframes as Array<{
percentage: number;
properties: Record<string, number>;
ease?: string;
}>;
// Delta is +20 (120 - 100 at the nearest, 100%, keyframe).
expect(keyframes[0]).toEqual({
percentage: 0,
properties: { x: 20, opacity: 1 },
ease: "power1.in",
});
expect(keyframes[1]).toEqual({ percentage: 100, properties: { x: 120, opacity: 0.5 } });
});
});