@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
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
8
|
+
import {
|
|
9
|
+
getTimelinePropertyLanes,
|
|
10
|
+
TimelinePropertyLanes,
|
|
11
|
+
type TimelinePropertyLanesProps,
|
|
12
|
+
} from "./TimelinePropertyLanes";
|
|
13
|
+
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
|
14
|
+
|
|
15
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.body.innerHTML = "";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function animation(
|
|
22
|
+
id: string,
|
|
23
|
+
propertyGroup: PropertyGroupName,
|
|
24
|
+
keyframes: Array<{
|
|
25
|
+
percentage: number;
|
|
26
|
+
properties: Record<string, number | string>;
|
|
27
|
+
ease?: string;
|
|
28
|
+
}>,
|
|
29
|
+
): GsapAnimation {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
targetSelector: "#clip-1",
|
|
33
|
+
method: "to",
|
|
34
|
+
position: 0,
|
|
35
|
+
duration: 1,
|
|
36
|
+
properties: {},
|
|
37
|
+
propertyGroup,
|
|
38
|
+
keyframes: { format: "percentage", keyframes },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function flatAnimation(
|
|
43
|
+
id: string,
|
|
44
|
+
propertyGroup: PropertyGroupName,
|
|
45
|
+
properties: Record<string, number | string>,
|
|
46
|
+
): GsapAnimation {
|
|
47
|
+
return {
|
|
48
|
+
id,
|
|
49
|
+
targetSelector: "#clip-1",
|
|
50
|
+
method: "to",
|
|
51
|
+
position: 0,
|
|
52
|
+
duration: 1,
|
|
53
|
+
properties,
|
|
54
|
+
propertyGroup,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderPropertyLanes(overrides: Partial<TimelinePropertyLanesProps> = {}): {
|
|
59
|
+
host: HTMLDivElement;
|
|
60
|
+
root: Root;
|
|
61
|
+
} {
|
|
62
|
+
const host = document.createElement("div");
|
|
63
|
+
document.body.append(host);
|
|
64
|
+
const root = createRoot(host);
|
|
65
|
+
act(() => {
|
|
66
|
+
root.render(
|
|
67
|
+
<TimelinePropertyLanes
|
|
68
|
+
animations={[]}
|
|
69
|
+
clipStart={0}
|
|
70
|
+
clipDuration={1}
|
|
71
|
+
clipLeftPx={0}
|
|
72
|
+
clipWidthPx={200}
|
|
73
|
+
accentColor="#4ba3d2"
|
|
74
|
+
isSelected
|
|
75
|
+
currentPercentage={-10}
|
|
76
|
+
elementId="clip-1"
|
|
77
|
+
selectedKeyframes={new Set()}
|
|
78
|
+
{...overrides}
|
|
79
|
+
/>,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
return { host, root };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function laneDiamonds(host: HTMLElement, group: string): HTMLButtonElement[] {
|
|
86
|
+
return Array.from(
|
|
87
|
+
host.querySelectorAll<HTMLButtonElement>(
|
|
88
|
+
`[data-property-group="${group}"] button[data-keyframe-percentage]`,
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function expectLanePercentages(host: HTMLElement, group: string, percentages: string[]) {
|
|
94
|
+
expect(laneDiamonds(host, group).map((diamond) => diamond.dataset.keyframePercentage)).toEqual(
|
|
95
|
+
percentages,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function laneEaseButtons(host: HTMLElement, group: string): HTMLButtonElement[] {
|
|
100
|
+
return Array.from(
|
|
101
|
+
host.querySelectorAll<HTMLButtonElement>(
|
|
102
|
+
`[data-property-group="${group}"] button[data-keyframe-ease-button]`,
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function laneEaseSegments(host: HTMLElement, group: string): HTMLElement[] {
|
|
108
|
+
return Array.from(
|
|
109
|
+
host.querySelectorAll<HTMLElement>(
|
|
110
|
+
`[data-property-group="${group}"] [data-keyframe-ease-segment]`,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function revealEaseButton(segment: HTMLElement): HTMLButtonElement | null {
|
|
116
|
+
return segment.querySelector<HTMLButtonElement>("button[data-keyframe-ease-button]");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const POSITION_SEGMENT_ANIMATION = animation("position-tween", "position", [
|
|
120
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
121
|
+
{ percentage: 50, properties: { x: 50 } },
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
describe("TimelinePropertyLanes", () => {
|
|
125
|
+
it("returns a position lane with synthesized endpoints for a flat tween", () => {
|
|
126
|
+
const lanes = getTimelinePropertyLanes(
|
|
127
|
+
[flatAnimation("position-tween", "position", { x: 420 })],
|
|
128
|
+
0,
|
|
129
|
+
1,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(lanes).toHaveLength(1);
|
|
133
|
+
expect(lanes[0]?.group).toBe("position");
|
|
134
|
+
expect(lanes[0]?.keyframes).toEqual([
|
|
135
|
+
{
|
|
136
|
+
percentage: 0,
|
|
137
|
+
tweenPercentage: 0,
|
|
138
|
+
properties: { x: 0 },
|
|
139
|
+
propertyGroup: "position",
|
|
140
|
+
animationId: "position-tween",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
percentage: 100,
|
|
144
|
+
tweenPercentage: 100,
|
|
145
|
+
properties: { x: 420 },
|
|
146
|
+
propertyGroup: "position",
|
|
147
|
+
animationId: "position-tween",
|
|
148
|
+
},
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns both flat and authored keyframe property groups", () => {
|
|
153
|
+
const lanes = getTimelinePropertyLanes(
|
|
154
|
+
[
|
|
155
|
+
flatAnimation("position-tween", "position", { x: 420 }),
|
|
156
|
+
animation("visual-tween", "visual", [
|
|
157
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
158
|
+
{ percentage: 100, properties: { opacity: 1 } },
|
|
159
|
+
]),
|
|
160
|
+
],
|
|
161
|
+
0,
|
|
162
|
+
1,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
expect(lanes.map((lane) => lane.group)).toEqual(["position", "visual"]);
|
|
166
|
+
expect(lanes.map((lane) => lane.keyframes.map((keyframe) => keyframe.percentage))).toEqual([
|
|
167
|
+
[0, 100],
|
|
168
|
+
[0, 100],
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("renders each source property group at its independent keyframe positions", () => {
|
|
173
|
+
const animations = [
|
|
174
|
+
animation("position-tween", "position", [
|
|
175
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
176
|
+
{ percentage: 50, properties: { x: 100, y: 20 } },
|
|
177
|
+
{ percentage: 100, properties: { x: 200, y: 40 } },
|
|
178
|
+
]),
|
|
179
|
+
animation("visual-tween", "visual", [{ percentage: 25, properties: { opacity: 0.5 } }]),
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const { host, root } = renderPropertyLanes({ animations });
|
|
183
|
+
const position = laneDiamonds(host, "position");
|
|
184
|
+
const visual = laneDiamonds(host, "visual");
|
|
185
|
+
|
|
186
|
+
expect(position).toHaveLength(3);
|
|
187
|
+
expect(visual).toHaveLength(1);
|
|
188
|
+
// Diamonds are centered on their true keyframe time (0% at -half); the
|
|
189
|
+
// reserved left gutter (content origin inset, tested at the Timeline level)
|
|
190
|
+
// keeps the overflowing left half visible rather than clamping it inward.
|
|
191
|
+
expect(position.map((diamond) => diamond.style.left)).toEqual(["-11px", "89px", "189px"]);
|
|
192
|
+
expect(visual[0]?.style.left).toBe("39px");
|
|
193
|
+
expect(
|
|
194
|
+
host.querySelectorAll('[data-property-group="position"] [data-keyframe-connector]'),
|
|
195
|
+
).toHaveLength(2);
|
|
196
|
+
act(() => root.unmount());
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("keeps both groups' diamonds when their source keyframes share 0% and 100%", () => {
|
|
200
|
+
const animations = [
|
|
201
|
+
animation("position-tween", "position", [
|
|
202
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
203
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
204
|
+
]),
|
|
205
|
+
animation("visual-tween", "visual", [
|
|
206
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
207
|
+
{ percentage: 100, properties: { opacity: 1 } },
|
|
208
|
+
]),
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
const { host, root } = renderPropertyLanes({ animations });
|
|
212
|
+
|
|
213
|
+
expectLanePercentages(host, "position", ["0", "100"]);
|
|
214
|
+
expectLanePercentages(host, "visual", ["0", "100"]);
|
|
215
|
+
act(() => root.unmount());
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("renders an authored hold keyframe whose value equals its predecessor", () => {
|
|
219
|
+
const animations = [
|
|
220
|
+
animation("position-tween", "position", [
|
|
221
|
+
{ percentage: 0, properties: { x: 10 } },
|
|
222
|
+
{ percentage: 50, properties: { x: 10 } },
|
|
223
|
+
{ percentage: 100, properties: { x: 20 } },
|
|
224
|
+
]),
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
const { host, root } = renderPropertyLanes({ animations });
|
|
228
|
+
|
|
229
|
+
expectLanePercentages(host, "position", ["0", "50", "100"]);
|
|
230
|
+
act(() => root.unmount());
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("keeps Position@50% selection distinct from Opacity@50%", () => {
|
|
234
|
+
const onClickKeyframe = vi.fn();
|
|
235
|
+
const animations = [
|
|
236
|
+
animation("position-tween", "position", [{ percentage: 50, properties: { x: 50 } }]),
|
|
237
|
+
animation("visual-tween", "visual", [{ percentage: 50, properties: { opacity: 0.5 } }]),
|
|
238
|
+
];
|
|
239
|
+
const { host, root } = renderPropertyLanes({ animations, onClickKeyframe });
|
|
240
|
+
const position = laneDiamonds(host, "position")[0]!;
|
|
241
|
+
|
|
242
|
+
act(() => {
|
|
243
|
+
position.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, button: 0 }));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const target = onClickKeyframe.mock.calls[0]?.[0];
|
|
247
|
+
expect(target).toEqual({
|
|
248
|
+
animationId: "position-tween",
|
|
249
|
+
percentage: 50,
|
|
250
|
+
propertyGroup: "position",
|
|
251
|
+
tweenPercentage: 50,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
act(() => {
|
|
255
|
+
root.render(
|
|
256
|
+
<TimelinePropertyLanes
|
|
257
|
+
animations={animations}
|
|
258
|
+
clipStart={0}
|
|
259
|
+
clipDuration={1}
|
|
260
|
+
clipLeftPx={0}
|
|
261
|
+
clipWidthPx={200}
|
|
262
|
+
accentColor="#4ba3d2"
|
|
263
|
+
isSelected
|
|
264
|
+
currentPercentage={-10}
|
|
265
|
+
elementId="clip-1"
|
|
266
|
+
selectedKeyframes={new Set([timelineKeyframeSelectionKey("clip-1", target)])}
|
|
267
|
+
/>,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const positionFill = laneDiamonds(host, "position")[0]?.querySelector("path:last-child");
|
|
272
|
+
const visualFill = laneDiamonds(host, "visual")[0]?.querySelector("path:last-child");
|
|
273
|
+
expect(positionFill?.getAttribute("fill")).toBe("#4ba3d2");
|
|
274
|
+
expect(visualFill?.getAttribute("fill")).toBe("#a3a3a3");
|
|
275
|
+
act(() => root.unmount());
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("keeps one accessible midpoint ease button per segment, regardless of selection", () => {
|
|
279
|
+
const animations = [
|
|
280
|
+
animation("position-tween", "position", [
|
|
281
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
282
|
+
{ percentage: 50, properties: { x: 50 } },
|
|
283
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
284
|
+
]),
|
|
285
|
+
];
|
|
286
|
+
const { host, root } = renderPropertyLanes({ animations, onSelectSegment: vi.fn() });
|
|
287
|
+
|
|
288
|
+
const segments = laneEaseSegments(host, "position");
|
|
289
|
+
expect(segments).toHaveLength(2);
|
|
290
|
+
expect(segments.map((segment) => segment.style.left)).toEqual(["0px", "100px"]);
|
|
291
|
+
expect(laneDiamonds(host, "position")).toHaveLength(3);
|
|
292
|
+
const buttons = laneEaseButtons(host, "position");
|
|
293
|
+
expect(buttons).toHaveLength(2);
|
|
294
|
+
expect(buttons.every((button) => button.classList.contains("opacity-0"))).toBe(true);
|
|
295
|
+
expect(buttons.every((button) => button.classList.contains("group-hover:opacity-100"))).toBe(
|
|
296
|
+
true,
|
|
297
|
+
);
|
|
298
|
+
expect(buttons.every((button) => button.classList.contains("focus-visible:opacity-100"))).toBe(
|
|
299
|
+
true,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// The ease button is available on hover even when the element is NOT selected
|
|
303
|
+
// (a lane shows for the track's active/primary clip, not only the selected one).
|
|
304
|
+
act(() => {
|
|
305
|
+
root.render(
|
|
306
|
+
<TimelinePropertyLanes
|
|
307
|
+
animations={animations}
|
|
308
|
+
clipStart={0}
|
|
309
|
+
clipDuration={1}
|
|
310
|
+
clipLeftPx={0}
|
|
311
|
+
clipWidthPx={200}
|
|
312
|
+
accentColor="#4ba3d2"
|
|
313
|
+
isSelected={false}
|
|
314
|
+
currentPercentage={-10}
|
|
315
|
+
elementId="clip-1"
|
|
316
|
+
selectedKeyframes={new Set()}
|
|
317
|
+
onSelectSegment={vi.fn()}
|
|
318
|
+
/>,
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
const unselectedSegments = laneEaseSegments(host, "position");
|
|
322
|
+
expect(unselectedSegments).toHaveLength(2);
|
|
323
|
+
expect(revealEaseButton(unselectedSegments[0]!)).not.toBeNull();
|
|
324
|
+
act(() => root.unmount());
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("reveals each segment's button with its destination keyframe ease curve", () => {
|
|
328
|
+
const animations = [
|
|
329
|
+
animation("position-tween", "position", [
|
|
330
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
331
|
+
{ percentage: 33, properties: { x: 33 }, ease: "none" },
|
|
332
|
+
{ percentage: 66, properties: { x: 66 }, ease: "power2.out" },
|
|
333
|
+
{
|
|
334
|
+
percentage: 100,
|
|
335
|
+
properties: { x: 100 },
|
|
336
|
+
ease: "custom(M0,0 C0.1,0.2 0.3,0.9 1,1)",
|
|
337
|
+
},
|
|
338
|
+
]),
|
|
339
|
+
];
|
|
340
|
+
const { host, root } = renderPropertyLanes({ animations, onSelectSegment: vi.fn() });
|
|
341
|
+
|
|
342
|
+
const segments = laneEaseSegments(host, "position");
|
|
343
|
+
expect(segments).toHaveLength(3);
|
|
344
|
+
const paths = segments.map((segment) =>
|
|
345
|
+
revealEaseButton(segment)?.querySelector("path")?.getAttribute("d"),
|
|
346
|
+
);
|
|
347
|
+
expect(paths).toHaveLength(3);
|
|
348
|
+
expect(new Set(paths).size).toBe(3);
|
|
349
|
+
// Uniqueness alone passes even when the curves are swapped between segments.
|
|
350
|
+
// Each segment is labelled with the ease it draws, so pin the ORDER: a
|
|
351
|
+
// segment carries the ease of the keyframe it arrives at.
|
|
352
|
+
expect(
|
|
353
|
+
segments.map((segment) => revealEaseButton(segment)?.getAttribute("aria-label")),
|
|
354
|
+
).toEqual([
|
|
355
|
+
"Edit none easing",
|
|
356
|
+
"Edit power2.out easing",
|
|
357
|
+
"Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing",
|
|
358
|
+
]);
|
|
359
|
+
act(() => root.unmount());
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("selects the destination keyframe when a hovered segment's ease button is clicked", () => {
|
|
363
|
+
const onSelectSegment = vi.fn();
|
|
364
|
+
const { host, root } = renderPropertyLanes({
|
|
365
|
+
animations: [POSITION_SEGMENT_ANIMATION],
|
|
366
|
+
onSelectSegment,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const button = revealEaseButton(laneEaseSegments(host, "position")[0]!);
|
|
370
|
+
act(() => button?.click());
|
|
371
|
+
|
|
372
|
+
expect(onSelectSegment).toHaveBeenCalledWith({
|
|
373
|
+
animationId: "position-tween",
|
|
374
|
+
percentage: 50,
|
|
375
|
+
propertyGroup: "position",
|
|
376
|
+
tweenPercentage: 50,
|
|
377
|
+
});
|
|
378
|
+
act(() => root.unmount());
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("routes a colliding Position segment to the Position animation", () => {
|
|
382
|
+
const onSelectSegment = vi.fn();
|
|
383
|
+
const animations = [
|
|
384
|
+
POSITION_SEGMENT_ANIMATION,
|
|
385
|
+
animation("visual-tween", "visual", [
|
|
386
|
+
{ percentage: 0, properties: { opacity: 0 } },
|
|
387
|
+
{ percentage: 50, properties: { opacity: 0.5 } },
|
|
388
|
+
]),
|
|
389
|
+
];
|
|
390
|
+
const { host, root } = renderPropertyLanes({ animations, onSelectSegment });
|
|
391
|
+
|
|
392
|
+
const button = revealEaseButton(laneEaseSegments(host, "position")[0]!);
|
|
393
|
+
act(() => button?.click());
|
|
394
|
+
|
|
395
|
+
expect(onSelectSegment.mock.calls[0]?.[0]).toMatchObject({
|
|
396
|
+
animationId: "position-tween",
|
|
397
|
+
propertyGroup: "position",
|
|
398
|
+
});
|
|
399
|
+
act(() => root.unmount());
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("keeps the collapsed TimelineClipDiamonds positions and callback contract unchanged", () => {
|
|
403
|
+
const onClickKeyframe = vi.fn();
|
|
404
|
+
const COLLAPSED_IDENTITY = { animationId: "position-tween", propertyGroup: "position" };
|
|
405
|
+
const COLLAPSED_TARGET = { ...COLLAPSED_IDENTITY, percentage: 50, tweenPercentage: 50 };
|
|
406
|
+
const host = document.createElement("div");
|
|
407
|
+
document.body.append(host);
|
|
408
|
+
const root = createRoot(host);
|
|
409
|
+
act(() => {
|
|
410
|
+
root.render(
|
|
411
|
+
<TimelineClipDiamonds
|
|
412
|
+
keyframesData={{
|
|
413
|
+
format: "percentage",
|
|
414
|
+
keyframes: [
|
|
415
|
+
{ percentage: 0, ...COLLAPSED_IDENTITY, tweenPercentage: 0, properties: { x: 0 } },
|
|
416
|
+
{
|
|
417
|
+
percentage: 50,
|
|
418
|
+
...COLLAPSED_IDENTITY,
|
|
419
|
+
tweenPercentage: 50,
|
|
420
|
+
properties: { x: 100 },
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
}}
|
|
424
|
+
clipWidthPx={200}
|
|
425
|
+
clipHeightPx={48}
|
|
426
|
+
accentColor="#4ba3d2"
|
|
427
|
+
isSelected
|
|
428
|
+
currentPercentage={-10}
|
|
429
|
+
elementId="clip-1"
|
|
430
|
+
selectedKeyframes={new Set([timelineKeyframeSelectionKey("clip-1", COLLAPSED_TARGET)])}
|
|
431
|
+
onClickKeyframe={onClickKeyframe}
|
|
432
|
+
/>,
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
const diamonds = Array.from(host.querySelectorAll<HTMLButtonElement>("button"));
|
|
436
|
+
|
|
437
|
+
// Unified keyframe-diamond size (LANE_H·ratio ≈ 22px, half 11) on collapsed
|
|
438
|
+
// clips too, so 0% sits at -11px regardless of clip-bar height.
|
|
439
|
+
expect(diamonds.map((diamond) => diamond.style.left)).toEqual(["-11px", "89px"]);
|
|
440
|
+
expect(diamonds[1]?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2");
|
|
441
|
+
act(() => {
|
|
442
|
+
diamonds[1]?.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, button: 0 }));
|
|
443
|
+
});
|
|
444
|
+
// The whole identity, not just the percentage: objectContaining on the one
|
|
445
|
+
// field passes even when the animation id / property group / tween-% the
|
|
446
|
+
// diamond-identity refactor added are dropped on the way out.
|
|
447
|
+
expect(onClickKeyframe).toHaveBeenCalledWith("clip-1", COLLAPSED_TARGET);
|
|
448
|
+
act(() => root.unmount());
|
|
449
|
+
});
|
|
450
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { useMemo, type MouseEvent as ReactMouseEvent, type RefObject } from "react";
|
|
2
|
+
import {
|
|
3
|
+
classifyPropertyGroup,
|
|
4
|
+
type GsapAnimation,
|
|
5
|
+
type PropertyGroupName,
|
|
6
|
+
} from "@hyperframes/core/gsap-parser";
|
|
7
|
+
import { toClipKeyframes } from "../../hooks/gsapShared";
|
|
8
|
+
import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
|
|
9
|
+
import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
|
|
10
|
+
import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
|
11
|
+
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
12
|
+
|
|
13
|
+
export interface TimelinePropertyLanesProps {
|
|
14
|
+
animations: readonly GsapAnimation[];
|
|
15
|
+
clipStart: number;
|
|
16
|
+
clipDuration: number;
|
|
17
|
+
clipLeftPx: number;
|
|
18
|
+
clipWidthPx: number;
|
|
19
|
+
accentColor: string;
|
|
20
|
+
isSelected: boolean;
|
|
21
|
+
currentPercentage: number;
|
|
22
|
+
elementId: string;
|
|
23
|
+
selectedKeyframes: ReadonlySet<string>;
|
|
24
|
+
onSelectSegment?: (target: TimelineKeyframeTarget) => void;
|
|
25
|
+
onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
|
26
|
+
onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
|
|
27
|
+
onContextMenuKeyframe?: (e: ReactMouseEvent, target: TimelineKeyframeTarget) => void;
|
|
28
|
+
onMoveKeyframe?: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
|
|
29
|
+
suppressClickRef?: RefObject<boolean>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hasGroupProperty(
|
|
33
|
+
properties: Record<string, number | string>,
|
|
34
|
+
group: PropertyGroupName,
|
|
35
|
+
): boolean {
|
|
36
|
+
return Object.keys(properties).some((property) => classifyPropertyGroup(property) === group);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The tween's editable keyframes: its real keyframes, or the start→end pair
|
|
40
|
+
* synthesized for a flat tween. Empty for a tween that animates nothing. */
|
|
41
|
+
function animationKeyframes(animation: GsapAnimation) {
|
|
42
|
+
return animation.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(animation)?.keyframes ?? [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A tween contributes a property lane when it has a group and at least one
|
|
46
|
+
* editable keyframe (real or synthesized). */
|
|
47
|
+
export function animationContributesLane(animation: GsapAnimation): boolean {
|
|
48
|
+
return !!animation.propertyGroup && animationKeyframes(animation).length > 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sourceGroups(animations: readonly GsapAnimation[]) {
|
|
52
|
+
const groups = new Map<PropertyGroupName, GsapAnimation[]>();
|
|
53
|
+
for (const animation of animations) {
|
|
54
|
+
if (!animation.propertyGroup || !animationContributesLane(animation)) continue;
|
|
55
|
+
const groupAnimations = groups.get(animation.propertyGroup) ?? [];
|
|
56
|
+
groupAnimations.push(animation);
|
|
57
|
+
groups.set(animation.propertyGroup, groupAnimations);
|
|
58
|
+
}
|
|
59
|
+
return groups;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolve the ease from THIS keyframe's own source tween. A lane can merge
|
|
63
|
+
* several tweens, so a shared lane-level fallback would label a segment with a
|
|
64
|
+
* different animation's ease than the one the ease editor targets (it routes
|
|
65
|
+
* by animationId). */
|
|
66
|
+
function keyframeEase(keyframe: { ease?: string }, animation: GsapAnimation): string | undefined {
|
|
67
|
+
return keyframe.ease ?? animation.keyframes?.easeEach ?? animation.ease;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One lane row per keyframe of `group`. The clip-% re-basing goes through the
|
|
72
|
+
* shared toClipKeyframes so lane rows land on the exact same percentage the
|
|
73
|
+
* keyframe cache writes: this file used to derive it inline and skipped that
|
|
74
|
+
* helper's rounding, which is the one precision every keyframe-cache writer has
|
|
75
|
+
* to agree on (selection keys embed the number).
|
|
76
|
+
*/
|
|
77
|
+
function groupKeyframes(
|
|
78
|
+
animations: readonly GsapAnimation[],
|
|
79
|
+
group: PropertyGroupName,
|
|
80
|
+
clipStart: number,
|
|
81
|
+
clipDuration: number,
|
|
82
|
+
): TimelineDiamondKeyframe[] {
|
|
83
|
+
const keyframes: TimelineDiamondKeyframe[] = [];
|
|
84
|
+
for (const animation of animations) {
|
|
85
|
+
const inGroup = animationKeyframes(animation).filter((keyframe) =>
|
|
86
|
+
hasGroupProperty(keyframe.properties, group),
|
|
87
|
+
);
|
|
88
|
+
for (const keyframe of toClipKeyframes(inGroup, animation, clipStart, clipDuration)) {
|
|
89
|
+
keyframes.push({
|
|
90
|
+
...keyframe,
|
|
91
|
+
// The LANE's group, not the tween's own classification: a mixed-property
|
|
92
|
+
// tween classifies to undefined yet still feeds every group it touches.
|
|
93
|
+
propertyGroup: group,
|
|
94
|
+
ease: keyframeEase(keyframe, animation),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return keyframes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getTimelinePropertyLanes(
|
|
102
|
+
animations: readonly GsapAnimation[],
|
|
103
|
+
clipStart: number,
|
|
104
|
+
clipDuration: number,
|
|
105
|
+
) {
|
|
106
|
+
if (clipDuration <= 0) return [];
|
|
107
|
+
return Array.from(sourceGroups(animations), ([group, groupAnimations]) => ({
|
|
108
|
+
group,
|
|
109
|
+
animations: groupAnimations,
|
|
110
|
+
keyframes: groupKeyframes(groupAnimations, group, clipStart, clipDuration),
|
|
111
|
+
})).filter((lane) => lane.keyframes.length > 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function TimelinePropertyLanes({
|
|
115
|
+
animations,
|
|
116
|
+
clipStart,
|
|
117
|
+
clipDuration,
|
|
118
|
+
clipLeftPx,
|
|
119
|
+
clipWidthPx,
|
|
120
|
+
accentColor,
|
|
121
|
+
isSelected,
|
|
122
|
+
currentPercentage,
|
|
123
|
+
elementId,
|
|
124
|
+
selectedKeyframes,
|
|
125
|
+
onSelectSegment,
|
|
126
|
+
onClickKeyframe,
|
|
127
|
+
onShiftClickKeyframe,
|
|
128
|
+
onContextMenuKeyframe,
|
|
129
|
+
onMoveKeyframe,
|
|
130
|
+
suppressClickRef,
|
|
131
|
+
}: TimelinePropertyLanesProps) {
|
|
132
|
+
// Memoized: TimelineDiamondLane is React.memo'd, and rebuilding the lanes (and
|
|
133
|
+
// a fresh keyframesData literal per lane) on every render would re-render every
|
|
134
|
+
// diamond in every expanded clip on each playhead tick.
|
|
135
|
+
const lanes = useMemo(
|
|
136
|
+
() =>
|
|
137
|
+
clipWidthPx < 20 || clipDuration <= 0
|
|
138
|
+
? []
|
|
139
|
+
: getTimelinePropertyLanes(animations, clipStart, clipDuration),
|
|
140
|
+
[animations, clipStart, clipDuration, clipWidthPx],
|
|
141
|
+
);
|
|
142
|
+
const laneData = useMemo(
|
|
143
|
+
() =>
|
|
144
|
+
lanes.map((lane) => ({
|
|
145
|
+
...lane,
|
|
146
|
+
keyframesData: { format: "percentage" as const, keyframes: lane.keyframes },
|
|
147
|
+
})),
|
|
148
|
+
[lanes],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (laneData.length === 0) return null;
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
{laneData.map(({ group, keyframesData }, laneIndex) => (
|
|
155
|
+
<div
|
|
156
|
+
key={group}
|
|
157
|
+
role="group"
|
|
158
|
+
aria-label={`${group} keyframes`}
|
|
159
|
+
data-property-group={group}
|
|
160
|
+
data-timeline-property-lane=""
|
|
161
|
+
data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
|
|
162
|
+
className="absolute"
|
|
163
|
+
style={{
|
|
164
|
+
left: clipLeftPx,
|
|
165
|
+
top: getTimelineLaneTop(laneIndex),
|
|
166
|
+
width: clipWidthPx,
|
|
167
|
+
height: LANE_H,
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<TimelineDiamondLane
|
|
171
|
+
keyframesData={keyframesData}
|
|
172
|
+
clipWidthPx={clipWidthPx}
|
|
173
|
+
clipHeightPx={LANE_H}
|
|
174
|
+
accentColor={accentColor}
|
|
175
|
+
isSelected={isSelected}
|
|
176
|
+
currentPercentage={currentPercentage}
|
|
177
|
+
elementId={elementId}
|
|
178
|
+
selectedKeyframes={selectedKeyframes}
|
|
179
|
+
onSelectSegment={onSelectSegment}
|
|
180
|
+
onClickKeyframe={onClickKeyframe}
|
|
181
|
+
onShiftClickKeyframe={onShiftClickKeyframe}
|
|
182
|
+
onContextMenuKeyframe={onContextMenuKeyframe}
|
|
183
|
+
onMoveKeyframe={onMoveKeyframe}
|
|
184
|
+
suppressClickRef={suppressClickRef}
|
|
185
|
+
groupAware
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
))}
|
|
189
|
+
</>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useCallback, useState, type MouseEvent, type PointerEvent, type RefObject } from "react";
|
|
2
|
+
import { getTimelineContentXFromClient } from "./timelineLayout";
|
|
3
|
+
|
|
4
|
+
interface TimelineRazorInteractionOptions {
|
|
5
|
+
active: boolean;
|
|
6
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
7
|
+
contentOrigin: number;
|
|
8
|
+
pixelsPerSecond: number;
|
|
9
|
+
onSplitAll?: (time: number) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useTimelineRazorInteraction({
|
|
13
|
+
active,
|
|
14
|
+
scrollRef,
|
|
15
|
+
contentOrigin,
|
|
16
|
+
pixelsPerSecond,
|
|
17
|
+
onSplitAll,
|
|
18
|
+
}: TimelineRazorInteractionOptions) {
|
|
19
|
+
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
|
|
20
|
+
|
|
21
|
+
const updateRazorGuide = useCallback(
|
|
22
|
+
(event: MouseEvent<HTMLDivElement>) => {
|
|
23
|
+
const scroll = scrollRef.current;
|
|
24
|
+
if (!active || !scroll) return;
|
|
25
|
+
const rect = scroll.getBoundingClientRect();
|
|
26
|
+
setRazorGuideX(event.clientX - rect.left + scroll.scrollLeft);
|
|
27
|
+
},
|
|
28
|
+
[active, scrollRef],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const clearRazorGuide = useCallback(() => setRazorGuideX(null), []);
|
|
32
|
+
|
|
33
|
+
const splitAllAtPointer = useCallback(
|
|
34
|
+
(event: PointerEvent<HTMLDivElement>) => {
|
|
35
|
+
const scroll = scrollRef.current;
|
|
36
|
+
if (!active || !event.shiftKey || event.button !== 0 || !scroll) return false;
|
|
37
|
+
const rect = scroll.getBoundingClientRect();
|
|
38
|
+
const x = getTimelineContentXFromClient({
|
|
39
|
+
clientX: event.clientX,
|
|
40
|
+
rectLeft: rect.left,
|
|
41
|
+
scrollLeft: scroll.scrollLeft,
|
|
42
|
+
contentOrigin,
|
|
43
|
+
});
|
|
44
|
+
onSplitAll?.(Math.max(0, x / pixelsPerSecond));
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
47
|
+
[active, contentOrigin, onSplitAll, pixelsPerSecond, scrollRef],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function TimelineRazorGuide({ x }: { x: number }) {
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className="absolute top-0 bottom-0 pointer-events-none z-10"
|
|
57
|
+
style={{ left: x, width: 1, background: "rgba(239,68,68,0.7)" }}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|