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
@@ -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);
}
};