@hyperframes/studio 0.7.79 → 0.7.80
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/{hyperframes-player-mFah2TZE.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-Bqj3h_1a.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-DlZMDyYs.js → index-BhdJN49y.js} +200 -200
- package/dist/assets/{index-hmnoiSEV.js → index-By-YiRxq.js} +1 -1
- package/dist/assets/index-D379YOKT.css +1 -0
- package/dist/index.d.ts +45 -3
- package/dist/index.html +2 -2
- package/dist/index.js +652 -465
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +2 -1
- package/src/components/editor/AnimationCard.test.tsx +225 -18
- package/src/components/editor/AnimationCard.tsx +24 -3
- package/src/components/editor/EaseCurveSection.test.tsx +147 -2
- package/src/components/editor/EaseCurveSection.tsx +77 -15
- package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
- package/src/components/editor/GsapAnimationSection.tsx +6 -1
- package/src/components/editor/KeyframeEaseList.tsx +4 -0
- package/src/components/editor/MotionPathOverlay.tsx +54 -19
- package/src/components/editor/PropertyPanel.tsx +4 -0
- package/src/components/editor/PropertyPanelFlat.tsx +6 -91
- package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
- package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
- package/src/components/editor/holdEaseSeek.test.ts +37 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
- package/src/components/editor/propertyPanelFlatProps.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +132 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +150 -50
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +4 -12
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useGsapTweenCache.ts +40 -34
- package/src/hooks/useKeyframeEaseCommits.ts +78 -0
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
- package/src/player/components/TimelineClipDiamonds.test.tsx +18 -6
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -85
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +29 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +24 -0
- package/src/player/store/keyframeSlice.ts +15 -5
- package/dist/assets/index-gGVKuFg5.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.80",
|
|
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/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
49
|
+
"@hyperframes/core": "0.7.80",
|
|
50
|
+
"@hyperframes/parsers": "0.7.80",
|
|
51
|
+
"@hyperframes/player": "0.7.80",
|
|
52
|
+
"@hyperframes/sdk": "0.7.80",
|
|
53
|
+
"@hyperframes/studio-server": "0.7.80"
|
|
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.80"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"react": "19",
|
|
@@ -19,7 +19,6 @@ import type { EditHistoryKind } from "../utils/editHistory";
|
|
|
19
19
|
import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
|
|
20
20
|
import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
|
|
21
21
|
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
|
|
22
|
-
|
|
23
22
|
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
|
|
24
23
|
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
|
25
24
|
import { useFileManagerContext } from "../contexts/FileManagerContext";
|
|
@@ -156,6 +155,7 @@ export function StudioRightPanel({
|
|
|
156
155
|
handleUpdateArcSegment,
|
|
157
156
|
handleUnroll,
|
|
158
157
|
handleUpdateKeyframeEase,
|
|
158
|
+
handleUpdateSegmentEase,
|
|
159
159
|
handleSetAllKeyframeEases,
|
|
160
160
|
handleGsapAddKeyframe,
|
|
161
161
|
handleGsapRemoveKeyframe,
|
|
@@ -406,6 +406,7 @@ export function StudioRightPanel({
|
|
|
406
406
|
onUnroll={handleUnroll}
|
|
407
407
|
onUpdateKeyframeEase={handleUpdateKeyframeEase}
|
|
408
408
|
onSetAllKeyframeEases={handleSetAllKeyframeEases}
|
|
409
|
+
onUpdateSegmentEase={handleUpdateSegmentEase}
|
|
409
410
|
recordingState={recordingState}
|
|
410
411
|
recordingDuration={recordingDuration}
|
|
411
412
|
onToggleRecording={onToggleRecording}
|
|
@@ -2,39 +2,98 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
5
6
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
7
|
import { AnimationCard } from "./AnimationCard";
|
|
7
|
-
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
8
8
|
import { EASE_PRESETS } from "./easePresetLibrary";
|
|
9
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
9
10
|
|
|
10
11
|
const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
|
|
11
12
|
vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
|
|
12
13
|
|
|
13
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
15
|
|
|
16
|
+
const ANIMATION: GsapAnimation = {
|
|
17
|
+
id: "position-tween",
|
|
18
|
+
targetSelector: "#clip-1",
|
|
19
|
+
method: "to",
|
|
20
|
+
position: 0,
|
|
21
|
+
duration: 2,
|
|
22
|
+
ease: "power1.out",
|
|
23
|
+
properties: { x: 200 },
|
|
24
|
+
keyframes: {
|
|
25
|
+
format: "percentage",
|
|
26
|
+
keyframes: [
|
|
27
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
28
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
29
|
+
{ percentage: 100, properties: { x: 200 } },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const FLAT_ANIMATION: GsapAnimation = {
|
|
35
|
+
...ANIMATION,
|
|
36
|
+
id: "flat-position-tween",
|
|
37
|
+
keyframes: undefined,
|
|
38
|
+
};
|
|
39
|
+
|
|
15
40
|
afterEach(() => {
|
|
16
41
|
document.body.innerHTML = "";
|
|
17
42
|
trackStudioSegmentEaseEdit.mockClear();
|
|
18
43
|
});
|
|
19
44
|
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
45
|
+
function renderFocusCard(
|
|
46
|
+
focusedSegment: {
|
|
47
|
+
tweenPercentage: number;
|
|
48
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
49
|
+
} | null,
|
|
50
|
+
onEaseCommit = vi.fn(),
|
|
51
|
+
defaultExpanded = false,
|
|
52
|
+
animation = ANIMATION,
|
|
53
|
+
onUpdateMeta = vi.fn(),
|
|
54
|
+
onUpdateSegmentEase = vi.fn(),
|
|
55
|
+
) {
|
|
56
|
+
const host = document.createElement("div");
|
|
57
|
+
document.body.append(host);
|
|
58
|
+
const root = createRoot(host);
|
|
59
|
+
const render = (nextFocusedSegment: { tweenPercentage: number } | null) => {
|
|
60
|
+
act(() => {
|
|
61
|
+
root.render(
|
|
62
|
+
<AnimationCard
|
|
63
|
+
animation={animation}
|
|
64
|
+
defaultExpanded={defaultExpanded}
|
|
65
|
+
focusedSegment={nextFocusedSegment}
|
|
66
|
+
onFocusSegmentConsumed={vi.fn()}
|
|
67
|
+
onUpdateProperty={vi.fn()}
|
|
68
|
+
onUpdateMeta={onUpdateMeta}
|
|
69
|
+
onDeleteAnimation={vi.fn()}
|
|
70
|
+
onAddProperty={vi.fn()}
|
|
71
|
+
onRemoveProperty={vi.fn()}
|
|
72
|
+
onUpdateKeyframeEase={onEaseCommit}
|
|
73
|
+
onUpdateSegmentEase={onUpdateSegmentEase}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
render(focusedSegment);
|
|
79
|
+
return { host, root, render };
|
|
30
80
|
}
|
|
31
81
|
|
|
32
|
-
|
|
82
|
+
function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
|
|
83
|
+
return Array.from(host.querySelectorAll("button")).find((button) =>
|
|
84
|
+
button.textContent?.includes(text),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function openSegment(host: HTMLElement, label: string): void {
|
|
89
|
+
const segment = findButton(host, label);
|
|
90
|
+
expect(segment).toBeDefined();
|
|
91
|
+
act(() => segment?.click());
|
|
92
|
+
}
|
|
33
93
|
|
|
34
94
|
function selectPreset(host: HTMLElement, presetId: string): string {
|
|
35
95
|
const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
|
|
36
96
|
if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);
|
|
37
|
-
|
|
38
97
|
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
|
|
39
98
|
expect(dropdown).not.toBeNull();
|
|
40
99
|
act(() => dropdown?.click());
|
|
@@ -45,6 +104,8 @@ function selectPreset(host: HTMLElement, presetId: string): string {
|
|
|
45
104
|
return presetConfig.ease;
|
|
46
105
|
}
|
|
47
106
|
|
|
107
|
+
const noop = () => {};
|
|
108
|
+
|
|
48
109
|
/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
|
|
49
110
|
function renderCard({
|
|
50
111
|
animation = baseAnimation(),
|
|
@@ -82,7 +143,157 @@ function renderCard({
|
|
|
82
143
|
return { host, root };
|
|
83
144
|
}
|
|
84
145
|
|
|
146
|
+
function restoreScrollIntoView(descriptor: PropertyDescriptor | undefined): void {
|
|
147
|
+
if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor);
|
|
148
|
+
else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("AnimationCard", () => {
|
|
152
|
+
it("scrolls a focused segment into view but not a manually toggled segment", () => {
|
|
153
|
+
const originalScrollIntoView = Object.getOwnPropertyDescriptor(
|
|
154
|
+
HTMLElement.prototype,
|
|
155
|
+
"scrollIntoView",
|
|
156
|
+
);
|
|
157
|
+
const scrollIntoView = vi.fn();
|
|
158
|
+
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
|
159
|
+
configurable: true,
|
|
160
|
+
value: scrollIntoView,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const view = renderFocusCard({ tweenPercentage: 50 });
|
|
164
|
+
try {
|
|
165
|
+
expect(scrollIntoView).toHaveBeenCalledOnce();
|
|
166
|
+
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });
|
|
167
|
+
|
|
168
|
+
view.render(null);
|
|
169
|
+
const manualToggle = findButton(view.host, "50% → 100%");
|
|
170
|
+
expect(manualToggle).toBeDefined();
|
|
171
|
+
act(() => manualToggle?.click());
|
|
172
|
+
expect(scrollIntoView).toHaveBeenCalledOnce();
|
|
173
|
+
} finally {
|
|
174
|
+
act(() => view.root.unmount());
|
|
175
|
+
restoreScrollIntoView(originalScrollIntoView);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("tracks a committed segment ease alongside the existing update", () => {
|
|
180
|
+
const onEaseCommit = vi.fn();
|
|
181
|
+
const view = renderFocusCard(null, onEaseCommit, true);
|
|
182
|
+
openSegment(view.host, "0% → 50%");
|
|
183
|
+
const ease = selectPreset(view.host, "quad-out");
|
|
184
|
+
|
|
185
|
+
expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease);
|
|
186
|
+
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "commit", ease });
|
|
187
|
+
act(() => view.root.unmount());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("commits a focused multi-id segment ease through the bulk callback", () => {
|
|
191
|
+
const onUpdateKeyframeEase = vi.fn();
|
|
192
|
+
const onUpdateSegmentEase = vi.fn();
|
|
193
|
+
const collidingAnimationTargets = [
|
|
194
|
+
{ animationId: ANIMATION.id, tweenPercentage: 50 },
|
|
195
|
+
{ animationId: "scale-tween", tweenPercentage: 75 },
|
|
196
|
+
{ animationId: "opacity-tween", tweenPercentage: 25 },
|
|
197
|
+
];
|
|
198
|
+
const view = renderFocusCard(
|
|
199
|
+
{ tweenPercentage: 50, collidingAnimationTargets },
|
|
200
|
+
onUpdateKeyframeEase,
|
|
201
|
+
false,
|
|
202
|
+
ANIMATION,
|
|
203
|
+
vi.fn(),
|
|
204
|
+
onUpdateSegmentEase,
|
|
205
|
+
);
|
|
206
|
+
const ease = selectPreset(view.host, "quad-out");
|
|
207
|
+
|
|
208
|
+
expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(collidingAnimationTargets, ease);
|
|
209
|
+
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
|
|
210
|
+
act(() => view.root.unmount());
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("keeps a focused single-id segment ease on the single callback", () => {
|
|
214
|
+
const onUpdateKeyframeEase = vi.fn();
|
|
215
|
+
const onUpdateSegmentEase = vi.fn();
|
|
216
|
+
const view = renderFocusCard(
|
|
217
|
+
{
|
|
218
|
+
tweenPercentage: 50,
|
|
219
|
+
collidingAnimationTargets: [{ animationId: ANIMATION.id, tweenPercentage: 50 }],
|
|
220
|
+
},
|
|
221
|
+
onUpdateKeyframeEase,
|
|
222
|
+
false,
|
|
223
|
+
ANIMATION,
|
|
224
|
+
vi.fn(),
|
|
225
|
+
onUpdateSegmentEase,
|
|
226
|
+
);
|
|
227
|
+
const ease = selectPreset(view.host, "quad-out");
|
|
228
|
+
|
|
229
|
+
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease);
|
|
230
|
+
expect(onUpdateSegmentEase).not.toHaveBeenCalled();
|
|
231
|
+
act(() => view.root.unmount());
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("commits a focused flat tween segment ease through tween metadata", () => {
|
|
235
|
+
const onUpdateMeta = vi.fn();
|
|
236
|
+
const onUpdateKeyframeEase = vi.fn();
|
|
237
|
+
const view = renderFocusCard(
|
|
238
|
+
{ tweenPercentage: 100 },
|
|
239
|
+
onUpdateKeyframeEase,
|
|
240
|
+
false,
|
|
241
|
+
FLAT_ANIMATION,
|
|
242
|
+
onUpdateMeta,
|
|
243
|
+
);
|
|
244
|
+
const ease = selectPreset(view.host, "quad-out");
|
|
245
|
+
|
|
246
|
+
expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(FLAT_ANIMATION.id, { ease });
|
|
247
|
+
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
|
|
248
|
+
act(() => view.root.unmount());
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
|
|
253
|
+
return {
|
|
254
|
+
id: "anim-1",
|
|
255
|
+
method: "to",
|
|
256
|
+
position: 0.8,
|
|
257
|
+
duration: 1.2,
|
|
258
|
+
ease: "power2.out",
|
|
259
|
+
properties: { opacity: 1 },
|
|
260
|
+
...overrides,
|
|
261
|
+
} as GsapAnimation;
|
|
262
|
+
}
|
|
263
|
+
|
|
85
264
|
describe("AnimationCard ease editing", () => {
|
|
265
|
+
it.each([
|
|
266
|
+
["spring", "power2.out", "spring(0.42)", "Spring bounce"],
|
|
267
|
+
["wiggle", "power2.out", "wiggle(3,easeInOut,0.12)", "Wiggle count"],
|
|
268
|
+
["curve", "spring(0.6)", "custom(M0,0 C0.16,1 0.3,1 1,1)", "Cubic bezier control points"],
|
|
269
|
+
] as const)(
|
|
270
|
+
"commits and immediately displays the %s default when a keyframe segment switches mode",
|
|
271
|
+
(mode, currentEase, ease, fieldLabel) => {
|
|
272
|
+
const onUpdateKeyframeEase = vi.fn();
|
|
273
|
+
const animation = baseAnimation({
|
|
274
|
+
keyframes: {
|
|
275
|
+
format: "percentage",
|
|
276
|
+
keyframes: [
|
|
277
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
278
|
+
{ percentage: 50, properties: { opacity: 0.5 }, ease: currentEase },
|
|
279
|
+
{ percentage: 100, properties: { opacity: 1 } },
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const view = renderFocusCard(null, onUpdateKeyframeEase, true, animation);
|
|
284
|
+
|
|
285
|
+
openSegment(view.host, "0% → 50%");
|
|
286
|
+
const modeButton = view.host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
|
|
287
|
+
expect(modeButton).not.toBeNull();
|
|
288
|
+
act(() => modeButton?.click());
|
|
289
|
+
|
|
290
|
+
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
|
|
291
|
+
expect(modeButton?.getAttribute("aria-checked")).toBe("true");
|
|
292
|
+
expect(view.host.querySelector(`[aria-label="${fieldLabel}"]`)).not.toBeNull();
|
|
293
|
+
act(() => view.root.unmount());
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
86
297
|
it("commits one preset change to the selected keyframe segment", () => {
|
|
87
298
|
const onUpdateKeyframeEase = vi.fn();
|
|
88
299
|
const animation = baseAnimation({
|
|
@@ -97,11 +308,7 @@ describe("AnimationCard ease editing", () => {
|
|
|
97
308
|
});
|
|
98
309
|
const view = renderCard({ animation, onUpdateKeyframeEase });
|
|
99
310
|
|
|
100
|
-
|
|
101
|
-
button.textContent?.includes("0% → 50%"),
|
|
102
|
-
);
|
|
103
|
-
expect(segment).toBeDefined();
|
|
104
|
-
act(() => segment?.click());
|
|
311
|
+
openSegment(view.host, "0% → 50%");
|
|
105
312
|
const ease = selectPreset(view.host, "quad-out");
|
|
106
313
|
|
|
107
314
|
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
|
|
@@ -18,12 +18,16 @@ import {
|
|
|
18
18
|
parseNumericOrString,
|
|
19
19
|
BOOLEAN_PROPS,
|
|
20
20
|
} from "./AnimationCardParts";
|
|
21
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
21
22
|
|
|
22
23
|
interface AnimationCardProps extends GsapAnimationEditCallbacks {
|
|
23
24
|
animation: GsapAnimation;
|
|
24
25
|
defaultExpanded: boolean;
|
|
25
26
|
flat?: boolean;
|
|
26
|
-
focusedSegment?: {
|
|
27
|
+
focusedSegment?: {
|
|
28
|
+
tweenPercentage: number;
|
|
29
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
30
|
+
} | null;
|
|
27
31
|
onFocusSegmentConsumed?: () => void;
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -47,6 +51,7 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
47
51
|
onSetArcPath,
|
|
48
52
|
onUpdateArcSegment,
|
|
49
53
|
onUpdateKeyframeEase,
|
|
54
|
+
onUpdateSegmentEase,
|
|
50
55
|
onSetAllKeyframeEases,
|
|
51
56
|
onUnroll,
|
|
52
57
|
}: AnimationCardProps) {
|
|
@@ -54,6 +59,9 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
54
59
|
const [addingProp, setAddingProp] = useState(false);
|
|
55
60
|
const [addingFromProp, setAddingFromProp] = useState(false);
|
|
56
61
|
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
|
|
62
|
+
const [focusedCollidingAnimationTargets, setFocusedCollidingAnimationTargets] = useState<
|
|
63
|
+
AnimationKeyframeTarget[] | undefined
|
|
64
|
+
>();
|
|
57
65
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
58
66
|
const pendingAutoScrollRef = useRef(false);
|
|
59
67
|
|
|
@@ -62,6 +70,7 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
62
70
|
setExpanded(true);
|
|
63
71
|
pendingAutoScrollRef.current = true;
|
|
64
72
|
setExpandedKfPct(focusedSegment.tweenPercentage);
|
|
73
|
+
setFocusedCollidingAnimationTargets(focusedSegment.collidingAnimationTargets);
|
|
65
74
|
onFocusSegmentConsumed?.();
|
|
66
75
|
}, [focusedSegment, onFocusSegmentConsumed]);
|
|
67
76
|
|
|
@@ -288,9 +297,21 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
288
297
|
keyframes={animation.keyframes.keyframes}
|
|
289
298
|
globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
|
|
290
299
|
expandedPct={expandedKfPct}
|
|
291
|
-
|
|
300
|
+
collidingAnimationTargets={focusedCollidingAnimationTargets}
|
|
301
|
+
onToggle={(pct) => {
|
|
302
|
+
setExpandedKfPct(pct);
|
|
303
|
+
setFocusedCollidingAnimationTargets(undefined);
|
|
304
|
+
}}
|
|
292
305
|
onEaseCommit={(pct, ease) => {
|
|
293
|
-
|
|
306
|
+
if (
|
|
307
|
+
focusedCollidingAnimationTargets &&
|
|
308
|
+
focusedCollidingAnimationTargets.length > 1 &&
|
|
309
|
+
onUpdateSegmentEase
|
|
310
|
+
) {
|
|
311
|
+
onUpdateSegmentEase(focusedCollidingAnimationTargets, ease);
|
|
312
|
+
} else {
|
|
313
|
+
onUpdateKeyframeEase(animation.id, pct, ease);
|
|
314
|
+
}
|
|
294
315
|
trackStudioSegmentEaseEdit({ action: "commit", ease });
|
|
295
316
|
}}
|
|
296
317
|
onApplyAll={
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
import React, { act, useState } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { parseSpringBounce } from "@hyperframes/core/spring-ease";
|
|
7
|
+
import { parseWiggleEase } from "@hyperframes/core/wiggle-ease";
|
|
6
8
|
import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection";
|
|
9
|
+
import { resolveEaseCurveTuple } from "./gsapAnimationConstants";
|
|
10
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
7
11
|
|
|
8
12
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
13
|
|
|
@@ -11,12 +15,22 @@ afterEach(() => {
|
|
|
11
15
|
document.body.innerHTML = "";
|
|
12
16
|
});
|
|
13
17
|
|
|
14
|
-
function renderSection(
|
|
18
|
+
function renderSection(
|
|
19
|
+
ease = "none",
|
|
20
|
+
onCustomEaseCommit = vi.fn(),
|
|
21
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[],
|
|
22
|
+
) {
|
|
15
23
|
const host = document.createElement("div");
|
|
16
24
|
document.body.append(host);
|
|
17
25
|
const root = createRoot(host);
|
|
18
26
|
act(() => {
|
|
19
|
-
root.render(
|
|
27
|
+
root.render(
|
|
28
|
+
<EaseCurveSection
|
|
29
|
+
ease={ease}
|
|
30
|
+
onCustomEaseCommit={onCustomEaseCommit}
|
|
31
|
+
collidingAnimationTargets={collidingAnimationTargets}
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
20
34
|
});
|
|
21
35
|
return { host, root, onCustomEaseCommit };
|
|
22
36
|
}
|
|
@@ -65,6 +79,19 @@ function renderStatefulSection(initialEase = "none", onCustomEaseCommit = vi.fn(
|
|
|
65
79
|
return { host, root, onCustomEaseCommit };
|
|
66
80
|
}
|
|
67
81
|
|
|
82
|
+
function renderControlledSection(initialEase = "none", onCustomEaseCommit = vi.fn()) {
|
|
83
|
+
const host = document.createElement("div");
|
|
84
|
+
document.body.append(host);
|
|
85
|
+
const root = createRoot(host);
|
|
86
|
+
const renderEase = (ease: string) => {
|
|
87
|
+
act(() =>
|
|
88
|
+
root.render(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />),
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
renderEase(initialEase);
|
|
92
|
+
return { host, root, onCustomEaseCommit, renderEase };
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void {
|
|
69
96
|
const toggle = host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
|
|
70
97
|
expect(toggle).not.toBeNull();
|
|
@@ -82,6 +109,29 @@ function editorLabel(host: HTMLElement): string | null {
|
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
describe("EaseCurveSection preset grid", () => {
|
|
112
|
+
it("shows the number of animations for a multi-id segment", () => {
|
|
113
|
+
const { host, root } = renderSection("power2.out", vi.fn(), [
|
|
114
|
+
{ animationId: "move-x", tweenPercentage: 20 },
|
|
115
|
+
{ animationId: "move-y", tweenPercentage: 50 },
|
|
116
|
+
{ animationId: "fade", tweenPercentage: 80 },
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
expect(host.textContent).toContain("Applies to 3 animations");
|
|
120
|
+
|
|
121
|
+
act(() => root.unmount());
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it.each([undefined, [{ animationId: "move-x", tweenPercentage: 20 }]])(
|
|
125
|
+
"does not show a property count for a non-colliding segment",
|
|
126
|
+
(collidingAnimationTargets) => {
|
|
127
|
+
const { host, root } = renderSection("power2.out", vi.fn(), collidingAnimationTargets);
|
|
128
|
+
|
|
129
|
+
expect(host.textContent).not.toContain("Applies to");
|
|
130
|
+
|
|
131
|
+
act(() => root.unmount());
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
85
135
|
it.each([
|
|
86
136
|
["curve", "none", "linear", ["flow-7", "spring-bouncy"]],
|
|
87
137
|
["spring", "spring(0.42)", "spring-bouncy", ["linear", "flow-7"]],
|
|
@@ -257,12 +307,107 @@ describe("EaseCurveSection preset grid", () => {
|
|
|
257
307
|
|
|
258
308
|
clickMode(host, "spring");
|
|
259
309
|
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)");
|
|
310
|
+
expect(parseSpringBounce(onCustomEaseCommit.mock.lastCall![0])).toBe(0.42);
|
|
260
311
|
|
|
261
312
|
clickMode(host, "curve");
|
|
262
313
|
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)");
|
|
314
|
+
expect(resolveEaseCurveTuple(onCustomEaseCommit.mock.lastCall![0])).toEqual([0.16, 1, 0.3, 1]);
|
|
263
315
|
|
|
264
316
|
clickMode(host, "wiggle");
|
|
265
317
|
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)");
|
|
318
|
+
expect(parseWiggleEase(onCustomEaseCommit.mock.lastCall![0])).toEqual({
|
|
319
|
+
wiggles: 3,
|
|
320
|
+
type: "easeInOut",
|
|
321
|
+
amplitude: 0.12,
|
|
322
|
+
});
|
|
323
|
+
expect(onCustomEaseCommit).toHaveBeenCalledTimes(3);
|
|
324
|
+
|
|
325
|
+
act(() => root.unmount());
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("keeps an optimistic mode visible through its canonical prop round-trip", () => {
|
|
329
|
+
const { host, root, onCustomEaseCommit, renderEase } = renderControlledSection();
|
|
330
|
+
|
|
331
|
+
clickMode(host, "spring");
|
|
332
|
+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
|
|
333
|
+
"true",
|
|
334
|
+
);
|
|
335
|
+
expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull();
|
|
336
|
+
|
|
337
|
+
renderEase("spring(0.42)");
|
|
338
|
+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
|
|
339
|
+
"true",
|
|
340
|
+
);
|
|
341
|
+
expect(host.querySelector('[aria-label="Spring bounce"]')).not.toBeNull();
|
|
342
|
+
expect(onCustomEaseCommit).toHaveBeenCalledExactlyOnceWith("spring(0.42)");
|
|
343
|
+
|
|
344
|
+
act(() => root.unmount());
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Two switches before the first commit round-trips: the commits serialize, so
|
|
348
|
+
// the older value arrives while the newer one is still in flight. Repainting
|
|
349
|
+
// it would flash wiggle, spring, wiggle in the panel.
|
|
350
|
+
it("ignores an older in-flight commit arriving after a newer switch", () => {
|
|
351
|
+
const { host, root, renderEase } = renderControlledSection();
|
|
352
|
+
|
|
353
|
+
clickMode(host, "spring");
|
|
354
|
+
clickMode(host, "wiggle");
|
|
355
|
+
renderEase("spring(0.42)");
|
|
356
|
+
|
|
357
|
+
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
|
|
358
|
+
"true",
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
renderEase("wiggle(3,easeInOut,0.12)");
|
|
362
|
+
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
|
|
363
|
+
"true",
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
act(() => root.unmount());
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// The commit is fire-and-forget: a rejected write or one that lands as a
|
|
370
|
+
// no-op never changes `ease`, so nothing else can retire the optimistic
|
|
371
|
+
// value and the panel would keep claiming a curve that was never saved.
|
|
372
|
+
it("falls back to the committed ease when the commit never round-trips", () => {
|
|
373
|
+
vi.useFakeTimers();
|
|
374
|
+
try {
|
|
375
|
+
const { host, root } = renderControlledSection("power2.out");
|
|
376
|
+
|
|
377
|
+
clickMode(host, "spring");
|
|
378
|
+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
|
|
379
|
+
"true",
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
act(() => vi.advanceTimersByTime(2000));
|
|
383
|
+
|
|
384
|
+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
|
|
385
|
+
"false",
|
|
386
|
+
);
|
|
387
|
+
expect(host.querySelector('[data-ease-mode="curve"]')?.getAttribute("aria-checked")).toBe(
|
|
388
|
+
"true",
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
act(() => root.unmount());
|
|
392
|
+
} finally {
|
|
393
|
+
vi.useRealTimers();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("replaces an optimistic mode when the canonical prop changes externally", () => {
|
|
398
|
+
const { host, root, renderEase } = renderControlledSection();
|
|
399
|
+
|
|
400
|
+
clickMode(host, "spring");
|
|
401
|
+
renderEase("wiggle(2,uniform,0.3)");
|
|
402
|
+
|
|
403
|
+
expect(host.querySelector('[data-ease-mode="spring"]')?.getAttribute("aria-checked")).toBe(
|
|
404
|
+
"false",
|
|
405
|
+
);
|
|
406
|
+
expect(host.querySelector('[data-ease-mode="wiggle"]')?.getAttribute("aria-checked")).toBe(
|
|
407
|
+
"true",
|
|
408
|
+
);
|
|
409
|
+
expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull();
|
|
410
|
+
expect(host.querySelector('[aria-label="Spring bounce"]')).toBeNull();
|
|
266
411
|
|
|
267
412
|
act(() => root.unmount());
|
|
268
413
|
});
|