@hyperframes/studio 0.7.75 → 0.7.77
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-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
- package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
- package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
- package/dist/assets/index-Bp4jAYZG.js +428 -0
- package/dist/assets/index-CBAfprvx.css +1 -0
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +18040 -16293
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-DzDajONI.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- package/src/utils/keyframeSelection.ts +0 -29
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
6
|
+
import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds";
|
|
7
|
+
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
|
7
8
|
|
|
8
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
10
|
|
|
@@ -45,6 +46,45 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
describe("TimelineClipDiamonds", () => {
|
|
49
|
+
// Dense rows narrow the DIAMOND so neighbours stay individually readable, but
|
|
50
|
+
// the hit box floors at KF_MIN_HIT_W — a gap-sized target gets unusable
|
|
51
|
+
// (~7px) at the zoom floor.
|
|
52
|
+
it("narrows dense keyframe visuals while flooring their hit regions", () => {
|
|
53
|
+
const host = document.createElement("div");
|
|
54
|
+
document.body.append(host);
|
|
55
|
+
const root = createRoot(host);
|
|
56
|
+
act(() => {
|
|
57
|
+
root.render(
|
|
58
|
+
<TimelineDiamondLane
|
|
59
|
+
keyframesData={{
|
|
60
|
+
format: "percentage",
|
|
61
|
+
keyframes: [30, 60, 90].map((percentage) => ({
|
|
62
|
+
percentage,
|
|
63
|
+
propertyGroup: "position",
|
|
64
|
+
properties: { x: percentage },
|
|
65
|
+
})),
|
|
66
|
+
}}
|
|
67
|
+
clipWidthPx={36}
|
|
68
|
+
clipHeightPx={48}
|
|
69
|
+
accentColor="#4ba3d2"
|
|
70
|
+
isSelected
|
|
71
|
+
currentPercentage={0}
|
|
72
|
+
elementId="clip-1"
|
|
73
|
+
selectedKeyframes={new Set()}
|
|
74
|
+
groupAware
|
|
75
|
+
/>,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const diamonds = Array.from(host.querySelectorAll<HTMLButtonElement>("button[title]"));
|
|
80
|
+
expect(diamonds).toHaveLength(3);
|
|
81
|
+
for (const diamond of diamonds) {
|
|
82
|
+
expect(Number.parseFloat(diamond.style.width)).toBeCloseTo(12);
|
|
83
|
+
expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBeCloseTo(8.8);
|
|
84
|
+
}
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
});
|
|
87
|
+
|
|
48
88
|
it("treats primary pointerup without drag as a keyframe click", () => {
|
|
49
89
|
const { host, root, onClickKeyframe } = renderDiamonds();
|
|
50
90
|
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
@@ -54,7 +94,57 @@ describe("TimelineClipDiamonds", () => {
|
|
|
54
94
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
|
55
95
|
});
|
|
56
96
|
|
|
57
|
-
expect(onClickKeyframe).toHaveBeenCalledWith(
|
|
97
|
+
expect(onClickKeyframe).toHaveBeenCalledWith(
|
|
98
|
+
"clip-1",
|
|
99
|
+
expect.objectContaining({ percentage: 50 }),
|
|
100
|
+
);
|
|
101
|
+
act(() => root.unmount());
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// The collapsed clip row and the expanded property lanes read the same cache,
|
|
105
|
+
// so a keyframe that carries a property group has to hash to the same key in
|
|
106
|
+
// both — otherwise collapsing a track silently drops the selection.
|
|
107
|
+
it("keys a grouped keyframe the same way collapsed as expanded", () => {
|
|
108
|
+
const groupedKeyframe = {
|
|
109
|
+
percentage: 50,
|
|
110
|
+
tweenPercentage: 25,
|
|
111
|
+
propertyGroup: "position",
|
|
112
|
+
animationId: "anim-1",
|
|
113
|
+
properties: { x: 100 },
|
|
114
|
+
};
|
|
115
|
+
const sharedKey = timelineKeyframeSelectionKey("clip-1", groupedKeyframe);
|
|
116
|
+
const host = document.createElement("div");
|
|
117
|
+
document.body.append(host);
|
|
118
|
+
const root = createRoot(host);
|
|
119
|
+
const onClickKeyframe = vi.fn();
|
|
120
|
+
act(() => {
|
|
121
|
+
root.render(
|
|
122
|
+
<TimelineClipDiamonds
|
|
123
|
+
keyframesData={{ format: "percentage", keyframes: [groupedKeyframe] }}
|
|
124
|
+
clipWidthPx={200}
|
|
125
|
+
clipHeightPx={48}
|
|
126
|
+
accentColor="#4ba3d2"
|
|
127
|
+
isSelected
|
|
128
|
+
currentPercentage={-10}
|
|
129
|
+
elementId="clip-1"
|
|
130
|
+
selectedKeyframes={new Set([sharedKey])}
|
|
131
|
+
onClickKeyframe={onClickKeyframe}
|
|
132
|
+
/>,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
136
|
+
// Highlighted from the shared key alone (the playhead is off-clip here).
|
|
137
|
+
expect(diamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2");
|
|
138
|
+
|
|
139
|
+
act(() => {
|
|
140
|
+
diamond?.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
|
|
141
|
+
});
|
|
142
|
+
expect(onClickKeyframe).toHaveBeenCalledWith("clip-1", {
|
|
143
|
+
percentage: 50,
|
|
144
|
+
tweenPercentage: 25,
|
|
145
|
+
propertyGroup: "position",
|
|
146
|
+
animationId: "anim-1",
|
|
147
|
+
});
|
|
58
148
|
act(() => root.unmount());
|
|
59
149
|
});
|
|
60
150
|
|
|
@@ -84,12 +174,24 @@ describe("TimelineClipDiamonds", () => {
|
|
|
84
174
|
const root = createRoot(host);
|
|
85
175
|
act(() => {
|
|
86
176
|
root.render(
|
|
87
|
-
<
|
|
177
|
+
<TimelineDiamondLane
|
|
88
178
|
keyframesData={{
|
|
89
179
|
format: "percentage",
|
|
90
180
|
keyframes: [
|
|
91
|
-
{
|
|
92
|
-
|
|
181
|
+
{
|
|
182
|
+
percentage: 0,
|
|
183
|
+
tweenPercentage: 0,
|
|
184
|
+
propertyGroup: "position",
|
|
185
|
+
animationId: "anim-1",
|
|
186
|
+
properties: { x: 0 },
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
percentage: 50,
|
|
190
|
+
tweenPercentage: 100,
|
|
191
|
+
propertyGroup: "position",
|
|
192
|
+
animationId: "anim-1",
|
|
193
|
+
properties: { x: 100 },
|
|
194
|
+
},
|
|
93
195
|
],
|
|
94
196
|
}}
|
|
95
197
|
clipWidthPx={5000}
|
|
@@ -101,6 +203,7 @@ describe("TimelineClipDiamonds", () => {
|
|
|
101
203
|
selectedKeyframes={new Set()}
|
|
102
204
|
onClickKeyframe={onClickKeyframe}
|
|
103
205
|
onMoveKeyframe={onMoveKeyframe}
|
|
206
|
+
groupAware
|
|
104
207
|
/>,
|
|
105
208
|
);
|
|
106
209
|
});
|
|
@@ -117,7 +220,12 @@ describe("TimelineClipDiamonds", () => {
|
|
|
117
220
|
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 }));
|
|
118
221
|
});
|
|
119
222
|
|
|
120
|
-
expect(onClickKeyframe).toHaveBeenCalledWith(
|
|
223
|
+
expect(onClickKeyframe).toHaveBeenCalledWith({
|
|
224
|
+
percentage: 50,
|
|
225
|
+
tweenPercentage: 100,
|
|
226
|
+
propertyGroup: "position",
|
|
227
|
+
animationId: "anim-1",
|
|
228
|
+
});
|
|
121
229
|
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
|
122
230
|
act(() => root.unmount());
|
|
123
231
|
});
|
|
@@ -126,20 +234,39 @@ describe("TimelineClipDiamonds", () => {
|
|
|
126
234
|
// keyframe) committed the move but never selected/parked on the result —
|
|
127
235
|
// the diamond it was just dragged looked exactly like one nothing happened
|
|
128
236
|
// to. Select it at its NEW position too.
|
|
129
|
-
it("
|
|
237
|
+
it("reselects a retimed keyframe with its post-move tween percentage", () => {
|
|
130
238
|
const onClickKeyframe = vi.fn();
|
|
131
|
-
const onMoveKeyframe = vi.fn();
|
|
239
|
+
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
|
132
240
|
const host = document.createElement("div");
|
|
133
241
|
document.body.append(host);
|
|
134
242
|
const root = createRoot(host);
|
|
135
243
|
act(() => {
|
|
136
244
|
root.render(
|
|
137
|
-
<
|
|
245
|
+
<TimelineDiamondLane
|
|
138
246
|
keyframesData={{
|
|
139
247
|
format: "percentage",
|
|
140
248
|
keyframes: [
|
|
141
|
-
{
|
|
142
|
-
|
|
249
|
+
{
|
|
250
|
+
percentage: 20,
|
|
251
|
+
tweenPercentage: 0,
|
|
252
|
+
propertyGroup: "position",
|
|
253
|
+
animationId: "anim-1",
|
|
254
|
+
properties: { x: 0 },
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
percentage: 40,
|
|
258
|
+
tweenPercentage: 50,
|
|
259
|
+
propertyGroup: "position",
|
|
260
|
+
animationId: "anim-1",
|
|
261
|
+
properties: { x: 100 },
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
percentage: 60,
|
|
265
|
+
tweenPercentage: 100,
|
|
266
|
+
propertyGroup: "position",
|
|
267
|
+
animationId: "anim-1",
|
|
268
|
+
properties: { x: 200 },
|
|
269
|
+
},
|
|
143
270
|
],
|
|
144
271
|
}}
|
|
145
272
|
clipWidthPx={200}
|
|
@@ -151,6 +278,164 @@ describe("TimelineClipDiamonds", () => {
|
|
|
151
278
|
selectedKeyframes={new Set()}
|
|
152
279
|
onClickKeyframe={onClickKeyframe}
|
|
153
280
|
onMoveKeyframe={onMoveKeyframe}
|
|
281
|
+
groupAware
|
|
282
|
+
/>,
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
|
|
286
|
+
expect(diamond).not.toBeNull();
|
|
287
|
+
|
|
288
|
+
act(() => {
|
|
289
|
+
diamond!.dispatchEvent(
|
|
290
|
+
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
|
|
291
|
+
);
|
|
292
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(onMoveKeyframe).toHaveBeenCalledWith(
|
|
296
|
+
{
|
|
297
|
+
percentage: 40,
|
|
298
|
+
tweenPercentage: 50,
|
|
299
|
+
propertyGroup: "position",
|
|
300
|
+
animationId: "anim-1",
|
|
301
|
+
},
|
|
302
|
+
50,
|
|
303
|
+
);
|
|
304
|
+
expect(onClickKeyframe).toHaveBeenCalledWith({
|
|
305
|
+
percentage: 50,
|
|
306
|
+
tweenPercentage: 75,
|
|
307
|
+
propertyGroup: "position",
|
|
308
|
+
animationId: "anim-1",
|
|
309
|
+
});
|
|
310
|
+
act(() => root.unmount());
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("leaves the selection alone when a stale retime fails after a newer drag", async () => {
|
|
314
|
+
const onClickKeyframe = vi.fn();
|
|
315
|
+
let failFirstDrag: (() => void) | undefined;
|
|
316
|
+
const onMoveKeyframe = vi
|
|
317
|
+
.fn()
|
|
318
|
+
.mockImplementationOnce(
|
|
319
|
+
() =>
|
|
320
|
+
new Promise<boolean>((resolve) => {
|
|
321
|
+
failFirstDrag = () => resolve(false);
|
|
322
|
+
}),
|
|
323
|
+
)
|
|
324
|
+
.mockResolvedValue(true);
|
|
325
|
+
const host = document.createElement("div");
|
|
326
|
+
document.body.append(host);
|
|
327
|
+
const root = createRoot(host);
|
|
328
|
+
act(() => {
|
|
329
|
+
root.render(
|
|
330
|
+
<TimelineDiamondLane
|
|
331
|
+
keyframesData={{
|
|
332
|
+
format: "percentage",
|
|
333
|
+
keyframes: [
|
|
334
|
+
{
|
|
335
|
+
percentage: 0,
|
|
336
|
+
tweenPercentage: 0,
|
|
337
|
+
propertyGroup: "position",
|
|
338
|
+
animationId: "anim-1",
|
|
339
|
+
properties: { x: 0 },
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
percentage: 50,
|
|
343
|
+
tweenPercentage: 50,
|
|
344
|
+
propertyGroup: "position",
|
|
345
|
+
animationId: "anim-1",
|
|
346
|
+
properties: { x: 100 },
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
percentage: 100,
|
|
350
|
+
tweenPercentage: 100,
|
|
351
|
+
propertyGroup: "position",
|
|
352
|
+
animationId: "anim-1",
|
|
353
|
+
properties: { x: 200 },
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
}}
|
|
357
|
+
clipWidthPx={200}
|
|
358
|
+
clipHeightPx={48}
|
|
359
|
+
accentColor="#4ba3d2"
|
|
360
|
+
isSelected
|
|
361
|
+
currentPercentage={0}
|
|
362
|
+
elementId="clip-1"
|
|
363
|
+
selectedKeyframes={new Set()}
|
|
364
|
+
onClickKeyframe={onClickKeyframe}
|
|
365
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
366
|
+
groupAware
|
|
367
|
+
/>,
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
371
|
+
|
|
372
|
+
// Two drags back to back; the first one's commit is still in flight.
|
|
373
|
+
act(() => {
|
|
374
|
+
diamond!.dispatchEvent(
|
|
375
|
+
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
|
376
|
+
);
|
|
377
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
|
378
|
+
diamond!.dispatchEvent(
|
|
379
|
+
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }),
|
|
380
|
+
);
|
|
381
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 }));
|
|
382
|
+
});
|
|
383
|
+
onClickKeyframe.mockClear();
|
|
384
|
+
|
|
385
|
+
await act(async () => {
|
|
386
|
+
failFirstDrag?.();
|
|
387
|
+
await Promise.resolve();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// The stale failure must not drag the selection back to the first drag's
|
|
391
|
+
// source: the second retime, which the user can see, owns it now.
|
|
392
|
+
expect(onClickKeyframe).not.toHaveBeenCalled();
|
|
393
|
+
act(() => root.unmount());
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("composes a rapid second retime from the pending position", () => {
|
|
397
|
+
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
|
398
|
+
const host = document.createElement("div");
|
|
399
|
+
document.body.append(host);
|
|
400
|
+
const root = createRoot(host);
|
|
401
|
+
act(() => {
|
|
402
|
+
root.render(
|
|
403
|
+
<TimelineDiamondLane
|
|
404
|
+
keyframesData={{
|
|
405
|
+
format: "percentage",
|
|
406
|
+
keyframes: [
|
|
407
|
+
{
|
|
408
|
+
percentage: 0,
|
|
409
|
+
tweenPercentage: 0,
|
|
410
|
+
propertyGroup: "position",
|
|
411
|
+
animationId: "anim-1",
|
|
412
|
+
properties: { x: 0 },
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
percentage: 50,
|
|
416
|
+
tweenPercentage: 50,
|
|
417
|
+
propertyGroup: "position",
|
|
418
|
+
animationId: "anim-1",
|
|
419
|
+
properties: { x: 100 },
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
percentage: 100,
|
|
423
|
+
tweenPercentage: 100,
|
|
424
|
+
propertyGroup: "position",
|
|
425
|
+
animationId: "anim-1",
|
|
426
|
+
properties: { x: 200 },
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
}}
|
|
430
|
+
clipWidthPx={200}
|
|
431
|
+
clipHeightPx={48}
|
|
432
|
+
accentColor="#4ba3d2"
|
|
433
|
+
isSelected
|
|
434
|
+
currentPercentage={0}
|
|
435
|
+
elementId="clip-1"
|
|
436
|
+
selectedKeyframes={new Set()}
|
|
437
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
438
|
+
groupAware
|
|
154
439
|
/>,
|
|
155
440
|
);
|
|
156
441
|
});
|
|
@@ -161,13 +446,156 @@ describe("TimelineClipDiamonds", () => {
|
|
|
161
446
|
diamond!.dispatchEvent(
|
|
162
447
|
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
|
163
448
|
);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
449
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
|
450
|
+
|
|
451
|
+
// The cache still exposes 50%, but this second +10% drag starts at the
|
|
452
|
+
// pending 75% destination and must therefore land at 85%, not 60%.
|
|
453
|
+
diamond!.dispatchEvent(
|
|
454
|
+
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }),
|
|
455
|
+
);
|
|
456
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 }));
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// The second move must identify the FROM keyframe by the pending (already-
|
|
460
|
+
// moved) position 75%, not the stale rendered 50%; otherwise the serialized
|
|
461
|
+
// mutation can't locate the keyframe the first move relocated.
|
|
462
|
+
expect(onMoveKeyframe).toHaveBeenNthCalledWith(
|
|
463
|
+
2,
|
|
464
|
+
{
|
|
465
|
+
percentage: 75,
|
|
466
|
+
tweenPercentage: 75,
|
|
467
|
+
propertyGroup: "position",
|
|
468
|
+
animationId: "anim-1",
|
|
469
|
+
},
|
|
470
|
+
85,
|
|
471
|
+
);
|
|
472
|
+
act(() => root.unmount());
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it.each([
|
|
476
|
+
["returns false", () => Promise.resolve(false)],
|
|
477
|
+
["rejects", () => Promise.reject(new Error("retime failed"))],
|
|
478
|
+
])("clears a failed pending retime when the callback %s", async (_label, settle) => {
|
|
479
|
+
const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true);
|
|
480
|
+
const host = document.createElement("div");
|
|
481
|
+
document.body.append(host);
|
|
482
|
+
const root = createRoot(host);
|
|
483
|
+
act(() => {
|
|
484
|
+
root.render(
|
|
485
|
+
<TimelineDiamondLane
|
|
486
|
+
keyframesData={{
|
|
487
|
+
format: "percentage",
|
|
488
|
+
keyframes: [
|
|
489
|
+
{
|
|
490
|
+
percentage: 0,
|
|
491
|
+
tweenPercentage: 0,
|
|
492
|
+
propertyGroup: "position",
|
|
493
|
+
animationId: "anim-1",
|
|
494
|
+
properties: { x: 0 },
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
percentage: 50,
|
|
498
|
+
tweenPercentage: 50,
|
|
499
|
+
propertyGroup: "position",
|
|
500
|
+
animationId: "anim-1",
|
|
501
|
+
properties: { x: 100 },
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
percentage: 100,
|
|
505
|
+
tweenPercentage: 100,
|
|
506
|
+
propertyGroup: "position",
|
|
507
|
+
animationId: "anim-1",
|
|
508
|
+
properties: { x: 200 },
|
|
509
|
+
},
|
|
510
|
+
],
|
|
511
|
+
}}
|
|
512
|
+
clipWidthPx={200}
|
|
513
|
+
clipHeightPx={48}
|
|
514
|
+
accentColor="#4ba3d2"
|
|
515
|
+
isSelected
|
|
516
|
+
currentPercentage={0}
|
|
517
|
+
elementId="clip-1"
|
|
518
|
+
selectedKeyframes={new Set()}
|
|
519
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
520
|
+
groupAware
|
|
521
|
+
/>,
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
525
|
+
expect(diamond).not.toBeNull();
|
|
526
|
+
|
|
527
|
+
await act(async () => {
|
|
528
|
+
diamond!.dispatchEvent(
|
|
529
|
+
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
|
530
|
+
);
|
|
531
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 }));
|
|
532
|
+
await Promise.resolve();
|
|
167
533
|
});
|
|
168
534
|
|
|
169
|
-
|
|
170
|
-
|
|
535
|
+
act(() => {
|
|
536
|
+
diamond!.dispatchEvent(
|
|
537
|
+
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
|
|
538
|
+
);
|
|
539
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 }));
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
expect(onMoveKeyframe).toHaveBeenNthCalledWith(
|
|
543
|
+
2,
|
|
544
|
+
{
|
|
545
|
+
percentage: 50,
|
|
546
|
+
tweenPercentage: 50,
|
|
547
|
+
propertyGroup: "position",
|
|
548
|
+
animationId: "anim-1",
|
|
549
|
+
},
|
|
550
|
+
60,
|
|
551
|
+
);
|
|
552
|
+
act(() => root.unmount());
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("cancels an in-flight retime on Escape without committing or selecting", () => {
|
|
556
|
+
const onClickKeyframe = vi.fn();
|
|
557
|
+
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
|
558
|
+
const host = document.createElement("div");
|
|
559
|
+
document.body.append(host);
|
|
560
|
+
const root = createRoot(host);
|
|
561
|
+
act(() => {
|
|
562
|
+
root.render(
|
|
563
|
+
<TimelineDiamondLane
|
|
564
|
+
keyframesData={{
|
|
565
|
+
format: "percentage",
|
|
566
|
+
keyframes: [
|
|
567
|
+
{ percentage: 20, tweenPercentage: 0, properties: { x: 0 } },
|
|
568
|
+
{ percentage: 40, tweenPercentage: 50, properties: { x: 100 } },
|
|
569
|
+
{ percentage: 60, tweenPercentage: 100, properties: { x: 200 } },
|
|
570
|
+
],
|
|
571
|
+
}}
|
|
572
|
+
clipWidthPx={200}
|
|
573
|
+
clipHeightPx={48}
|
|
574
|
+
accentColor="#4ba3d2"
|
|
575
|
+
isSelected
|
|
576
|
+
currentPercentage={0}
|
|
577
|
+
elementId="clip-1"
|
|
578
|
+
selectedKeyframes={new Set()}
|
|
579
|
+
onClickKeyframe={onClickKeyframe}
|
|
580
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
581
|
+
/>,
|
|
582
|
+
);
|
|
583
|
+
});
|
|
584
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="40%"]');
|
|
585
|
+
expect(diamond).not.toBeNull();
|
|
586
|
+
|
|
587
|
+
act(() => {
|
|
588
|
+
diamond!.dispatchEvent(
|
|
589
|
+
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }),
|
|
590
|
+
);
|
|
591
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
592
|
+
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 }));
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// Escape ends the gesture: no retime is written, and the release is not
|
|
596
|
+
// reinterpreted as a click that would park the playhead on the keyframe.
|
|
597
|
+
expect(onMoveKeyframe).not.toHaveBeenCalled();
|
|
598
|
+
expect(onClickKeyframe).not.toHaveBeenCalled();
|
|
171
599
|
act(() => root.unmount());
|
|
172
600
|
});
|
|
173
601
|
|
|
@@ -212,4 +640,100 @@ describe("TimelineClipDiamonds", () => {
|
|
|
212
640
|
expect(suppressClickRef.current).toBe(true);
|
|
213
641
|
act(() => root.unmount());
|
|
214
642
|
});
|
|
643
|
+
|
|
644
|
+
const renderSegmentLane = (lastAmbiguous: boolean) => {
|
|
645
|
+
const host = document.createElement("div");
|
|
646
|
+
document.body.append(host);
|
|
647
|
+
const root = createRoot(host);
|
|
648
|
+
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
|
|
649
|
+
percentage,
|
|
650
|
+
tweenPercentage: percentage,
|
|
651
|
+
propertyGroup: "position",
|
|
652
|
+
animationId: "anim-1",
|
|
653
|
+
properties: { x: percentage },
|
|
654
|
+
...extra,
|
|
655
|
+
});
|
|
656
|
+
act(() => {
|
|
657
|
+
root.render(
|
|
658
|
+
<TimelineDiamondLane
|
|
659
|
+
keyframesData={{
|
|
660
|
+
format: "percentage",
|
|
661
|
+
keyframes: [kf(0), kf(50), kf(100, { easeAmbiguous: lastAmbiguous })],
|
|
662
|
+
}}
|
|
663
|
+
clipWidthPx={200}
|
|
664
|
+
clipHeightPx={48}
|
|
665
|
+
accentColor="#4ba3d2"
|
|
666
|
+
isSelected
|
|
667
|
+
currentPercentage={0}
|
|
668
|
+
elementId="clip-1"
|
|
669
|
+
selectedKeyframes={new Set()}
|
|
670
|
+
onSelectSegment={vi.fn()}
|
|
671
|
+
groupAware
|
|
672
|
+
/>,
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
return { host, root };
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
it("hides the inline ease button on an ambiguous merged segment", () => {
|
|
679
|
+
// Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous
|
|
680
|
+
// keyframe, so its hover/ease-button area is not rendered.
|
|
681
|
+
const { host, root } = renderSegmentLane(true);
|
|
682
|
+
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
|
|
683
|
+
act(() => root.unmount());
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("keeps the inline ease button on unambiguous merged segments", () => {
|
|
687
|
+
const { host, root } = renderSegmentLane(false);
|
|
688
|
+
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
|
|
689
|
+
act(() => root.unmount());
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("keeps the ease button above nearby diamonds without blocking the segment", () => {
|
|
693
|
+
const { host, root } = renderSegmentLane(false);
|
|
694
|
+
const segment = host.querySelector<HTMLElement>("[data-keyframe-ease-segment]");
|
|
695
|
+
const ease = segment?.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
|
|
696
|
+
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
|
697
|
+
|
|
698
|
+
expect(segment?.style.pointerEvents).toBe("none");
|
|
699
|
+
expect(ease?.style.pointerEvents).toBe("auto");
|
|
700
|
+
expect(Number(segment?.style.zIndex)).toBeGreaterThan(Number(diamond?.style.zIndex));
|
|
701
|
+
act(() => root.unmount());
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("hides the inline ease button on a segment with no source animation id", () => {
|
|
705
|
+
// A runtime-scanned keyframe has no animationId, so there is no tween to
|
|
706
|
+
// target; the segment ending on it must not render a (dead) ease button.
|
|
707
|
+
const host = document.createElement("div");
|
|
708
|
+
document.body.append(host);
|
|
709
|
+
const root = createRoot(host);
|
|
710
|
+
const kf = (percentage: number, animationId?: string) => ({
|
|
711
|
+
percentage,
|
|
712
|
+
tweenPercentage: percentage,
|
|
713
|
+
propertyGroup: "position",
|
|
714
|
+
...(animationId ? { animationId } : {}),
|
|
715
|
+
properties: { x: percentage },
|
|
716
|
+
});
|
|
717
|
+
act(() => {
|
|
718
|
+
root.render(
|
|
719
|
+
<TimelineDiamondLane
|
|
720
|
+
keyframesData={{
|
|
721
|
+
format: "percentage",
|
|
722
|
+
keyframes: [kf(0, "anim-1"), kf(50, "anim-1"), kf(100)],
|
|
723
|
+
}}
|
|
724
|
+
clipWidthPx={200}
|
|
725
|
+
clipHeightPx={48}
|
|
726
|
+
accentColor="#4ba3d2"
|
|
727
|
+
isSelected
|
|
728
|
+
currentPercentage={0}
|
|
729
|
+
elementId="clip-1"
|
|
730
|
+
selectedKeyframes={new Set()}
|
|
731
|
+
onSelectSegment={vi.fn()}
|
|
732
|
+
groupAware
|
|
733
|
+
/>,
|
|
734
|
+
);
|
|
735
|
+
});
|
|
736
|
+
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1);
|
|
737
|
+
act(() => root.unmount());
|
|
738
|
+
});
|
|
215
739
|
});
|