From 636d5dd6c52083a6c10a25eb00580c207b164534 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 1 Jul 2026 19:26:03 -0700 Subject: [PATCH] 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 --- .../src/components/TimelineToolbar.test.tsx | 50 +++++++ .../studio/src/components/TimelineToolbar.tsx | 112 ++++++++++----- .../components/editor/MotionPathOverlay.tsx | 31 +++- .../src/hooks/gsapRuntimeBridge.test.ts | 66 +++++++++ .../studio/src/hooks/gsapRuntimeBridge.ts | 42 +++++- .../gsapWholePropertyOffsetCommit.test.ts | 136 ++++++++++++++++++ .../hooks/gsapWholePropertyOffsetCommit.ts | 76 ++++++++++ .../hooks/useAnimatedPropertyCommit.test.tsx | 105 ++++++++++++++ .../src/hooks/useAnimatedPropertyCommit.ts | 22 +++ .../studio/src/player/store/playerStore.ts | 8 ++ 10 files changed, 608 insertions(+), 40 deletions(-) create mode 100644 packages/studio/src/components/TimelineToolbar.test.tsx create mode 100644 packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts create mode 100644 packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts create mode 100644 packages/studio/src/hooks/useAnimatedPropertyCommit.test.tsx 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 && ( - <> - + - - + + {keyframeState === "active" ? ( + + ) : ( + + )} + + + + )} + {STUDIO_KEYFRAMES_ENABLED && ( + + + )} {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) =>