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");
});
});
+41 -1
View File
@@ -26,6 +26,7 @@ import {
findSizeSetAnimation,
materializeIfDynamic,
} from "./gsapDragCommit";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
import { selectorFromSelection } from "./gsapShared";
@@ -225,7 +226,12 @@ export async function tryGsapDragIntercept(
}
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
if (options?.altKey) {
// Alt-drag already means "shift the whole path" — the global auto-keyframe
// toggle (#1808) just makes that the default while it's off, so a manual
// edit on an already-animated element nudges the animation instead of
// inserting/updating a keyframe at the playhead.
const autoKeyframeEnabled = usePlayerStore.getState().autoKeyframeEnabled;
if (options?.altKey || !autoKeyframeEnabled) {
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
} else {
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
@@ -332,6 +338,24 @@ export async function tryGsapResizeIntercept(
height: Math.round(size.height),
};
}
// With auto-keyframe off (#1808), `anim` is already a real (non-"set")
// tween for this resize group, so nudge it as a whole rather than adding a
// keyframe at the playhead.
if (!usePlayerStore.getState().autoKeyframeEnabled) {
if (activeKeyframePct != null) setActiveKeyframePct(null);
await commitWholePropertyOffset(
selection,
anim,
resizeProps,
pct,
iframe,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
"Resize animation",
);
return true;
}
const ct = usePlayerStore.getState().currentTime;
const ts = resolveTweenStart(anim);
const td = resolveTweenDuration(anim);
@@ -490,6 +514,22 @@ export async function tryGsapRotationIntercept(
const pct = computeCurrentPercentage(selection, anim);
// With auto-keyframe off (#1808), a rotation tween already exists for this
// element (checked above) so nudge it as a whole rather than adding a
// keyframe at the playhead.
if (!usePlayerStore.getState().autoKeyframeEnabled) {
await commitWholePropertyOffset(
selection,
anim,
{ rotation: newRotation },
pct,
iframe,
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
"Rotate animation",
);
return true;
}
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
if (newId) anim = { ...anim, id: newId };
@@ -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 } });
});
});
@@ -0,0 +1,76 @@
/**
* commitWholePropertyOffset — extracted from gsapDragCommit.ts to keep file
* sizes under the 600-line limit (mirrors gsapDragPositionCommit.ts).
*/
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import { PROPERTY_DEFAULTS } from "./gsapShared";
import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
import { materializeIfDynamic, type GsapDragCommitCallbacks } from "./gsapDragCommit";
/**
* Generic sibling of commitWholePathOffset for property groups other than
* position (rotation, size, scale) — shift every keyframe of `anim` by the
* same delta for each key in `newValues`, preserving the tween's shape. The
* delta is computed against the keyframe NEAREST `currentPct` (the one an
* ordinary edit would otherwise overwrite), not the live DOM: by the time a
* drag gesture reaches its commit step, the preview has often already
* gsap.set the dragged property to its new value, so the DOM can no longer
* tell "old" from "new".
*/
export async function commitWholePropertyOffset(
selection: DomEditSelection,
anim: GsapAnimation,
newValues: Record<string, number>,
currentPct: number,
iframe: HTMLIFrameElement | null,
callbacks: GsapDragCommitCallbacks,
label: string,
): Promise<void> {
let effectiveAnim = anim;
if (anim.keyframes) {
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
if (newId) effectiveAnim = { ...anim, id: newId };
}
const ts = resolveTweenStart(effectiveAnim);
const td = resolveTweenDuration(effectiveAnim);
const ease = effectiveAnim.keyframes?.easeEach ?? effectiveAnim.ease;
const keys = Object.keys(newValues);
const at = (props: Record<string, number | string>, key: string) =>
typeof props[key] === "number" ? (props[key] as number) : (PROPERTY_DEFAULTS[key] ?? 0);
const kfs =
effectiveAnim.keyframes?.keyframes ??
synthesizeFlatTweenKeyframes(effectiveAnim)?.keyframes ??
[];
const nearest = kfs.reduce((best, kf) =>
Math.abs(kf.percentage - currentPct) < Math.abs(best.percentage - currentPct) ? kf : best,
);
const shifted = kfs.map((kf) => {
const properties = { ...kf.properties };
for (const key of keys) {
properties[key] = roundTo3(
at(properties, key) + (newValues[key]! - at(nearest.properties, key)),
);
}
return { percentage: kf.percentage, properties, ...(kf.ease ? { ease: kf.ease } : {}) };
});
await callbacks.commitMutation(
selection,
{
type: "replace-with-keyframes",
animationId: effectiveAnim.id,
targetSelector: effectiveAnim.targetSelector,
position: roundTo3(ts ?? 0),
duration: roundTo3(td || 1),
keyframes: shifted,
ease,
},
{ label, softReload: true },
);
}
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player/store/playerStore";
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 0 });
});
const selection = { id: "box", selector: "#box" } as DomEditSelection;
const keyframedAnim = {
id: "#box-to-position",
targetSelector: "#box",
propertyGroup: "position",
method: "to",
properties: {},
resolvedStart: 0,
duration: 2,
keyframes: {
keyframes: [
{ percentage: 0, properties: { x: 0, y: 0 } },
{ percentage: 100, properties: { x: 100, y: 0 } },
],
},
} as unknown as GsapAnimation;
type Commit = (
selection: DomEditSelection,
props: Record<string, number | string>,
) => Promise<void>;
/** Renders the hook and hands its commit function to the caller via a ref callback. */
function renderCommitHook(
mutations: Array<Record<string, unknown>>,
onReady: (commit: Commit) => void,
) {
function Harness() {
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
selectedGsapAnimations: [keyframedAnim],
gsapCommitMutation: async (_sel, mutation) => {
mutations.push(mutation);
},
addGsapAnimation: vi.fn(),
convertToKeyframes: vi.fn(),
previewIframeRef: { current: null },
bumpGsapCache: vi.fn(),
});
onReady(commitAnimatedProperties);
return null;
}
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<Harness />);
});
return root;
}
// Regression (#1808): a "3D transform" / design-panel property edit on an
// element that already has a keyframed tween is the ACTUAL path a manual
// canvas nudge exercises (not the raw drag intercept) — with auto-keyframe
// off, it must shift the whole tween instead of adding/updating a keyframe
// at the playhead.
describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", () => {
it("shifts the whole tween instead of updating a keyframe when the toggle is off", async () => {
usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 0 });
const mutations: Array<Record<string, unknown>> = [];
let commit: Commit | undefined;
const root = renderCommitHook(mutations, (fn) => (commit = fn));
await act(async () => {
await commit!(selection, { x: 50 });
});
expect(mutations).toHaveLength(1);
expect(mutations[0]!.type).toBe("replace-with-keyframes");
act(() => root.unmount());
});
it("still updates a keyframe at the playhead when the toggle is on (default)", async () => {
const mutations: Array<Record<string, unknown>> = [];
let commit: Commit | undefined;
const root = renderCommitHook(mutations, (fn) => (commit = fn));
await act(async () => {
await commit!(selection, { x: 50 });
});
expect(mutations.some((m) => m.type === "update-keyframe" || m.type === "add-keyframe")).toBe(
true,
);
expect(mutations.some((m) => m.type === "replace-with-keyframes")).toBe(false);
act(() => root.unmount());
});
});
@@ -17,6 +17,7 @@ import type { SetPatchProps } from "./gsapRuntimePatch";
import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
interface CommitAnimatedPropertyDeps {
selectedGsapAnimations: GsapAnimation[];
@@ -341,6 +342,27 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
// keyframe would never land (the bug: scrolling depth on a keyframed element
// just changed the constant instead of dropping a keyframe).
if (elementHasKeyframes && anim) {
// With auto-keyframe off (#1808), nudge the whole tween instead of
// adding/updating a keyframe at the playhead.
if (!usePlayerStore.getState().autoKeyframeEnabled) {
const pct = computeElementPercentage(
usePlayerStore.getState().currentTime,
selection,
anim,
);
await commitWholePropertyOffset(
selection,
anim,
Object.fromEntries(
propEntries.filter((e): e is [string, number] => typeof e[1] === "number"),
),
pct,
iframe,
{ commitMutation: gsapCommitMutation },
`Edit ${primaryProp} (whole animation)`,
);
return;
}
await commitKeyframeProps(
selection,
anim,