@hyperframes/studio 0.7.25 → 0.7.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-CF35g_E4.js → index-BPya4QRs.js} +1 -1
- package/dist/assets/{index-HBY-OdAh.js → index-BZqvc9Ns.js} +183 -183
- package/dist/assets/{index-Bmd8RnCq.js → index-D3eh3pRK.js} +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.html +1 -1
- package/dist/index.js +377 -210
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/TimelineToolbar.test.tsx +50 -0
- package/src/components/TimelineToolbar.tsx +70 -35
- package/src/components/editor/MotionPathOverlay.tsx +27 -4
- package/src/components/editor/SnapToolbar.test.tsx +124 -0
- package/src/components/editor/SnapToolbar.tsx +1 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +66 -0
- package/src/hooks/gsapRuntimeBridge.ts +41 -1
- package/src/hooks/gsapTweenSynth.test.ts +55 -0
- package/src/hooks/gsapTweenSynth.ts +12 -6
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +162 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +85 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +105 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +22 -0
- package/src/hooks/useAppHotkeys.ts +3 -2
- package/src/hooks/useGsapScriptCommits.test.tsx +8 -8
- package/src/hooks/useGsapScriptCommits.ts +6 -1
- package/src/hooks/useRenderClipContent.test.ts +54 -1
- package/src/hooks/useRenderClipContent.ts +7 -5
- package/src/player/components/Timeline.test.ts +51 -1
- package/src/player/components/Timeline.tsx +0 -2
- package/src/player/components/TimelineCanvas.tsx +1 -0
- package/src/player/components/TimelineClipDiamonds.test.tsx +215 -0
- package/src/player/components/TimelineClipDiamonds.tsx +46 -2
- package/src/player/store/playerStore.ts +8 -0
- package/src/utils/gsapSoftReload.test.ts +12 -0
- package/src/utils/gsapSoftReload.ts +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.26",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
"gsap": "^3.13.0",
|
|
47
47
|
"marked": "^14.1.4",
|
|
48
48
|
"mediabunny": "^1.45.3",
|
|
49
|
-
"@hyperframes/core": "0.7.
|
|
50
|
-
"@hyperframes/parsers": "0.7.
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/studio-server": "0.7.
|
|
49
|
+
"@hyperframes/core": "0.7.26",
|
|
50
|
+
"@hyperframes/parsers": "0.7.26",
|
|
51
|
+
"@hyperframes/sdk": "0.7.26",
|
|
52
|
+
"@hyperframes/player": "0.7.26",
|
|
53
|
+
"@hyperframes/studio-server": "0.7.26"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/react": "19",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"vite": "^6.4.2",
|
|
66
66
|
"vitest": "^3.2.4",
|
|
67
67
|
"zustand": "^5.0.0",
|
|
68
|
-
"@hyperframes/producer": "0.7.
|
|
68
|
+
"@hyperframes/producer": "0.7.26"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"react": "19",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
7
|
+
import { TimelineToolbar } from "./TimelineToolbar";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function renderToolbar() {
|
|
17
|
+
const host = document.createElement("div");
|
|
18
|
+
document.body.append(host);
|
|
19
|
+
const root = createRoot(host);
|
|
20
|
+
act(() => {
|
|
21
|
+
root.render(<TimelineToolbar toggleTimelineVisibility={vi.fn()} />);
|
|
22
|
+
});
|
|
23
|
+
return { host, root };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Regression (#1808): the auto-keyframe toggle is a GLOBAL setting (unlike the
|
|
27
|
+
// diamond "Add keyframe" button, which needs a selection to mean anything), so
|
|
28
|
+
// it must stay visible and usable with nothing selected — it must not be
|
|
29
|
+
// gated behind `domEditSession`/`onToggleKeyframe`.
|
|
30
|
+
describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
|
|
31
|
+
it("renders enabled (pressed) by default with no selection", () => {
|
|
32
|
+
const { host, root } = renderToolbar();
|
|
33
|
+
const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
|
|
34
|
+
expect(btn).not.toBeNull();
|
|
35
|
+
act(() => root.unmount());
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("flips autoKeyframeEnabled in the store when clicked", () => {
|
|
39
|
+
const { host, root } = renderToolbar();
|
|
40
|
+
const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')!;
|
|
41
|
+
|
|
42
|
+
act(() => {
|
|
43
|
+
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(usePlayerStore.getState().autoKeyframeEnabled).toBe(false);
|
|
47
|
+
expect(host.querySelector('button[aria-pressed="false"]')).not.toBeNull();
|
|
48
|
+
act(() => root.unmount());
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -79,6 +79,8 @@ export function TimelineToolbar({
|
|
|
79
79
|
}: TimelineToolbarProps) {
|
|
80
80
|
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
81
81
|
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
|
|
82
|
+
const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
|
|
83
|
+
const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
|
|
82
84
|
// Subscribe so the add-beat button reacts to playhead movement and analysis load.
|
|
83
85
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
84
86
|
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
|
|
@@ -137,44 +139,77 @@ export function TimelineToolbar({
|
|
|
137
139
|
</div>
|
|
138
140
|
)}
|
|
139
141
|
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
<Tooltip
|
|
143
|
+
label={
|
|
144
|
+
keyframeState === "active"
|
|
145
|
+
? "Remove keyframe at playhead (K)"
|
|
146
|
+
: keyframeState === "inactive"
|
|
147
|
+
? keyframeWillExtend
|
|
148
|
+
? "Add keyframe at playhead, extends animation (K)"
|
|
149
|
+
: "Add keyframe at playhead (K)"
|
|
150
|
+
: "Add keyframe (K)"
|
|
151
|
+
}
|
|
152
|
+
>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onClick={onToggleKeyframe}
|
|
156
|
+
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
|
143
157
|
keyframeState === "active"
|
|
144
|
-
? "
|
|
158
|
+
? "text-studio-accent"
|
|
145
159
|
: keyframeState === "inactive"
|
|
146
|
-
?
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
: "Add keyframe (K)"
|
|
150
|
-
}
|
|
160
|
+
? "text-neutral-400 hover:text-studio-accent"
|
|
161
|
+
: "text-neutral-600 hover:text-neutral-400"
|
|
162
|
+
}`}
|
|
151
163
|
>
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
164
|
+
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
|
|
165
|
+
{keyframeState === "active" ? (
|
|
166
|
+
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
|
167
|
+
) : (
|
|
168
|
+
<path
|
|
169
|
+
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
|
|
170
|
+
fill="none"
|
|
171
|
+
stroke="currentColor"
|
|
172
|
+
strokeWidth="1.2"
|
|
173
|
+
/>
|
|
174
|
+
)}
|
|
175
|
+
</svg>
|
|
176
|
+
</button>
|
|
177
|
+
</Tooltip>
|
|
178
|
+
)}
|
|
179
|
+
{STUDIO_KEYFRAMES_ENABLED && (
|
|
180
|
+
<Tooltip
|
|
181
|
+
label={
|
|
182
|
+
autoKeyframeEnabled
|
|
183
|
+
? "Auto-record manual edits as keyframes (click to turn off)"
|
|
184
|
+
: "Manual edits will not be recorded as keyframes (click to turn on)"
|
|
185
|
+
}
|
|
186
|
+
>
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
|
|
190
|
+
aria-pressed={autoKeyframeEnabled}
|
|
191
|
+
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
|
192
|
+
autoKeyframeEnabled
|
|
193
|
+
? "text-red-400 hover:text-red-300"
|
|
194
|
+
: "text-neutral-600 hover:text-neutral-400"
|
|
195
|
+
}`}
|
|
196
|
+
>
|
|
197
|
+
<svg width="18" height="18" viewBox="0 0 10 10" fill="none">
|
|
198
|
+
{/* Same diamond outline as the Add-keyframe icon, with a
|
|
199
|
+
record-style dot inside: filled = auto-recording,
|
|
200
|
+
hollow = manual edits won't be keyframed. */}
|
|
201
|
+
<path d="M5 0.7L9.3 5L5 9.3L0.7 5Z" stroke="currentColor" strokeWidth="1" />
|
|
202
|
+
<circle
|
|
203
|
+
cx="5"
|
|
204
|
+
cy="5"
|
|
205
|
+
r="1.8"
|
|
206
|
+
fill={autoKeyframeEnabled ? "currentColor" : "none"}
|
|
207
|
+
stroke="currentColor"
|
|
208
|
+
strokeWidth="1"
|
|
209
|
+
/>
|
|
210
|
+
</svg>
|
|
211
|
+
</button>
|
|
212
|
+
</Tooltip>
|
|
178
213
|
)}
|
|
179
214
|
{onSplitElement &&
|
|
180
215
|
(() => {
|
|
@@ -3,6 +3,7 @@ import type { DomEditSelection } from "./domEditing";
|
|
|
3
3
|
import { useDomEditContext } from "../../contexts/DomEditContext";
|
|
4
4
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
5
5
|
import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
|
|
6
|
+
import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
|
|
6
7
|
import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
|
|
7
8
|
import { editableAnimationId, selectorFor } from "./motionPathSelection";
|
|
8
9
|
import { ACCENT, MotionPathNode } from "./MotionPathNode";
|
|
@@ -334,13 +335,35 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
334
335
|
// high zoom) would commit an identical value — a no-op undo entry. Skip the
|
|
335
336
|
// commit, but don't treat it as a click either (the user did drag).
|
|
336
337
|
if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
|
|
337
|
-
|
|
338
|
+
// With auto-keyframe off (#1808), dragging a keyframe's node on the motion
|
|
339
|
+
// path (the common way to nudge a KEYFRAMED element's position on canvas,
|
|
340
|
+
// since the element renders exactly at its current keyframe) shifts the
|
|
341
|
+
// whole path instead of moving just that one keyframe.
|
|
342
|
+
const anim =
|
|
343
|
+
d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
|
|
344
|
+
if (
|
|
345
|
+
d.ref.type === "keyframe" &&
|
|
346
|
+
anim &&
|
|
347
|
+
selection &&
|
|
348
|
+
!usePlayerStore.getState().autoKeyframeEnabled
|
|
349
|
+
) {
|
|
350
|
+
void commitWholePropertyOffset(
|
|
351
|
+
selection,
|
|
352
|
+
anim,
|
|
353
|
+
{ x, y },
|
|
354
|
+
d.ref.pct,
|
|
355
|
+
iframeRef.current,
|
|
356
|
+
{ commitMutation: (_sel, mutation, options) => commitMutation(mutation, options) },
|
|
357
|
+
"Move animation path",
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
void commitNode(d.ref, x, y, animId, commitMutation);
|
|
361
|
+
}
|
|
338
362
|
// Park the playhead on the edited keyframe's time so the element previews AT
|
|
339
363
|
// that keyframe. Without it, a playhead sitting before the tween renders the
|
|
340
364
|
// element's base pose — the edit (correct on the path) looks like it vanished.
|
|
341
|
-
if (d.ref.type === "keyframe") {
|
|
342
|
-
|
|
343
|
-
if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct);
|
|
365
|
+
if (d.ref.type === "keyframe" && anim) {
|
|
366
|
+
parkPlayheadOnKeyframe(anim, d.ref.pct);
|
|
344
367
|
}
|
|
345
368
|
};
|
|
346
369
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, useRef } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useAppHotkeys } from "../../hooks/useAppHotkeys";
|
|
7
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
|
+
import type { LeftSidebarHandle } from "../sidebar/LeftSidebar";
|
|
9
|
+
import type { DomEditSelection } from "./domEditing";
|
|
10
|
+
import { SnapToolbar } from "./SnapToolbar";
|
|
11
|
+
|
|
12
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
document.body.innerHTML = "";
|
|
16
|
+
window.localStorage.clear();
|
|
17
|
+
usePlayerStore.getState().reset();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function renderToolbar(onSnapChange = vi.fn()) {
|
|
21
|
+
const host = document.createElement("div");
|
|
22
|
+
document.body.append(host);
|
|
23
|
+
const root = createRoot(host);
|
|
24
|
+
act(() => {
|
|
25
|
+
root.render(<SnapToolbar onSnapChange={onSnapChange} />);
|
|
26
|
+
});
|
|
27
|
+
return { root, onSnapChange };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function AppHotkeyHarness() {
|
|
31
|
+
const domEditSelectionRef = useRef<DomEditSelection | null>(null);
|
|
32
|
+
const clearDomSelectionRef = useRef<() => void>(() => undefined);
|
|
33
|
+
const domEditSaveTimestampRef = useRef(0);
|
|
34
|
+
const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
|
|
35
|
+
|
|
36
|
+
useAppHotkeys({
|
|
37
|
+
toggleTimelineVisibility: vi.fn(),
|
|
38
|
+
handleTimelineElementDelete: vi.fn(),
|
|
39
|
+
handleTimelineElementSplit: vi.fn(),
|
|
40
|
+
handleDomEditElementDelete: vi.fn(),
|
|
41
|
+
domEditSelectionRef,
|
|
42
|
+
clearDomSelectionRef,
|
|
43
|
+
editHistory: {
|
|
44
|
+
undo: vi.fn(async () => ({ ok: false })),
|
|
45
|
+
redo: vi.fn(async () => ({ ok: false })),
|
|
46
|
+
state: { undo: [], redo: [] },
|
|
47
|
+
},
|
|
48
|
+
readOptionalProjectFile: vi.fn(async () => ""),
|
|
49
|
+
readProjectFile: vi.fn(async () => ""),
|
|
50
|
+
writeProjectFile: vi.fn(async () => undefined),
|
|
51
|
+
domEditSaveTimestampRef,
|
|
52
|
+
showToast: vi.fn(),
|
|
53
|
+
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
54
|
+
waitForPendingDomEditSaves: vi.fn(async () => undefined),
|
|
55
|
+
leftSidebarRef,
|
|
56
|
+
handleCopy: vi.fn(() => false),
|
|
57
|
+
handlePaste: vi.fn(async () => undefined),
|
|
58
|
+
handleCut: vi.fn(async () => false),
|
|
59
|
+
onResetKeyframes: vi.fn(() => false),
|
|
60
|
+
onDeleteSelectedKeyframes: vi.fn(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderToolbarWithAppHotkeys(onSnapChange = vi.fn()) {
|
|
67
|
+
const host = document.createElement("div");
|
|
68
|
+
document.body.append(host);
|
|
69
|
+
const root = createRoot(host);
|
|
70
|
+
act(() => {
|
|
71
|
+
root.render(
|
|
72
|
+
<>
|
|
73
|
+
<AppHotkeyHarness />
|
|
74
|
+
<SnapToolbar onSnapChange={onSnapChange} />
|
|
75
|
+
</>,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
return { root, onSnapChange };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("SnapToolbar keyboard shortcuts", () => {
|
|
82
|
+
it("toggles snap on an unclaimed S keypress", () => {
|
|
83
|
+
const { root, onSnapChange } = renderToolbar();
|
|
84
|
+
|
|
85
|
+
act(() => {
|
|
86
|
+
document.dispatchEvent(
|
|
87
|
+
new KeyboardEvent("keydown", { key: "s", bubbles: true, cancelable: true }),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(onSnapChange).toHaveBeenCalledWith(expect.objectContaining({ snapEnabled: false }));
|
|
92
|
+
act(() => root.unmount());
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("does not toggle snap when another handler already prevented S", () => {
|
|
96
|
+
const { root, onSnapChange } = renderToolbar();
|
|
97
|
+
const event = new KeyboardEvent("keydown", {
|
|
98
|
+
key: "s",
|
|
99
|
+
bubbles: true,
|
|
100
|
+
cancelable: true,
|
|
101
|
+
});
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
|
|
104
|
+
act(() => {
|
|
105
|
+
document.dispatchEvent(event);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(onSnapChange).not.toHaveBeenCalled();
|
|
109
|
+
act(() => root.unmount());
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("does not toggle snap when the app split shortcut claims S without a selected clip", () => {
|
|
113
|
+
const { root, onSnapChange } = renderToolbarWithAppHotkeys();
|
|
114
|
+
|
|
115
|
+
act(() => {
|
|
116
|
+
document.dispatchEvent(
|
|
117
|
+
new KeyboardEvent("keydown", { key: "s", bubbles: true, cancelable: true }),
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(onSnapChange).not.toHaveBeenCalled();
|
|
122
|
+
act(() => root.unmount());
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -65,6 +65,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|
|
65
65
|
useEffect(() => {
|
|
66
66
|
// fallow-ignore-next-line complexity
|
|
67
67
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
68
|
+
if (e.defaultPrevented) return;
|
|
68
69
|
const t = e.target;
|
|
69
70
|
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return;
|
|
70
71
|
if (t instanceof HTMLElement && t.isContentEditable) return;
|
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
|
|
5
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Regression: `selectedGsapAnimations` (and the fetch fallback) is an async
|
|
@@ -178,3 +179,68 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
178
179
|
expect(staleLogged).toBe(false);
|
|
179
180
|
});
|
|
180
181
|
});
|
|
182
|
+
|
|
183
|
+
// Regression (#1808): with the global auto-keyframe toggle off, dragging an
|
|
184
|
+
// element that already has a keyframed position tween must shift the whole
|
|
185
|
+
// tween (a "replace-with-keyframes" carrying every original percentage) —
|
|
186
|
+
// the same path Alt-drag already takes — instead of inserting a keyframe at
|
|
187
|
+
// the playhead.
|
|
188
|
+
describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const keyframedPositionAnim = {
|
|
194
|
+
id: "#puck-b-to-position",
|
|
195
|
+
targetSelector: "#puck-b",
|
|
196
|
+
propertyGroup: "position",
|
|
197
|
+
method: "to",
|
|
198
|
+
properties: {},
|
|
199
|
+
position: 0,
|
|
200
|
+
resolvedStart: 0,
|
|
201
|
+
duration: 2,
|
|
202
|
+
keyframes: {
|
|
203
|
+
keyframes: [
|
|
204
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
205
|
+
{ percentage: 100, properties: { x: 100, y: 0 } },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
} as unknown as GsapAnimation;
|
|
209
|
+
|
|
210
|
+
it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
|
|
211
|
+
usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 2 }); // playhead at 100%
|
|
212
|
+
const commitMutation = vi.fn();
|
|
213
|
+
const iframe = fakeIframe("puck-b", []);
|
|
214
|
+
|
|
215
|
+
const handled = await tryGsapDragIntercept(
|
|
216
|
+
selection,
|
|
217
|
+
{ x: -50, y: 0 },
|
|
218
|
+
[keyframedPositionAnim],
|
|
219
|
+
iframe,
|
|
220
|
+
commitMutation,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(handled).toBe(true);
|
|
224
|
+
const types = commitMutation.mock.calls.map(([, m]) => m.type);
|
|
225
|
+
expect(types).toContain("replace-with-keyframes");
|
|
226
|
+
expect(types).not.toContain("add-keyframe");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
|
|
230
|
+
usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 2 });
|
|
231
|
+
const commitMutation = vi.fn();
|
|
232
|
+
const iframe = fakeIframe("puck-b", []);
|
|
233
|
+
|
|
234
|
+
const handled = await tryGsapDragIntercept(
|
|
235
|
+
selection,
|
|
236
|
+
{ x: -50, y: 0 },
|
|
237
|
+
[keyframedPositionAnim],
|
|
238
|
+
iframe,
|
|
239
|
+
commitMutation,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(handled).toBe(true);
|
|
243
|
+
const types = commitMutation.mock.calls.map(([, m]) => m.type);
|
|
244
|
+
expect(types).not.toContain("replace-with-keyframes");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
findSizeSetAnimation,
|
|
27
27
|
materializeIfDynamic,
|
|
28
28
|
} from "./gsapDragCommit";
|
|
29
|
+
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
29
30
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
30
31
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
31
32
|
import { selectorFromSelection } from "./gsapShared";
|
|
@@ -225,7 +226,12 @@ export async function tryGsapDragIntercept(
|
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
|
|
228
|
-
|
|
229
|
+
// Alt-drag already means "shift the whole path" — the global auto-keyframe
|
|
230
|
+
// toggle (#1808) just makes that the default while it's off, so a manual
|
|
231
|
+
// edit on an already-animated element nudges the animation instead of
|
|
232
|
+
// inserting/updating a keyframe at the playhead.
|
|
233
|
+
const autoKeyframeEnabled = usePlayerStore.getState().autoKeyframeEnabled;
|
|
234
|
+
if (options?.altKey || !autoKeyframeEnabled) {
|
|
229
235
|
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
|
230
236
|
} else {
|
|
231
237
|
await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
|
@@ -332,6 +338,24 @@ export async function tryGsapResizeIntercept(
|
|
|
332
338
|
height: Math.round(size.height),
|
|
333
339
|
};
|
|
334
340
|
}
|
|
341
|
+
|
|
342
|
+
// With auto-keyframe off (#1808), `anim` is already a real (non-"set")
|
|
343
|
+
// tween for this resize group, so nudge it as a whole rather than adding a
|
|
344
|
+
// keyframe at the playhead.
|
|
345
|
+
if (!usePlayerStore.getState().autoKeyframeEnabled) {
|
|
346
|
+
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
|
347
|
+
await commitWholePropertyOffset(
|
|
348
|
+
selection,
|
|
349
|
+
anim,
|
|
350
|
+
resizeProps,
|
|
351
|
+
pct,
|
|
352
|
+
iframe,
|
|
353
|
+
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
354
|
+
"Resize animation",
|
|
355
|
+
);
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
|
|
335
359
|
const ct = usePlayerStore.getState().currentTime;
|
|
336
360
|
const ts = resolveTweenStart(anim);
|
|
337
361
|
const td = resolveTweenDuration(anim);
|
|
@@ -490,6 +514,22 @@ export async function tryGsapRotationIntercept(
|
|
|
490
514
|
|
|
491
515
|
const pct = computeCurrentPercentage(selection, anim);
|
|
492
516
|
|
|
517
|
+
// With auto-keyframe off (#1808), a rotation tween already exists for this
|
|
518
|
+
// element (checked above) so nudge it as a whole rather than adding a
|
|
519
|
+
// keyframe at the playhead.
|
|
520
|
+
if (!usePlayerStore.getState().autoKeyframeEnabled) {
|
|
521
|
+
await commitWholePropertyOffset(
|
|
522
|
+
selection,
|
|
523
|
+
anim,
|
|
524
|
+
{ rotation: newRotation },
|
|
525
|
+
pct,
|
|
526
|
+
iframe,
|
|
527
|
+
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
528
|
+
"Rotate animation",
|
|
529
|
+
);
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
|
|
493
533
|
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
494
534
|
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
495
535
|
if (newId) anim = { ...anim, id: newId };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
|
4
|
+
|
|
5
|
+
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
|
|
6
|
+
return {
|
|
7
|
+
id: "a1",
|
|
8
|
+
targetSelector: "#title",
|
|
9
|
+
method: "to",
|
|
10
|
+
position: 0,
|
|
11
|
+
properties: {},
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("synthesizeFlatTweenKeyframes", () => {
|
|
17
|
+
it("returns null for a set() static hold", () => {
|
|
18
|
+
expect(synthesizeFlatTweenKeyframes(anim({ method: "set", properties: { x: 5 } }))).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Regression: removeAllKeyframesFromScript collapses a keyframed tween to
|
|
22
|
+
// `tl.to(..., { duration: 0, immediateRender: true })` — a static hold with
|
|
23
|
+
// the same "not an animation" semantics as set(), but a different method
|
|
24
|
+
// string. Before this fix, only position-only (x/y) collapses were treated
|
|
25
|
+
// as holds elsewhere; a collapse to scale/opacity (or anything else) still
|
|
26
|
+
// synthesized a phantom keyframe diamond after "Delete All Keyframes".
|
|
27
|
+
it("returns null for a to() collapsed to a zero-duration immediateRender hold", () => {
|
|
28
|
+
const collapsed = anim({
|
|
29
|
+
method: "to",
|
|
30
|
+
duration: 0,
|
|
31
|
+
// Both parsers encode a literal `immediateRender: true` as this raw
|
|
32
|
+
// source string, not a boolean — see gsapParser.ts/gsapParserAcorn.ts.
|
|
33
|
+
extras: { immediateRender: "__raw:true" },
|
|
34
|
+
properties: { scale: 1, opacity: 1 },
|
|
35
|
+
});
|
|
36
|
+
expect(synthesizeFlatTweenKeyframes(collapsed)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("still synthesizes keyframes for a genuine animated to() tween", () => {
|
|
40
|
+
const out = synthesizeFlatTweenKeyframes(
|
|
41
|
+
anim({ method: "to", duration: 1, properties: { opacity: 1 } }),
|
|
42
|
+
);
|
|
43
|
+
expect(out).not.toBeNull();
|
|
44
|
+
expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 100]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("still synthesizes keyframes for a duration:0 tween that isn't an immediateRender hold", () => {
|
|
48
|
+
// duration:0 alone isn't enough — only paired with immediateRender does it
|
|
49
|
+
// mean "this is a static hold, not an animation".
|
|
50
|
+
const out = synthesizeFlatTweenKeyframes(
|
|
51
|
+
anim({ method: "to", duration: 0, properties: { opacity: 1 } }),
|
|
52
|
+
);
|
|
53
|
+
expect(out).not.toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -23,12 +23,18 @@ export function deduplicateKeyframes(
|
|
|
23
23
|
|
|
24
24
|
// fallow-ignore-next-line complexity
|
|
25
25
|
export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
//
|
|
26
|
+
// Both parsers store extras as raw source text (`__raw:${code}`) so
|
|
27
|
+
// non-editable config like `stagger: {...}` survives verbatim — a literal
|
|
28
|
+
// `immediateRender: true` prints as exactly this string, not a boolean.
|
|
29
|
+
const hasImmediateRenderHold = anim.extras?.immediateRender === "__raw:true";
|
|
30
|
+
if (anim.method === "set" || (anim.duration === 0 && hasImmediateRenderHold)) {
|
|
31
|
+
// A `set` — or a `to()`/`from()` collapsed to a zero-duration
|
|
32
|
+
// immediateRender hold (what removeAllKeyframesFromScript collapses a
|
|
33
|
+
// keyframed tween to) — is a STATIC HOLD: a value applied at one point,
|
|
34
|
+
// not an animated keyframe. It must NOT synthesize a keyframe, or the
|
|
35
|
+
// timeline + panel show a phantom diamond for a value that doesn't
|
|
36
|
+
// animate. This aligns the AST path with the runtime scan, which already
|
|
37
|
+
// skips every zero-duration set.
|
|
32
38
|
return null;
|
|
33
39
|
}
|
|
34
40
|
const toProps = anim.properties;
|