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