@hyperframes/studio 0.7.77 → 0.7.79
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-CEggaaxR.js → hyperframes-player-mFah2TZE.js} +1 -1
- package/dist/assets/{index-B1Tjjdse.js → index-Bqj3h_1a.js} +1 -1
- package/dist/assets/{index-Bp4jAYZG.js → index-DlZMDyYs.js} +194 -194
- package/dist/assets/index-gGVKuFg5.css +1 -0
- package/dist/assets/{index-B1INQNmj.js → index-hmnoiSEV.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1432 -1181
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +5 -4
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +4 -1
- package/src/components/editor/PropertyPanel.tsx +3 -7
- 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/propertyPanelPrimitives.tsx +3 -1
- package/src/components/nle/TimelineResizeDivider.tsx +9 -4
- package/src/components/pointerTargetSize.test.tsx +65 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
- package/src/components/sidebar/CompositionsTab.tsx +70 -33
- package/src/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
- 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/keyframeCacheAstLoad.ts +5 -71
- 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/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 +4 -11
- package/src/player/components/LayerDisclosureRow.tsx +4 -1
- package/src/player/components/Player.test.ts +143 -12
- package/src/player/components/Player.tsx +84 -43
- 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 +17 -2
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
- 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/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/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 +151 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- package/dist/assets/index-CBAfprvx.css +0 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, createRef } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { TimelineLanes } from "./TimelineLanes";
|
|
8
|
+
import { getTrackStyle } from "./timelineIcons";
|
|
9
|
+
import { defaultTimelineTheme } from "./timelineTheme";
|
|
10
|
+
import { TRACK_H } from "./timelineLayout";
|
|
11
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
12
|
+
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
13
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
14
|
+
import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
15
|
+
|
|
16
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
document.body.innerHTML = "";
|
|
20
|
+
usePlayerStore.getState().reset();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** The z-order sort keys really are fractional: a clip nudged between two lanes
|
|
24
|
+
* lands on the midpoint. These are the values that used to reach aria-label. */
|
|
25
|
+
const TRACK_A = 1 / 6;
|
|
26
|
+
const TRACK_B = 0.5;
|
|
27
|
+
|
|
28
|
+
function element(id: string, track: number): TimelineElement {
|
|
29
|
+
return { id, label: id, tag: "div", start: 0, duration: 2, track };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function positionTween(id: string): GsapAnimation {
|
|
33
|
+
return {
|
|
34
|
+
id: `${id}-tween`,
|
|
35
|
+
targetSelector: `#${id}`,
|
|
36
|
+
method: "to",
|
|
37
|
+
position: 0,
|
|
38
|
+
duration: 2,
|
|
39
|
+
properties: {},
|
|
40
|
+
propertyGroup: "position",
|
|
41
|
+
keyframes: {
|
|
42
|
+
format: "percentage",
|
|
43
|
+
keyframes: [
|
|
44
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
45
|
+
{ percentage: 100, properties: { x: 100 } },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RenderLanesOptions {
|
|
52
|
+
elements?: TimelineElement[];
|
|
53
|
+
animations?: Map<string, GsapAnimation[]>;
|
|
54
|
+
expandedClipIds?: string[];
|
|
55
|
+
selectedElementIds?: Set<string>;
|
|
56
|
+
multiDragPreview?: MultiDragPreviewInput | null;
|
|
57
|
+
draggedClip?: DraggedClipState | null;
|
|
58
|
+
onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
59
|
+
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderLanes(options: RenderLanesOptions = {}): {
|
|
63
|
+
host: HTMLDivElement;
|
|
64
|
+
root: Root;
|
|
65
|
+
rerender: (next: RenderLanesOptions) => void;
|
|
66
|
+
} {
|
|
67
|
+
const host = document.createElement("div");
|
|
68
|
+
document.body.append(host);
|
|
69
|
+
const root = createRoot(host);
|
|
70
|
+
const render = (next: RenderLanesOptions) => {
|
|
71
|
+
const elements = next.elements ?? [element("clip-a", TRACK_A)];
|
|
72
|
+
const gsapAnimations = next.animations ?? new Map<string, GsapAnimation[]>();
|
|
73
|
+
const displayTrackOrder = [...new Set(elements.map((el) => el.track))].sort((a, b) => a - b);
|
|
74
|
+
const tracks: [number, TimelineElement[]][] = displayTrackOrder.map((track) => [
|
|
75
|
+
track,
|
|
76
|
+
elements.filter((el) => el.track === track),
|
|
77
|
+
]);
|
|
78
|
+
const laneCounts = new Map(
|
|
79
|
+
elements.map((el) => [el.id, (gsapAnimations.get(el.id) ?? []).length]),
|
|
80
|
+
);
|
|
81
|
+
act(() => {
|
|
82
|
+
usePlayerStore.setState({ expandedClipIds: new Set(next.expandedClipIds ?? []) });
|
|
83
|
+
root.render(
|
|
84
|
+
<TimelineLanes
|
|
85
|
+
pps={100}
|
|
86
|
+
contentOrigin={232}
|
|
87
|
+
contentGutter={32}
|
|
88
|
+
trackContentWidth={800}
|
|
89
|
+
theme={defaultTimelineTheme}
|
|
90
|
+
displayTrackOrder={displayTrackOrder}
|
|
91
|
+
rowHeights={displayTrackOrder.map(() => TRACK_H)}
|
|
92
|
+
trackOrder={displayTrackOrder}
|
|
93
|
+
tracks={tracks}
|
|
94
|
+
trackStyles={new Map()}
|
|
95
|
+
laneCounts={laneCounts}
|
|
96
|
+
selectedElementId={null}
|
|
97
|
+
selectedElementIds={next.selectedElementIds ?? new Set()}
|
|
98
|
+
hoveredClip={null}
|
|
99
|
+
draggedClip={next.draggedClip ?? null}
|
|
100
|
+
draggedElement={null}
|
|
101
|
+
multiDragPreview={next.multiDragPreview ?? null}
|
|
102
|
+
blockedClipRef={createRef<BlockedClipState | null>()}
|
|
103
|
+
suppressClickRef={{ current: false }}
|
|
104
|
+
scrollRef={createRef<HTMLDivElement>()}
|
|
105
|
+
setHoveredClip={vi.fn()}
|
|
106
|
+
setShowPopover={vi.fn()}
|
|
107
|
+
setRangeSelection={vi.fn()}
|
|
108
|
+
setResizingClip={vi.fn()}
|
|
109
|
+
setDraggedClip={vi.fn()}
|
|
110
|
+
setSelectedElementId={vi.fn()}
|
|
111
|
+
syncClipDragAutoScroll={vi.fn()}
|
|
112
|
+
shiftClickClipRef={createRef()}
|
|
113
|
+
getPreviewElement={(el) => el}
|
|
114
|
+
getTrackStyle={getTrackStyle}
|
|
115
|
+
gsapAnimations={gsapAnimations}
|
|
116
|
+
selectedKeyframes={new Set()}
|
|
117
|
+
currentTime={0}
|
|
118
|
+
onContextMenuLane={next.onContextMenuLane}
|
|
119
|
+
onToggleTrackHidden={next.onToggleTrackHidden}
|
|
120
|
+
onTogglePropertyGroupKeyframe={vi.fn()}
|
|
121
|
+
onResizeElement={vi.fn()}
|
|
122
|
+
onMoveElement={vi.fn()}
|
|
123
|
+
onRazorSplit={vi.fn()}
|
|
124
|
+
onRazorSplitAll={vi.fn()}
|
|
125
|
+
/>,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
render(options);
|
|
130
|
+
return { host, root, rerender: render };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function visibilityLabels(host: HTMLElement): (string | null)[] {
|
|
134
|
+
return Array.from(host.querySelectorAll("button[aria-label^='Hide track ']")).map((button) =>
|
|
135
|
+
button.getAttribute("aria-label"),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe("TimelineLanes track numbering", () => {
|
|
140
|
+
// Screen readers literally announced "Hide track 0.16666666666666666".
|
|
141
|
+
it("numbers tracks contiguously from 1 regardless of the fractional sort keys", () => {
|
|
142
|
+
const view = renderLanes({
|
|
143
|
+
elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
|
|
147
|
+
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
|
|
148
|
+
act(() => view.root.unmount());
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("hands the visibility toggle the real track key, not the display index", () => {
|
|
152
|
+
const onToggleTrackHidden = vi.fn();
|
|
153
|
+
const view = renderLanes({
|
|
154
|
+
elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
|
|
155
|
+
onToggleTrackHidden,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const second = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 2"]');
|
|
159
|
+
act(() => second?.click());
|
|
160
|
+
|
|
161
|
+
expect(onToggleTrackHidden).toHaveBeenCalledWith(TRACK_B, true);
|
|
162
|
+
act(() => view.root.unmount());
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// The gap menu inserts at the track it is given, so a display index here would
|
|
166
|
+
// drop the new clip on the wrong lane.
|
|
167
|
+
it("hands the lane context menu the real track key, not the display index", () => {
|
|
168
|
+
const onContextMenuLane = vi.fn();
|
|
169
|
+
const view = renderLanes({
|
|
170
|
+
elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)],
|
|
171
|
+
onContextMenuLane,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Row children: [sticky header column, time-mapped track content].
|
|
175
|
+
const rows = Array.from(view.host.children);
|
|
176
|
+
const secondTrackContent = rows[1]?.children.item(1);
|
|
177
|
+
act(() => {
|
|
178
|
+
secondTrackContent?.dispatchEvent(
|
|
179
|
+
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100 }),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(onContextMenuLane).toHaveBeenCalledOnce();
|
|
184
|
+
expect(onContextMenuLane.mock.calls[0]?.[1]).toBe(TRACK_B);
|
|
185
|
+
act(() => view.root.unmount());
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("TimelineLanes disclosure target", () => {
|
|
190
|
+
const ANIMATIONS = new Map([["clip-a", [positionTween("clip-a")]]]);
|
|
191
|
+
|
|
192
|
+
function ariaControlsTarget(host: HTMLElement): HTMLElement | null {
|
|
193
|
+
const caret = host.querySelector("button[aria-controls]");
|
|
194
|
+
const id = caret?.getAttribute("aria-controls");
|
|
195
|
+
return id ? host.querySelector<HTMLElement>(`#${id}`) : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// aria-controls used to name a div in the sticky label column: it computed to
|
|
199
|
+
// 0x0 and held no diamonds at all.
|
|
200
|
+
it("resolves the caret's aria-controls to an element holding the property lanes", () => {
|
|
201
|
+
const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
|
|
202
|
+
const target = ariaControlsTarget(view.host);
|
|
203
|
+
|
|
204
|
+
expect(target).not.toBeNull();
|
|
205
|
+
expect(target?.querySelectorAll("[data-timeline-property-lane]").length).toBeGreaterThan(0);
|
|
206
|
+
act(() => view.root.unmount());
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("still resolves the caret's aria-controls while the layer is collapsed", () => {
|
|
210
|
+
const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: [] });
|
|
211
|
+
const target = ariaControlsTarget(view.host);
|
|
212
|
+
|
|
213
|
+
expect(target).not.toBeNull();
|
|
214
|
+
expect(target?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(0);
|
|
215
|
+
act(() => view.root.unmount());
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Two timelines on one page (a mini-timeline in a modal beside the main one)
|
|
219
|
+
// both minted `timeline-lanes-track-0`, so every caret's aria-controls
|
|
220
|
+
// resolved to whichever instance mounted first.
|
|
221
|
+
it("mints lane ids that do not collide with a second TimelineLanes on the page", () => {
|
|
222
|
+
const first = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
|
|
223
|
+
const second = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] });
|
|
224
|
+
|
|
225
|
+
const idsFor = (host: HTMLElement) =>
|
|
226
|
+
Array.from(host.querySelectorAll("button[aria-controls]")).map((caret) =>
|
|
227
|
+
caret.getAttribute("aria-controls"),
|
|
228
|
+
);
|
|
229
|
+
const firstIds = idsFor(first.host);
|
|
230
|
+
const secondIds = idsFor(second.host);
|
|
231
|
+
|
|
232
|
+
expect(firstIds.length).toBeGreaterThan(0);
|
|
233
|
+
expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
|
|
234
|
+
// Still a legal CSS id selector: the aria-controls lookups above use `#id`.
|
|
235
|
+
for (const id of [...firstIds, ...secondIds]) {
|
|
236
|
+
expect(id).toMatch(/^[A-Za-z][\w-]*$/);
|
|
237
|
+
}
|
|
238
|
+
act(() => first.root.unmount());
|
|
239
|
+
act(() => second.root.unmount());
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// The passenger branch wraps [clip, lanes] in a transformed div that re-renders
|
|
243
|
+
// on every pointer move. An unstable key there remounts the lanes and drops the
|
|
244
|
+
// in-flight drag.
|
|
245
|
+
it("does not remount the lanes while a multi-clip drag slides the formation", () => {
|
|
246
|
+
const elements = [element("clip-a", TRACK_A), element("clip-b", TRACK_A)];
|
|
247
|
+
const selectedElementIds = new Set(["clip-a", "clip-b"]);
|
|
248
|
+
const preview = (draggedPreviewStart: number): MultiDragPreviewInput => ({
|
|
249
|
+
dragStarted: true,
|
|
250
|
+
draggedKey: "clip-b",
|
|
251
|
+
draggedOriginStart: 0,
|
|
252
|
+
draggedPreviewStart,
|
|
253
|
+
selectedKeys: selectedElementIds,
|
|
254
|
+
});
|
|
255
|
+
const view = renderLanes({
|
|
256
|
+
elements,
|
|
257
|
+
animations: ANIMATIONS,
|
|
258
|
+
expandedClipIds: ["clip-a"],
|
|
259
|
+
selectedElementIds,
|
|
260
|
+
multiDragPreview: preview(0.25),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const before = ariaControlsTarget(view.host);
|
|
264
|
+
const beforeLane = before?.querySelector("[data-timeline-property-lane]");
|
|
265
|
+
expect(before).not.toBeNull();
|
|
266
|
+
expect(beforeLane).not.toBeNull();
|
|
267
|
+
|
|
268
|
+
view.rerender({
|
|
269
|
+
elements,
|
|
270
|
+
animations: ANIMATIONS,
|
|
271
|
+
expandedClipIds: ["clip-a"],
|
|
272
|
+
selectedElementIds,
|
|
273
|
+
multiDragPreview: preview(0.75),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Node identity, not just presence: a remount replaces these nodes.
|
|
277
|
+
expect(ariaControlsTarget(view.host)).toBe(before);
|
|
278
|
+
expect(before?.querySelector("[data-timeline-property-lane]")).toBe(beforeLane);
|
|
279
|
+
act(() => view.root.unmount());
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -1,28 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
1
|
+
import { useId } from "react";
|
|
3
2
|
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
|
|
4
3
|
import { TimelineClip } from "./TimelineClip";
|
|
5
4
|
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
6
5
|
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
7
6
|
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
|
8
7
|
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
|
|
9
|
-
import
|
|
10
|
-
import
|
|
8
|
+
import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
9
|
+
import { clipTimingStart } from "../../hooks/gsapShared";
|
|
11
10
|
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
|
|
12
|
-
import type { TimelineTheme } from "./timelineTheme";
|
|
13
11
|
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
|
|
14
|
-
import {
|
|
15
|
-
usePlayerStore,
|
|
16
|
-
type TimelineElement,
|
|
17
|
-
type KeyframeCacheEntry,
|
|
18
|
-
} from "../store/playerStore";
|
|
19
|
-
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
12
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
20
13
|
import {
|
|
21
14
|
isMultiDragPassenger,
|
|
22
15
|
multiDragPassengerOffsetPx,
|
|
23
16
|
type MultiDragPreviewInput,
|
|
24
17
|
} from "./timelineMultiDragPreview";
|
|
25
|
-
import type {
|
|
18
|
+
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
|
26
19
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
27
20
|
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
|
28
21
|
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
|
@@ -30,83 +23,6 @@ import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
|
30
23
|
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
|
31
24
|
import { renderClipChildren } from "./timelineClipChildren";
|
|
32
25
|
|
|
33
|
-
/**
|
|
34
|
-
* Props shared by the scroll container ({@link TimelineCanvas}) and the lane
|
|
35
|
-
* renderer below. TimelineCanvas passes these straight through via spread, so
|
|
36
|
-
* they are declared once here and both prop types compose from this base — no
|
|
37
|
-
* duplicated prop list.
|
|
38
|
-
*/
|
|
39
|
-
export interface TimelineLaneBaseProps {
|
|
40
|
-
pps: number;
|
|
41
|
-
contentOrigin: number;
|
|
42
|
-
contentGutter: number;
|
|
43
|
-
trackContentWidth: number;
|
|
44
|
-
theme: TimelineTheme;
|
|
45
|
-
displayTrackOrder: number[];
|
|
46
|
-
rowHeights: readonly number[];
|
|
47
|
-
trackOrder: number[];
|
|
48
|
-
tracks: [number, TimelineElement[]][];
|
|
49
|
-
trackStyles: Map<number, TrackVisualStyle>;
|
|
50
|
-
laneCounts: ReadonlyMap<string, number>;
|
|
51
|
-
selectedElementId: string | null;
|
|
52
|
-
selectedElementIds: Set<string>;
|
|
53
|
-
hoveredClip: string | null;
|
|
54
|
-
draggedClip: DraggedClipState | null;
|
|
55
|
-
blockedClipRef: React.RefObject<BlockedClipState | null>;
|
|
56
|
-
suppressClickRef: React.RefObject<boolean>;
|
|
57
|
-
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
58
|
-
renderClipContent?: (
|
|
59
|
-
element: TimelineElement,
|
|
60
|
-
style: { clip: string; label: string },
|
|
61
|
-
) => ReactNode;
|
|
62
|
-
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
63
|
-
onDrillDown?: (element: TimelineElement) => void;
|
|
64
|
-
onSelectElement?: (element: TimelineElement | null) => void;
|
|
65
|
-
setHoveredClip: (key: string | null) => void;
|
|
66
|
-
setShowPopover: (v: boolean) => void;
|
|
67
|
-
setRangeSelection: (v: null) => void;
|
|
68
|
-
setResizingClip: (v: ResizingClipState | null) => void;
|
|
69
|
-
setDraggedClip: (v: DraggedClipState | null) => void;
|
|
70
|
-
setSelectedElementId: (id: string | null) => void;
|
|
71
|
-
syncClipDragAutoScroll: (x: number, y: number) => void;
|
|
72
|
-
shiftClickClipRef: React.RefObject<{
|
|
73
|
-
element: TimelineElement;
|
|
74
|
-
anchorX: number;
|
|
75
|
-
anchorY: number;
|
|
76
|
-
} | null>;
|
|
77
|
-
getPreviewElement: (element: TimelineElement) => TimelineElement;
|
|
78
|
-
getTrackStyle: (tag: string) => TrackVisualStyle;
|
|
79
|
-
keyframeCache?: Map<string, KeyframeCacheEntry>;
|
|
80
|
-
gsapAnimations: Map<string, GsapAnimation[]>;
|
|
81
|
-
selectedKeyframes: Set<string>;
|
|
82
|
-
currentTime: number;
|
|
83
|
-
onSeek?: (time: number) => void;
|
|
84
|
-
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
|
85
|
-
onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
|
|
86
|
-
onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
|
87
|
-
onContextMenuKeyframe?: (
|
|
88
|
-
e: React.MouseEvent,
|
|
89
|
-
elementId: string,
|
|
90
|
-
target: TimelineKeyframeTarget,
|
|
91
|
-
) => void;
|
|
92
|
-
onMoveKeyframe?: (
|
|
93
|
-
elementId: string,
|
|
94
|
-
keyframe: TimelineKeyframeTarget,
|
|
95
|
-
toClipPercentage: number,
|
|
96
|
-
propertyGroup?: string,
|
|
97
|
-
tweenPercentage?: number,
|
|
98
|
-
animationId?: string,
|
|
99
|
-
) => Promise<boolean>;
|
|
100
|
-
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
101
|
-
/**
|
|
102
|
-
* Right-click on EMPTY lane space (not on a clip — those preventDefault
|
|
103
|
-
* before this fires — not the gutter/ruler, not below the lanes). `time` is
|
|
104
|
-
* the timeline time (seconds) under the pointer on that lane.
|
|
105
|
-
*/
|
|
106
|
-
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
|
107
|
-
beatAnalysis?: MusicBeatAnalysis | null;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
26
|
interface TimelineLanesProps extends TimelineLaneBaseProps {
|
|
111
27
|
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
|
|
112
28
|
draggedElement: TimelineElement | null;
|
|
@@ -174,6 +90,12 @@ export function TimelineLanes({
|
|
|
174
90
|
onRazorSplit,
|
|
175
91
|
onRazorSplitAll,
|
|
176
92
|
}: TimelineLanesProps) {
|
|
93
|
+
// Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal
|
|
94
|
+
// beside the main one) cannot both mint `...-track-0` and have every caret's
|
|
95
|
+
// aria-controls resolve to whichever mounted first. React's useId embeds
|
|
96
|
+
// colons, which are legal in an id and in aria-controls but need escaping in
|
|
97
|
+
// a CSS `#id` selector, so they come out here and the prefix stays plain.
|
|
98
|
+
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
|
|
177
99
|
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
178
100
|
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
|
179
101
|
const toggleClipExpandedTracked = (key: string) => {
|
|
@@ -191,6 +113,7 @@ export function TimelineLanes({
|
|
|
191
113
|
// in a virtualizer if editorial workflows ever push very high clip counts.
|
|
192
114
|
// fallow-ignore-next-line complexity
|
|
193
115
|
displayTrackOrder.map((trackNum, row) => {
|
|
116
|
+
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
|
|
194
117
|
const rowHeight = getTimelineRowHeight(row, rowHeights);
|
|
195
118
|
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
|
|
196
119
|
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
|
|
@@ -217,6 +140,11 @@ export function TimelineLanes({
|
|
|
217
140
|
const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
|
|
218
141
|
const keyframeClipExpanded =
|
|
219
142
|
keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
|
|
143
|
+
// Minted here because this is the only place that sees BOTH ends of
|
|
144
|
+
// the disclosure: the caret in the sticky header and the diamond lanes
|
|
145
|
+
// on the canvas. Keyed by display row, not by `trackNum`, which is a
|
|
146
|
+
// fractional sort key and would mint ids like `...-0.16666666666666666`.
|
|
147
|
+
const lanesId = `${lanesIdPrefix}-track-${row}`;
|
|
220
148
|
return (
|
|
221
149
|
<div
|
|
222
150
|
key={trackNum}
|
|
@@ -229,7 +157,16 @@ export function TimelineLanes({
|
|
|
229
157
|
>
|
|
230
158
|
<TimelineTrackHeader
|
|
231
159
|
trackNumber={trackNum}
|
|
232
|
-
|
|
160
|
+
// What gets announced. `trackNum` is a fractional z-order sort
|
|
161
|
+
// key, so it stays out of every label and in every callback.
|
|
162
|
+
trackDisplayNumber={displayNumber}
|
|
163
|
+
trackLabel={
|
|
164
|
+
els[0]?.label ??
|
|
165
|
+
els[0]?.domId ??
|
|
166
|
+
els[0]?.id ??
|
|
167
|
+
`Track${trackDisplaySuffix(displayNumber)}`
|
|
168
|
+
}
|
|
169
|
+
lanesId={lanesId}
|
|
233
170
|
contentOrigin={contentOrigin}
|
|
234
171
|
keyframeClip={keyframeClip}
|
|
235
172
|
clipCount={els.length}
|
|
@@ -312,10 +249,9 @@ export function TimelineLanes({
|
|
|
312
249
|
// Only the track's active keyframe clip shows expanded lanes;
|
|
313
250
|
// other clips (incl. siblings on a shared track) show compact
|
|
314
251
|
// diamonds on their own bar instead.
|
|
315
|
-
const
|
|
316
|
-
STUDIO_KEYFRAMES_ENABLED &&
|
|
317
|
-
|
|
318
|
-
keyframeClipExpanded;
|
|
252
|
+
const isTrackKeyframeClip =
|
|
253
|
+
STUDIO_KEYFRAMES_ENABLED && elementKey === keyframeClipKey;
|
|
254
|
+
const showsLanes = isTrackKeyframeClip && keyframeClipExpanded;
|
|
319
255
|
const capabilities = getTimelineEditCapabilities(el);
|
|
320
256
|
const isSelected =
|
|
321
257
|
selectedElementId === elementKey || selectedElementIds.has(elementKey);
|
|
@@ -523,11 +459,21 @@ export function TimelineLanes({
|
|
|
523
459
|
)}
|
|
524
460
|
</TimelineClip>
|
|
525
461
|
);
|
|
526
|
-
|
|
462
|
+
// Mounted for the track's keyframe clip in BOTH disclosure
|
|
463
|
+
// states, so the header caret's aria-controls resolves while
|
|
464
|
+
// collapsed too; collapsed just feeds it no animations, so
|
|
465
|
+
// the wrapper renders empty. The key is stable across a
|
|
466
|
+
// multi-drag: without it the passenger branch below remounts
|
|
467
|
+
// this subtree and interrupts the gesture.
|
|
468
|
+
const propertyLanes = isTrackKeyframeClip && (
|
|
527
469
|
<TimelinePropertyLanes
|
|
528
470
|
key={`${clipKey}-property-lanes`}
|
|
529
|
-
|
|
530
|
-
|
|
471
|
+
id={lanesId}
|
|
472
|
+
animations={showsLanes ? (gsapAnimations.get(elementKey) ?? []) : []}
|
|
473
|
+
// clipTimingStart, not the raw start: an expanded sub-comp
|
|
474
|
+
// child's start is host-absolute while its tweens are
|
|
475
|
+
// local to its own file.
|
|
476
|
+
clipStart={clipTimingStart(previewElement)}
|
|
531
477
|
clipDuration={previewElement.duration}
|
|
532
478
|
clipLeftPx={previewElement.start * pps}
|
|
533
479
|
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
|