mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +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,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(<TimelineToolbar toggleTimelineVisibility={vi.fn()} />);
|
||||
});
|
||||
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<HTMLButtonElement>('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<HTMLButtonElement>('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());
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
|
||||
<>
|
||||
<Tooltip
|
||||
label={
|
||||
<Tooltip
|
||||
label={
|
||||
keyframeState === "active"
|
||||
? "Remove keyframe at playhead (K)"
|
||||
: keyframeState === "inactive"
|
||||
? keyframeWillExtend
|
||||
? "Add keyframe at playhead, extends animation (K)"
|
||||
: "Add keyframe at playhead (K)"
|
||||
: "Add keyframe (K)"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleKeyframe}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
keyframeState === "active"
|
||||
? "Remove keyframe at playhead (K)"
|
||||
? "text-studio-accent"
|
||||
: keyframeState === "inactive"
|
||||
? keyframeWillExtend
|
||||
? "Add keyframe at playhead — extends animation (K)"
|
||||
: "Add keyframe at playhead (K)"
|
||||
: "Add keyframe (K)"
|
||||
}
|
||||
? "text-neutral-400 hover:text-studio-accent"
|
||||
: "text-neutral-600 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleKeyframe}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
keyframeState === "active"
|
||||
? "text-studio-accent"
|
||||
: keyframeState === "inactive"
|
||||
? "text-neutral-400 hover:text-studio-accent"
|
||||
: "text-neutral-600 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
|
||||
{keyframeState === "active" ? (
|
||||
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
||||
) : (
|
||||
<path
|
||||
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
|
||||
{keyframeState === "active" ? (
|
||||
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
||||
) : (
|
||||
<path
|
||||
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{STUDIO_KEYFRAMES_ENABLED && (
|
||||
<Tooltip
|
||||
label={
|
||||
autoKeyframeEnabled
|
||||
? "Auto-record manual edits as keyframes (click to turn off)"
|
||||
: "Manual edits will not be recorded as keyframes (click to turn on)"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<circle
|
||||
cx="7"
|
||||
cy="7"
|
||||
r="5"
|
||||
fill={autoKeyframeEnabled ? "currentColor" : "none"}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
{!autoKeyframeEnabled && (
|
||||
<line
|
||||
x1="2.2"
|
||||
y1="11.8"
|
||||
x2="11.8"
|
||||
y2="2.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onSplitElement &&
|
||||
(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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<string, number | string>,
|
||||
) => Promise<void>;
|
||||
|
||||
/** Renders the hook and hands its commit function to the caller via a ref callback. */
|
||||
function renderCommitHook(
|
||||
mutations: Array<Record<string, unknown>>,
|
||||
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(<Harness />);
|
||||
});
|
||||
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<Record<string, unknown>> = [];
|
||||
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<Record<string, unknown>> = [];
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>;
|
||||
@@ -238,6 +244,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setMotionPathArmed: (armed) => set({ motionPathArmed: armed }),
|
||||
motionPathCreateAvailable: false,
|
||||
setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }),
|
||||
autoKeyframeEnabled: true,
|
||||
setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
|
||||
|
||||
selectedElementIds: new Set<string>(),
|
||||
toggleSelectedElementId: (id: string) =>
|
||||
|
||||
Reference in New Issue
Block a user