mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
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:
@@ -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 } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user