@hyperframes/studio 0.7.78 → 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-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
- package/dist/assets/{index-r1tKKlU7.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 +1891 -1522
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +7 -5
- package/src/components/TimelineToolbar.tsx +3 -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/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +58 -20
- package/src/components/editor/PropertyPanel.tsx +7 -7
- 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/keyframeRetime.test.ts +42 -0
- package/src/components/editor/keyframeRetime.ts +4 -1
- package/src/components/editor/motionPathSelection.test.ts +65 -0
- package/src/components/editor/motionPathSelection.ts +13 -3
- package/src/components/editor/propertyPanelColor.test.tsx +181 -12
- package/src/components/editor/propertyPanelColor.tsx +32 -26
- 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/propertyPanelPrimitives.tsx +3 -1
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/nle/TimelineResizeDivider.tsx +9 -4
- package/src/components/pointerTargetSize.test.tsx +65 -0
- package/src/components/sidebar/CompositionsTab.tsx +4 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
- package/src/hooks/gsapKeyframeCommit.ts +11 -2
- package/src/hooks/gsapResizeIntercept.ts +2 -2
- package/src/hooks/gsapRuntimeBridge.ts +3 -2
- package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
- package/src/hooks/gsapShared.test.ts +144 -0
- package/src/hooks/gsapShared.ts +264 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +9 -83
- package/src/hooks/newTweenTarget.test.ts +364 -0
- package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
- package/src/hooks/timelineTrackVisibility.test.ts +61 -1
- package/src/hooks/timelineTrackVisibility.ts +11 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useEnableKeyframes.test.ts +44 -0
- package/src/hooks/useEnableKeyframes.ts +18 -5
- package/src/hooks/useGestureCommit.ts +21 -4
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapTweenCache.test.ts +11 -3
- package/src/hooks/useGsapTweenCache.ts +44 -45
- 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/LayerDisclosureRow.tsx +4 -1
- package/src/player/components/ShortcutsPanel.test.tsx +158 -0
- package/src/player/components/ShortcutsPanel.tsx +11 -15
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +2 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
- package/src/player/components/TimelineLanes.test.tsx +281 -0
- package/src/player/components/TimelineLanes.tsx +43 -97
- package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
- package/src/player/components/TimelinePropertyLanes.tsx +80 -11
- package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
- package/src/player/components/TimelineTrackHeader.tsx +52 -22
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/timelineLaneProps.ts +86 -0
- package/src/player/components/timelineTrackDisplay.test.ts +35 -0
- package/src/player/components/timelineTrackDisplay.ts +38 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/components/useTimelineRangeSelection.ts +5 -0
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
- package/src/player/components/useTimelineTrackLayout.ts +4 -4
- package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
- package/src/player/store/keyframeSlice.ts +15 -5
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- package/dist/assets/index-DpkO2Hvw.css +0 -1
|
@@ -60,12 +60,14 @@ const OPACITY = animation("opacity-tween", "visual", [
|
|
|
60
60
|
]);
|
|
61
61
|
|
|
62
62
|
interface RenderHeaderOptions {
|
|
63
|
+
keyframeClip?: TimelineElement;
|
|
63
64
|
animations?: GsapAnimation[];
|
|
64
65
|
clipCount?: number;
|
|
65
66
|
currentTime?: number;
|
|
66
67
|
expanded?: boolean;
|
|
67
68
|
onSeek?: (time: number) => void;
|
|
68
69
|
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
70
|
+
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
function renderHeader(options: RenderHeaderOptions = {}): {
|
|
@@ -80,10 +82,14 @@ function renderHeader(options: RenderHeaderOptions = {}): {
|
|
|
80
82
|
act(() => {
|
|
81
83
|
root.render(
|
|
82
84
|
<TimelineTrackHeader
|
|
83
|
-
|
|
85
|
+
// A real fractional z-order sort key, so a label built from it would
|
|
86
|
+
// read out "track 0.16666666666666666".
|
|
87
|
+
trackNumber={1 / 6}
|
|
88
|
+
trackDisplayNumber={1}
|
|
84
89
|
trackLabel="Hero card"
|
|
90
|
+
lanesId="timeline-lanes-track-0"
|
|
85
91
|
contentOrigin={LABEL_COL_W}
|
|
86
|
-
keyframeClip={ELEMENT}
|
|
92
|
+
keyframeClip={next.keyframeClip ?? ELEMENT}
|
|
87
93
|
clipCount={next.clipCount ?? 1}
|
|
88
94
|
isExpanded={next.expanded !== false}
|
|
89
95
|
animations={next.animations ?? [POSITION, OPACITY]}
|
|
@@ -92,7 +98,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
|
|
|
92
98
|
isAudioTrack={false}
|
|
93
99
|
theme={defaultTimelineTheme}
|
|
94
100
|
onToggleClipExpanded={vi.fn()}
|
|
95
|
-
onToggleTrackHidden={vi.fn()}
|
|
101
|
+
onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
|
|
96
102
|
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
|
|
97
103
|
onSeek={next.onSeek}
|
|
98
104
|
/>,
|
|
@@ -110,6 +116,53 @@ function click(host: HTMLElement, label: string) {
|
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
describe("TimelineTrackHeader", () => {
|
|
119
|
+
// An expanded sub-composition child sits on the MASTER timeline at a
|
|
120
|
+
// host-absolute start, but its tweens are parsed from its own file and are
|
|
121
|
+
// local to it. Feeding the raw start straight into the clip-% math put every
|
|
122
|
+
// lane keyframe far outside the clip.
|
|
123
|
+
it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
|
|
124
|
+
const child: TimelineElement = {
|
|
125
|
+
id: "pill",
|
|
126
|
+
tag: "div",
|
|
127
|
+
start: 16.5,
|
|
128
|
+
duration: 2,
|
|
129
|
+
track: 0,
|
|
130
|
+
expandedParentStart: 16,
|
|
131
|
+
sourceFile: "scene.html",
|
|
132
|
+
};
|
|
133
|
+
const local: GsapAnimation = {
|
|
134
|
+
id: "pill-tween",
|
|
135
|
+
targetSelector: "#pill",
|
|
136
|
+
method: "to",
|
|
137
|
+
position: 0.5,
|
|
138
|
+
resolvedStart: 0.5,
|
|
139
|
+
duration: 2,
|
|
140
|
+
properties: {},
|
|
141
|
+
propertyGroup: "position",
|
|
142
|
+
keyframes: {
|
|
143
|
+
format: "percentage",
|
|
144
|
+
keyframes: [
|
|
145
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
146
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
// Playhead at the clip's midpoint (master time), so the 100% keyframe is
|
|
151
|
+
// ahead of it. On the raw host-absolute basis every keyframe rebased to a
|
|
152
|
+
// large negative percentage and nothing was ever ahead of the playhead.
|
|
153
|
+
const view = renderHeader({
|
|
154
|
+
keyframeClip: child,
|
|
155
|
+
animations: [local],
|
|
156
|
+
currentTime: 17.5,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(
|
|
160
|
+
view.host.querySelector<HTMLButtonElement>('button[aria-label="Next Position keyframe"]')
|
|
161
|
+
?.disabled,
|
|
162
|
+
).toBe(false);
|
|
163
|
+
act(() => view.root.unmount());
|
|
164
|
+
});
|
|
165
|
+
|
|
113
166
|
// The header shows one clip's lanes, so how many clips the track holds is
|
|
114
167
|
// otherwise invisible from the label column. A single-clip track stays silent.
|
|
115
168
|
it("shows the track's clip count only once the track holds more than one clip", () => {
|
|
@@ -125,10 +178,27 @@ describe("TimelineTrackHeader", () => {
|
|
|
125
178
|
// in every disclosure state — a hover-gated eye is unusable by keyboard.
|
|
126
179
|
it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => {
|
|
127
180
|
const view = renderHeader({ expanded: true });
|
|
128
|
-
expect(view.host.querySelector('button[aria-label="Hide track
|
|
181
|
+
expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
|
|
129
182
|
|
|
130
183
|
view.rerender({ expanded: false });
|
|
131
|
-
expect(view.host.querySelector('button[aria-label="Hide track
|
|
184
|
+
expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
|
|
185
|
+
act(() => view.root.unmount());
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// trackNumber is a fractional z-order sort key, so building the label from it
|
|
189
|
+
// made screen readers announce "Hide track 0.16666666666666666". The display
|
|
190
|
+
// number is label-only; the toggle still routes by the real key.
|
|
191
|
+
it("announces the display track number but toggles with the real fractional key", () => {
|
|
192
|
+
const onToggleTrackHidden = vi.fn();
|
|
193
|
+
const view = renderHeader({ onToggleTrackHidden });
|
|
194
|
+
const eye = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 1"]');
|
|
195
|
+
|
|
196
|
+
expect(eye).not.toBeNull();
|
|
197
|
+
expect(eye?.title).toBe("Hide track 1");
|
|
198
|
+
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
|
|
199
|
+
|
|
200
|
+
act(() => eye?.click());
|
|
201
|
+
expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true);
|
|
132
202
|
act(() => view.root.unmount());
|
|
133
203
|
});
|
|
134
204
|
|
|
@@ -4,6 +4,7 @@ import { Music } from "../../icons/SystemIcons";
|
|
|
4
4
|
import type { TimelineElement } from "../store/playerStore";
|
|
5
5
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
6
6
|
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
7
|
+
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
7
8
|
import { LayerDisclosureRow } from "./LayerDisclosureRow";
|
|
8
9
|
import { TrackClipCount } from "./TrackClipCount";
|
|
9
10
|
import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
|
|
@@ -14,10 +15,21 @@ import {
|
|
|
14
15
|
type TimelinePropertyLane,
|
|
15
16
|
} from "./trackHeaderLaneState";
|
|
16
17
|
import { valueReadout } from "./trackHeaderLaneValues";
|
|
18
|
+
import { trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
17
19
|
|
|
18
20
|
interface TimelineTrackHeaderProps {
|
|
21
|
+
/** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
|
|
22
|
+
* never shown or announced. */
|
|
19
23
|
trackNumber: number;
|
|
24
|
+
/** The track's 1-based position in the rendered order: the only number safe
|
|
25
|
+
* to put in a label. Announcing `trackNumber` read out "track
|
|
26
|
+
* 0.16666666666666666". Null when the key has no row, which drops the number
|
|
27
|
+
* from the label rather than inventing one (see trackDisplayNumber). */
|
|
28
|
+
trackDisplayNumber: number | null;
|
|
20
29
|
trackLabel: string;
|
|
30
|
+
/** Id of the canvas-side lanes element the disclosure caret expands. Minted by
|
|
31
|
+
* TimelineLanes, which is the one place that sees both subtrees. */
|
|
32
|
+
lanesId: string;
|
|
21
33
|
contentOrigin: number;
|
|
22
34
|
/** The track's active keyframe clip (selected, else primary) — the one whose
|
|
23
35
|
* disclosure + property rows this header shows, whether expanded or not. */
|
|
@@ -39,16 +51,21 @@ interface TimelineTrackHeaderProps {
|
|
|
39
51
|
function VisibilityButton({
|
|
40
52
|
hidden,
|
|
41
53
|
trackNumber,
|
|
54
|
+
trackDisplayNumber,
|
|
42
55
|
visible,
|
|
43
56
|
onToggle,
|
|
44
57
|
}: {
|
|
45
58
|
hidden: boolean;
|
|
46
59
|
trackNumber: number;
|
|
60
|
+
trackDisplayNumber: number | null;
|
|
47
61
|
visible: boolean;
|
|
48
62
|
onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
49
63
|
}) {
|
|
50
64
|
if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
|
|
51
|
-
|
|
65
|
+
// Display number in the text, real key in the callback. The two must not be
|
|
66
|
+
// conflated in either direction.
|
|
67
|
+
const suffix = trackDisplaySuffix(trackDisplayNumber);
|
|
68
|
+
const label = hidden ? `Show track${suffix}` : `Hide track${suffix}`;
|
|
52
69
|
return (
|
|
53
70
|
<button
|
|
54
71
|
type="button"
|
|
@@ -76,6 +93,7 @@ function VisibilityButton({
|
|
|
76
93
|
// count, eye. Not deprecated — it is the live path for every track without lanes.
|
|
77
94
|
function PlainTrackHeader({
|
|
78
95
|
trackNumber,
|
|
96
|
+
trackDisplayNumber,
|
|
79
97
|
trackLabel,
|
|
80
98
|
clipCount,
|
|
81
99
|
showTrackLabel,
|
|
@@ -85,6 +103,7 @@ function PlainTrackHeader({
|
|
|
85
103
|
}: Pick<
|
|
86
104
|
TimelineTrackHeaderProps,
|
|
87
105
|
| "trackNumber"
|
|
106
|
+
| "trackDisplayNumber"
|
|
88
107
|
| "trackLabel"
|
|
89
108
|
| "clipCount"
|
|
90
109
|
| "isTrackHidden"
|
|
@@ -105,6 +124,7 @@ function PlainTrackHeader({
|
|
|
105
124
|
<VisibilityButton
|
|
106
125
|
hidden={isTrackHidden}
|
|
107
126
|
trackNumber={trackNumber}
|
|
127
|
+
trackDisplayNumber={trackDisplayNumber}
|
|
108
128
|
visible
|
|
109
129
|
onToggle={onToggleTrackHidden}
|
|
110
130
|
/>
|
|
@@ -262,7 +282,9 @@ function PropertyGroupHeaderRow({
|
|
|
262
282
|
|
|
263
283
|
export function TimelineTrackHeader({
|
|
264
284
|
trackNumber,
|
|
285
|
+
trackDisplayNumber,
|
|
265
286
|
trackLabel,
|
|
287
|
+
lanesId,
|
|
266
288
|
contentOrigin,
|
|
267
289
|
keyframeClip,
|
|
268
290
|
clipCount,
|
|
@@ -281,13 +303,14 @@ export function TimelineTrackHeader({
|
|
|
281
303
|
? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
|
|
282
304
|
: 0;
|
|
283
305
|
const lanes = keyframeClip
|
|
284
|
-
?
|
|
306
|
+
? // clipTimingStart, not the raw start: an expanded sub-comp child's start is
|
|
307
|
+
// host-absolute while its tweens are local to its own file.
|
|
308
|
+
getTimelinePropertyLanes(animations, clipTimingStart(keyframeClip), keyframeClip.duration)
|
|
285
309
|
: [];
|
|
286
310
|
// Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
|
|
287
311
|
// owns the gutter past it, so a 0% diamond isn't clipped by this panel).
|
|
288
312
|
const showTrackLabel = contentOrigin >= LABEL_COL_W;
|
|
289
313
|
const isKeyframeLayer = !!keyframeClip && lanes.length > 0;
|
|
290
|
-
const lanesId = `timeline-lanes-track-${trackNumber}`;
|
|
291
314
|
|
|
292
315
|
return (
|
|
293
316
|
<div
|
|
@@ -307,6 +330,7 @@ export function TimelineTrackHeader({
|
|
|
307
330
|
{!keyframeClip || lanes.length === 0 ? (
|
|
308
331
|
<PlainTrackHeader
|
|
309
332
|
trackNumber={trackNumber}
|
|
333
|
+
trackDisplayNumber={trackDisplayNumber}
|
|
310
334
|
trackLabel={trackLabel}
|
|
311
335
|
clipCount={clipCount}
|
|
312
336
|
showTrackLabel={showTrackLabel}
|
|
@@ -333,29 +357,35 @@ export function TimelineTrackHeader({
|
|
|
333
357
|
<VisibilityButton
|
|
334
358
|
hidden={isTrackHidden}
|
|
335
359
|
trackNumber={trackNumber}
|
|
360
|
+
trackDisplayNumber={trackDisplayNumber}
|
|
336
361
|
visible
|
|
337
362
|
onToggle={onToggleTrackHidden}
|
|
338
363
|
/>
|
|
339
364
|
</LayerDisclosureRow>
|
|
340
|
-
{/*
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
365
|
+
{/* The caret expands TWO disjoint subtrees: these label-column rows,
|
|
366
|
+
which carry the per-lane keyframe controls, and the diamond lanes
|
|
367
|
+
on the canvas. `lanesId` names the canvas lanes (rendered by
|
|
368
|
+
TimelineLanes), because that is what a sighted user watches appear
|
|
369
|
+
and what following the reference has to land on. These rows are not
|
|
370
|
+
empty and are not the target; they are absolutely positioned inside
|
|
371
|
+
the sticky column, which is what made a wrapper HERE compute to
|
|
372
|
+
0x0 and hold no diamonds. */}
|
|
373
|
+
{isExpanded &&
|
|
374
|
+
lanes.map((lane, laneIndex) => (
|
|
375
|
+
<PropertyGroupHeaderRow
|
|
376
|
+
key={lane.group}
|
|
377
|
+
lane={lane}
|
|
378
|
+
laneIndex={laneIndex}
|
|
379
|
+
isLastLane={laneIndex === lanes.length - 1}
|
|
380
|
+
expandedElement={keyframeClip}
|
|
381
|
+
currentTime={currentTime}
|
|
382
|
+
clipPercentage={clipPercentage}
|
|
383
|
+
gutterBackground={theme.gutterBackground}
|
|
384
|
+
columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
|
|
385
|
+
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
|
|
386
|
+
onSeek={onSeek}
|
|
387
|
+
/>
|
|
388
|
+
))}
|
|
359
389
|
</>
|
|
360
390
|
)}
|
|
361
391
|
</div>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* keyframe-identity helper live here; the rendering lives there.
|
|
5
5
|
*/
|
|
6
6
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
7
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
7
8
|
|
|
8
9
|
export interface TimelineDiamondKeyframe {
|
|
9
10
|
percentage: number;
|
|
@@ -13,9 +14,8 @@ export interface TimelineDiamondKeyframe {
|
|
|
13
14
|
animationId?: string;
|
|
14
15
|
properties: Record<string, number | string>;
|
|
15
16
|
ease?: string;
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
easeAmbiguous?: boolean;
|
|
17
|
+
/** Source animation/keyframe targets that collide at this clip percentage. */
|
|
18
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
interface KeyframeCacheEntry {
|
|
@@ -116,5 +116,6 @@ export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyfr
|
|
|
116
116
|
tweenPercentage: keyframe.tweenPercentage,
|
|
117
117
|
propertyGroup: keyframe.propertyGroup,
|
|
118
118
|
animationId: keyframe.animationId,
|
|
119
|
+
collidingAnimationTargets: keyframe.collidingAnimationTargets,
|
|
119
120
|
};
|
|
120
121
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
2
|
+
|
|
1
3
|
export interface TimelineKeyframeTarget {
|
|
2
4
|
percentage: number;
|
|
3
5
|
tweenPercentage?: number;
|
|
4
6
|
propertyGroup?: string;
|
|
5
7
|
animationId?: string;
|
|
8
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
6
9
|
}
|
|
7
10
|
|
|
8
11
|
/**
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
4
|
+
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
|
5
|
+
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
6
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
7
|
+
import type { TrackVisualStyle } from "./timelineIcons";
|
|
8
|
+
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
|
|
12
|
+
* the lane renderer ({@link import("./TimelineLanes")}). TimelineCanvas passes
|
|
13
|
+
* these straight through via spread, so they are declared once here and both
|
|
14
|
+
* prop types compose from this base — no duplicated prop list. Kept in its own
|
|
15
|
+
* module so neither side has to import the other just to name the contract.
|
|
16
|
+
*/
|
|
17
|
+
export interface TimelineLaneBaseProps {
|
|
18
|
+
pps: number;
|
|
19
|
+
contentOrigin: number;
|
|
20
|
+
contentGutter: number;
|
|
21
|
+
trackContentWidth: number;
|
|
22
|
+
theme: TimelineTheme;
|
|
23
|
+
displayTrackOrder: number[];
|
|
24
|
+
rowHeights: readonly number[];
|
|
25
|
+
trackOrder: number[];
|
|
26
|
+
tracks: [number, TimelineElement[]][];
|
|
27
|
+
trackStyles: Map<number, TrackVisualStyle>;
|
|
28
|
+
laneCounts: ReadonlyMap<string, number>;
|
|
29
|
+
selectedElementId: string | null;
|
|
30
|
+
selectedElementIds: Set<string>;
|
|
31
|
+
hoveredClip: string | null;
|
|
32
|
+
draggedClip: DraggedClipState | null;
|
|
33
|
+
blockedClipRef: React.RefObject<BlockedClipState | null>;
|
|
34
|
+
suppressClickRef: React.RefObject<boolean>;
|
|
35
|
+
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
36
|
+
renderClipContent?: (
|
|
37
|
+
element: TimelineElement,
|
|
38
|
+
style: { clip: string; label: string },
|
|
39
|
+
) => ReactNode;
|
|
40
|
+
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
41
|
+
onDrillDown?: (element: TimelineElement) => void;
|
|
42
|
+
onSelectElement?: (element: TimelineElement | null) => void;
|
|
43
|
+
setHoveredClip: (key: string | null) => void;
|
|
44
|
+
setShowPopover: (v: boolean) => void;
|
|
45
|
+
setRangeSelection: (v: null) => void;
|
|
46
|
+
setResizingClip: (v: ResizingClipState | null) => void;
|
|
47
|
+
setDraggedClip: (v: DraggedClipState | null) => void;
|
|
48
|
+
setSelectedElementId: (id: string | null) => void;
|
|
49
|
+
syncClipDragAutoScroll: (x: number, y: number) => void;
|
|
50
|
+
shiftClickClipRef: React.RefObject<{
|
|
51
|
+
element: TimelineElement;
|
|
52
|
+
anchorX: number;
|
|
53
|
+
anchorY: number;
|
|
54
|
+
} | null>;
|
|
55
|
+
getPreviewElement: (element: TimelineElement) => TimelineElement;
|
|
56
|
+
getTrackStyle: (tag: string) => TrackVisualStyle;
|
|
57
|
+
keyframeCache?: Map<string, KeyframeCacheEntry>;
|
|
58
|
+
gsapAnimations: Map<string, GsapAnimation[]>;
|
|
59
|
+
selectedKeyframes: Set<string>;
|
|
60
|
+
currentTime: number;
|
|
61
|
+
onSeek?: (time: number) => void;
|
|
62
|
+
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
|
63
|
+
onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
|
|
64
|
+
onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
|
65
|
+
onContextMenuKeyframe?: (
|
|
66
|
+
e: React.MouseEvent,
|
|
67
|
+
elementId: string,
|
|
68
|
+
target: TimelineKeyframeTarget,
|
|
69
|
+
) => void;
|
|
70
|
+
onMoveKeyframe?: (
|
|
71
|
+
elementId: string,
|
|
72
|
+
keyframe: TimelineKeyframeTarget,
|
|
73
|
+
toClipPercentage: number,
|
|
74
|
+
propertyGroup?: string,
|
|
75
|
+
tweenPercentage?: number,
|
|
76
|
+
animationId?: string,
|
|
77
|
+
) => Promise<boolean>;
|
|
78
|
+
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Right-click on EMPTY lane space (not on a clip — those preventDefault
|
|
81
|
+
* before this fires — not the gutter/ruler, not below the lanes). `time` is
|
|
82
|
+
* the timeline time (seconds) under the pointer on that lane.
|
|
83
|
+
*/
|
|
84
|
+
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
|
85
|
+
beatAnalysis?: MusicBeatAnalysis | null;
|
|
86
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { timelineTrackOrder, trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
3
|
+
|
|
4
|
+
describe("timelineTrackOrder", () => {
|
|
5
|
+
it("is the ascending distinct key order, fractional sub-comp keys included", () => {
|
|
6
|
+
const elements = [{ track: 1 }, { track: 0 }, { track: 1 / 6 }, { track: 1 }];
|
|
7
|
+
|
|
8
|
+
expect(timelineTrackOrder(elements)).toEqual([0, 1 / 6, 1]);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("trackDisplayNumber", () => {
|
|
13
|
+
it("is the key's 1-based row, not the fractional key itself", () => {
|
|
14
|
+
const order = timelineTrackOrder([{ track: 0 }, { track: 1 / 6 }, { track: 1 }]);
|
|
15
|
+
|
|
16
|
+
expect(trackDisplayNumber(order, 1 / 6)).toBe(2);
|
|
17
|
+
expect(trackDisplayNumber(order, 1)).toBe(3);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("is null for a key with no row rather than a plausible-looking one", () => {
|
|
21
|
+
// The old end-row fallback announced "track 4" for a track the user cannot
|
|
22
|
+
// see at row 4, and nothing upstream could tell that apart from a real row.
|
|
23
|
+
expect(trackDisplayNumber([0, 1, 2], 7)).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("trackDisplaySuffix", () => {
|
|
28
|
+
it("names the row when there is one", () => {
|
|
29
|
+
expect(`Hide track${trackDisplaySuffix(3)}`).toBe("Hide track 3");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("drops the number entirely when there is no row", () => {
|
|
33
|
+
expect(`Hide track${trackDisplaySuffix(null)}`).toBe("Hide track");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one owner of "what track number does the user see".
|
|
3
|
+
*
|
|
4
|
+
* `TimelineElement.track` is a z-order SORT key, not a row number: an expanded
|
|
5
|
+
* sub-composition child gets a synthesized fractional key (`host.track + n /
|
|
6
|
+
* (siblings + 2)`, see `useExpandedTimelineElements`), so putting it in a string
|
|
7
|
+
* announces "Hide track 0.16666666666666666". Every user-visible track number,
|
|
8
|
+
* whether it is rendered by a component or baked into an undo-history label,
|
|
9
|
+
* routes through here; the raw key stays in callbacks and lookups only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Ascending distinct track keys, the row order the timeline renders in. */
|
|
13
|
+
export function timelineTrackOrder(elements: readonly { track: number }[]): number[] {
|
|
14
|
+
return [...new Set(elements.map((element) => element.track))].sort((a, b) => a - b);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A track key's 1-based display row, or null when the key is not in
|
|
19
|
+
* `trackOrder` at all.
|
|
20
|
+
*
|
|
21
|
+
* Both callers build `trackOrder` from the same elements the key came from, so
|
|
22
|
+
* null is unreachable by construction today. It is null rather than a number
|
|
23
|
+
* because the only numbers available to return (the end row, the last row) are
|
|
24
|
+
* indistinguishable from a real answer: a label would announce a row the user
|
|
25
|
+
* can see is wrong, and nothing upstream would ever learn it had guessed.
|
|
26
|
+
*/
|
|
27
|
+
export function trackDisplayNumber(trackOrder: readonly number[], track: number): number | null {
|
|
28
|
+
const row = trackOrder.indexOf(track);
|
|
29
|
+
return row < 0 ? null : row + 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `" 3"` in `Hide track 3`, empty when there is no display row to name.
|
|
34
|
+
* Announcing "Hide track" is thin; announcing an invented row is wrong.
|
|
35
|
+
*/
|
|
36
|
+
export function trackDisplaySuffix(displayNumber: number | null): string {
|
|
37
|
+
return displayNumber === null ? "" : ` ${displayNumber}`;
|
|
38
|
+
}
|
|
@@ -36,72 +36,92 @@ const FLAT_TWEEN_TARGET: TimelineKeyframeTarget = {
|
|
|
36
36
|
animationId: "position-tween",
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
const COLLIDING_TARGET: TimelineKeyframeTarget = {
|
|
40
|
+
...FLAT_TWEEN_TARGET,
|
|
41
|
+
collidingAnimationTargets: [
|
|
42
|
+
{ animationId: "position-tween", tweenPercentage: 100 },
|
|
43
|
+
{ animationId: "scale-tween", tweenPercentage: 75 },
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
|
|
39
47
|
afterEach(() => {
|
|
40
48
|
document.body.innerHTML = "";
|
|
41
49
|
trackStudioSegmentEaseEdit.mockClear();
|
|
42
50
|
usePlayerStore.setState({ focusedEaseSegment: null });
|
|
43
51
|
});
|
|
44
52
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Mount the hook on its own and hand back the handlers it returned, with the
|
|
55
|
+
* options every test shares already filled in. Each test overrides only the
|
|
56
|
+
* inputs its assertion is about.
|
|
57
|
+
*/
|
|
58
|
+
function mountHandlers(options: Partial<Parameters<typeof useTimelineKeyframeHandlers>[0]> = {}) {
|
|
59
|
+
const handlers: Partial<ReturnType<typeof useTimelineKeyframeHandlers>> = {};
|
|
60
|
+
|
|
61
|
+
function Harness() {
|
|
62
|
+
Object.assign(
|
|
63
|
+
handlers,
|
|
64
|
+
useTimelineKeyframeHandlers({
|
|
51
65
|
expandedElements: [ELEMENT],
|
|
52
66
|
keyframeCache: new Map(),
|
|
53
67
|
setSelectedElementId: vi.fn(),
|
|
54
68
|
setKfContextMenu: vi.fn(),
|
|
55
69
|
toggleSelectedKeyframe: vi.fn(),
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
70
|
+
...options,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { root: mountReactHarness(<Harness />), handlers };
|
|
77
|
+
}
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
describe("useTimelineKeyframeHandlers", () => {
|
|
80
|
+
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
|
|
81
|
+
const { root, handlers } = mountHandlers();
|
|
82
|
+
act(() => handlers.onSelectSegment?.(ELEMENT.id, TARGET));
|
|
62
83
|
|
|
63
84
|
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledOnce();
|
|
64
85
|
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "open" });
|
|
65
86
|
act(() => root.unmount());
|
|
66
87
|
});
|
|
67
88
|
|
|
89
|
+
it("focuses a merged segment with its colliding animation targets", () => {
|
|
90
|
+
const { root, handlers } = mountHandlers();
|
|
91
|
+
act(() => handlers.onSelectSegment?.(ELEMENT.id, COLLIDING_TARGET));
|
|
92
|
+
|
|
93
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
|
|
94
|
+
animationId: "position-tween",
|
|
95
|
+
collidingAnimationTargets: [
|
|
96
|
+
{ animationId: "position-tween", tweenPercentage: 100 },
|
|
97
|
+
{ animationId: "scale-tween", tweenPercentage: 75 },
|
|
98
|
+
],
|
|
99
|
+
tweenPercentage: 100,
|
|
100
|
+
elementId: ELEMENT.id,
|
|
101
|
+
});
|
|
102
|
+
act(() => root.unmount());
|
|
103
|
+
});
|
|
104
|
+
|
|
68
105
|
it("focuses a flat tween segment without seeking, while keyframe clicks still seek", () => {
|
|
69
106
|
const onSeek = vi.fn();
|
|
70
107
|
const onSelectElement = vi.fn();
|
|
71
108
|
const setSelectedElementId = vi.fn();
|
|
72
|
-
|
|
73
|
-
| ((el: TimelineElement, target: TimelineKeyframeTarget) => void)
|
|
74
|
-
| undefined;
|
|
75
|
-
let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
|
|
76
|
-
|
|
77
|
-
function Harness() {
|
|
78
|
-
({ onClickKeyframe, onSelectSegment } = useTimelineKeyframeHandlers({
|
|
79
|
-
expandedElements: [ELEMENT],
|
|
80
|
-
keyframeCache: new Map(),
|
|
81
|
-
onSelectElement,
|
|
82
|
-
onSeek,
|
|
83
|
-
setSelectedElementId,
|
|
84
|
-
setKfContextMenu: vi.fn(),
|
|
85
|
-
toggleSelectedKeyframe: vi.fn(),
|
|
86
|
-
}));
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const root = mountReactHarness(<Harness />);
|
|
109
|
+
const { root, handlers } = mountHandlers({ onSelectElement, onSeek, setSelectedElementId });
|
|
91
110
|
|
|
92
111
|
// Selecting a segment must NOT move the playhead.
|
|
93
|
-
act(() => onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
|
|
112
|
+
act(() => handlers.onSelectSegment?.(ELEMENT.id, FLAT_TWEEN_TARGET));
|
|
94
113
|
expect(onSeek).not.toHaveBeenCalled();
|
|
95
114
|
expect(usePlayerStore.getState().focusedEaseSegment).toEqual({
|
|
96
115
|
animationId: "position-tween",
|
|
97
116
|
tweenPercentage: 100,
|
|
98
117
|
elementId: ELEMENT.id,
|
|
99
118
|
});
|
|
119
|
+
expect(usePlayerStore.getState().focusedEaseSegment?.collidingAnimationTargets).toBeUndefined();
|
|
100
120
|
expect(setSelectedElementId).toHaveBeenCalledWith(ELEMENT.id);
|
|
101
121
|
expect(onSelectElement).toHaveBeenCalledWith(ELEMENT);
|
|
102
122
|
|
|
103
123
|
// Clicking the keyframe itself still seeks to it (start 1 + 50% of 2 = 2).
|
|
104
|
-
act(() => onClickKeyframe?.(ELEMENT, TARGET));
|
|
124
|
+
act(() => handlers.onClickKeyframe?.(ELEMENT, TARGET));
|
|
105
125
|
expect(onSeek).toHaveBeenCalledExactlyOnceWith(2);
|
|
106
126
|
act(() => root.unmount());
|
|
107
127
|
});
|
|
@@ -65,6 +65,7 @@ export function useTimelineKeyframeHandlers({
|
|
|
65
65
|
if (target.animationId !== undefined && target.tweenPercentage !== undefined) {
|
|
66
66
|
usePlayerStore.getState().setFocusedEaseSegment({
|
|
67
67
|
animationId: target.animationId,
|
|
68
|
+
collidingAnimationTargets: target.collidingAnimationTargets,
|
|
68
69
|
tweenPercentage: target.tweenPercentage,
|
|
69
70
|
elementId: elId,
|
|
70
71
|
});
|
|
@@ -268,6 +268,11 @@ export function useTimelineRangeSelection({
|
|
|
268
268
|
if (!point || !scrollRect || isTimelineRulerPress(e.clientY, scrollRect.top)) {
|
|
269
269
|
isDragging.current = true;
|
|
270
270
|
setIsScrubbing(true);
|
|
271
|
+
// Seed the pending coordinate so a press with no pointermove still
|
|
272
|
+
// replays THIS x on pointerup. `updateScrubDrag` is the only other
|
|
273
|
+
// writer, so without this a plain click settles on the ref's initial
|
|
274
|
+
// 0 and clamps the playhead back to t=0.
|
|
275
|
+
pendingClientXRef.current = e.clientX;
|
|
271
276
|
seekFromX(e.clientX);
|
|
272
277
|
return;
|
|
273
278
|
}
|