diff --git a/packages/studio/src/components/TimelineToolbar.test.tsx b/packages/studio/src/components/TimelineToolbar.test.tsx
new file mode 100644
index 000000000..4b772ed05
--- /dev/null
+++ b/packages/studio/src/components/TimelineToolbar.test.tsx
@@ -0,0 +1,50 @@
+// @vitest-environment happy-dom
+
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { usePlayerStore } from "../player/store/playerStore";
+import { TimelineToolbar } from "./TimelineToolbar";
+
+(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+afterEach(() => {
+ document.body.innerHTML = "";
+ usePlayerStore.setState({ autoKeyframeEnabled: true });
+});
+
+function renderToolbar() {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render();
+ });
+ return { host, root };
+}
+
+// Regression (#1808): the auto-keyframe toggle is a GLOBAL setting (unlike the
+// diamond "Add keyframe" button, which needs a selection to mean anything), so
+// it must stay visible and usable with nothing selected — it must not be
+// gated behind `domEditSession`/`onToggleKeyframe`.
+describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
+ it("renders enabled (pressed) by default with no selection", () => {
+ const { host, root } = renderToolbar();
+ const btn = host.querySelector('button[aria-pressed="true"]');
+ expect(btn).not.toBeNull();
+ act(() => root.unmount());
+ });
+
+ it("flips autoKeyframeEnabled in the store when clicked", () => {
+ const { host, root } = renderToolbar();
+ const btn = host.querySelector('button[aria-pressed="true"]')!;
+
+ act(() => {
+ btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(usePlayerStore.getState().autoKeyframeEnabled).toBe(false);
+ expect(host.querySelector('button[aria-pressed="false"]')).not.toBeNull();
+ act(() => root.unmount());
+ });
+});
diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx
index 68df0df32..bf46ddb7f 100644
--- a/packages/studio/src/components/TimelineToolbar.tsx
+++ b/packages/studio/src/components/TimelineToolbar.tsx
@@ -79,6 +79,8 @@ export function TimelineToolbar({
}: TimelineToolbarProps) {
const activeTool = usePlayerStore((s) => s.activeTool);
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
+ const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
+ const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
// Subscribe so the add-beat button reacts to playhead movement and analysis load.
const currentTime = usePlayerStore((s) => s.currentTime);
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
@@ -137,44 +139,84 @@ export function TimelineToolbar({
)}
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
- <>
-
+
- >
+
+
+
+ )}
+ {STUDIO_KEYFRAMES_ENABLED && (
+
+ setAutoKeyframeEnabled(!autoKeyframeEnabled)}
+ aria-pressed={autoKeyframeEnabled}
+ className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
+ autoKeyframeEnabled
+ ? "text-red-400 hover:text-red-300"
+ : "text-neutral-600 hover:text-neutral-400"
+ }`}
+ >
+
+
+
)}
{onSplitElement &&
(() => {
diff --git a/packages/studio/src/components/editor/MotionPathOverlay.tsx b/packages/studio/src/components/editor/MotionPathOverlay.tsx
index 42c211b1d..1ed95b0ce 100644
--- a/packages/studio/src/components/editor/MotionPathOverlay.tsx
+++ b/packages/studio/src/components/editor/MotionPathOverlay.tsx
@@ -3,6 +3,7 @@ import type { DomEditSelection } from "./domEditing";
import { useDomEditContext } from "../../contexts/DomEditContext";
import { usePlayerStore } from "../../player/store/playerStore";
import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
+import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
import { editableAnimationId, selectorFor } from "./motionPathSelection";
import { ACCENT, MotionPathNode } from "./MotionPathNode";
@@ -334,13 +335,35 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
// high zoom) would commit an identical value — a no-op undo entry. Skip the
// commit, but don't treat it as a click either (the user did drag).
if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
- void commitNode(d.ref, x, y, animId, commitMutation);
+ // With auto-keyframe off (#1808), dragging a keyframe's node on the motion
+ // path (the common way to nudge a KEYFRAMED element's position on canvas,
+ // since the element renders exactly at its current keyframe) shifts the
+ // whole path instead of moving just that one keyframe.
+ const anim =
+ d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
+ if (
+ d.ref.type === "keyframe" &&
+ anim &&
+ selection &&
+ !usePlayerStore.getState().autoKeyframeEnabled
+ ) {
+ void commitWholePropertyOffset(
+ selection,
+ anim,
+ { x, y },
+ d.ref.pct,
+ iframeRef.current,
+ { commitMutation: (_sel, mutation, options) => commitMutation(mutation, options) },
+ "Move animation path",
+ );
+ } else {
+ void commitNode(d.ref, x, y, animId, commitMutation);
+ }
// Park the playhead on the edited keyframe's time so the element previews AT
// that keyframe. Without it, a playhead sitting before the tween renders the
// element's base pose — the edit (correct on the path) looks like it vanished.
- if (d.ref.type === "keyframe") {
- const anim = selectedGsapAnimations?.find((a) => a.id === animId);
- if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct);
+ if (d.ref.type === "keyframe" && anim) {
+ parkPlayheadOnKeyframe(anim, d.ref.pct);
}
};
diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts
index 143fac9f9..a35809511 100644
--- a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts
+++ b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts
@@ -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");
+ });
+});
diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts
index c442c4b97..3fdbcc429 100644
--- a/packages/studio/src/hooks/gsapRuntimeBridge.ts
+++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts
@@ -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 };
diff --git a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts
new file mode 100644
index 000000000..aff002556
--- /dev/null
+++ b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts
@@ -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>;
+ callbacks: GsapDragCommitCallbacks;
+} {
+ const mutations: Array> = [];
+ 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;
+ }>;
+ 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;
+ }>;
+ 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;
+ 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 } });
+ });
+});
diff --git a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts
new file mode 100644
index 000000000..6363363c4
--- /dev/null
+++ b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts
@@ -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,
+ currentPct: number,
+ iframe: HTMLIFrameElement | null,
+ callbacks: GsapDragCommitCallbacks,
+ label: string,
+): Promise {
+ 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, 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 },
+ );
+}
diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx b/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx
new file mode 100644
index 000000000..038e03d21
--- /dev/null
+++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx
@@ -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,
+) => Promise;
+
+/** Renders the hook and hands its commit function to the caller via a ref callback. */
+function renderCommitHook(
+ mutations: Array>,
+ 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();
+ });
+ 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> = [];
+ 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> = [];
+ 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());
+ });
+});
diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts
index 28999ee6f..a6ea2e308 100644
--- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts
+++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts
@@ -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,
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index 4f108b265..ee78be3fb 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -104,6 +104,12 @@ interface PlayerState {
setMotionPathArmed: (armed: boolean) => void;
motionPathCreateAvailable: boolean;
setMotionPathCreateAvailable: (available: boolean) => void;
+ /** Global toggle for the "Add keyframe" diamond in the timeline toolbar (#1808).
+ * When false, a manual drag/resize/rotate edit on an element that already has
+ * a live tween shifts every keyframe by the edit's delta (preserving the
+ * animation's shape) instead of inserting/updating a keyframe at the playhead. */
+ autoKeyframeEnabled: boolean;
+ setAutoKeyframeEnabled: (enabled: boolean) => void;
/** Multi-select: additional selected elements beyond selectedElementId. */
selectedElementIds: Set;
@@ -238,6 +244,8 @@ export const usePlayerStore = create((set, get) => ({
setMotionPathArmed: (armed) => set({ motionPathArmed: armed }),
motionPathCreateAvailable: false,
setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
+ autoKeyframeEnabled: true,
+ setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
selectedElementIds: new Set(),
toggleSelectedElementId: (id: string) =>