@hyperframes/studio 0.8.11 → 0.8.13
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-C6J6fsAD.js → hyperframes-player-BP9rShvS.js} +1 -1
- package/dist/assets/{index-1vMlv05L.js → index-CBm9bYHQ.js} +1 -1
- package/dist/assets/{index-BX0piiWc.js → index-CDerRsMw.js} +1 -1
- package/dist/assets/index-CQMHN4nk.js +431 -0
- package/dist/assets/index-yGhfxxoL.css +1 -0
- package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
- package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
- package/dist/index.d.ts +84 -43
- package/dist/index.html +2 -2
- package/dist/index.js +7646 -6514
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +0 -2
- package/src/components/StudioRightPanel.tsx +15 -57
- package/src/components/editor/PropertyPanel.test.tsx +101 -3
- package/src/components/editor/PropertyPanel.tsx +15 -1
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
- package/src/components/editor/PropertyPanelFlat.tsx +82 -21
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
- package/src/components/editor/TimelineFxPopover.tsx +24 -4
- package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
- package/src/components/editor/audioFxRevealTarget.ts +113 -0
- package/src/components/editor/audioFxSignalPath.test.ts +50 -0
- package/src/components/editor/audioFxSignalPath.ts +71 -0
- package/src/components/editor/audioFxSummary.test.ts +8 -0
- package/src/components/editor/audioFxSummary.ts +28 -16
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
- package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
- package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
- package/src/components/editor/propertyPanelFxControls.tsx +10 -2
- package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
- package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
- package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
- package/src/components/editor/propertyPanelFxSection.tsx +63 -4
- package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
- package/src/components/editor/useAudioFxRevealSection.ts +64 -0
- package/src/components/editor/useAuditionTransport.test.ts +36 -0
- package/src/components/editor/useAuditionTransport.ts +78 -0
- package/src/components/editor/useFxAudition.ts +12 -1
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
- package/src/components/editor/useFxCarveGrouping.ts +139 -4
- package/src/components/editor/useFxCarveNodes.ts +153 -0
- package/src/components/editor/useFxLevelling.ts +5 -34
- package/src/components/nle/NLEContext.tsx +3 -10
- package/src/components/nle/PreviewPane.tsx +0 -1
- package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
- package/src/contexts/DesignPanelInputContext.tsx +6 -5
- package/src/contexts/DomEditContext.tsx +10 -3
- package/src/contexts/FileManagerContext.tsx +3 -2
- package/src/contexts/PanelLayoutContext.tsx +3 -2
- package/src/contexts/StudioContext.tsx +7 -3
- package/src/contexts/TimelineEditContext.tsx +6 -2
- package/src/contexts/VariablePromoteContext.tsx +6 -2
- package/src/contexts/ViewModeContext.tsx +2 -2
- package/src/hooks/domEditDeleteMembers.ts +34 -0
- package/src/hooks/domSelectionTimelineMirror.ts +12 -3
- package/src/hooks/timelineAudioGroupCreate.ts +345 -0
- package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
- package/src/hooks/timelineAudioGroupVolume.ts +135 -3
- package/src/hooks/timelineEditingHelpers.test.ts +86 -0
- package/src/hooks/timelineEditingHelpers.ts +19 -7
- package/src/hooks/timelineElementFxAttribute.ts +0 -3
- package/src/hooks/timelineTrackVisibility.test.ts +95 -13
- package/src/hooks/timelineTrackVisibility.ts +6 -182
- package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
- package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
- package/src/hooks/useDomEditAttributeCommits.ts +13 -0
- package/src/hooks/useDomEditSession.test.tsx +57 -2
- package/src/hooks/useDomEditSession.ts +23 -24
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +115 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
- package/src/hooks/useLivePlayheadTime.ts +3 -1
- package/src/hooks/useRemoveBackground.ts +70 -0
- package/src/hooks/useTimelineEditing.ts +5 -19
- package/src/hooks/useTimelineEditingTypes.ts +7 -0
- package/src/player/components/AutomationEnvelopePaths.tsx +54 -0
- package/src/player/components/AutomationSelectionMenu.tsx +4 -0
- package/src/player/components/LayerDisclosureRow.tsx +59 -25
- package/src/player/components/PlayerControls.tsx +0 -38
- package/src/player/components/Timeline.test.ts +40 -1
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +180 -0
- package/src/player/components/TimelineAutomationLane.tsx +129 -36
- package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
- package/src/player/components/TimelineFxButton.test.tsx +167 -21
- package/src/player/components/TimelineFxButton.tsx +156 -31
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +99 -0
- package/src/player/components/TimelineGroupRow.test.tsx +222 -0
- package/src/player/components/TimelineGroupRow.tsx +140 -71
- package/src/player/components/TimelineLanes.test.tsx +3 -3
- package/src/player/components/TimelineLanes.tsx +25 -23
- package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
- package/src/player/components/TimelineTrackHeader.tsx +301 -381
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
- package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
- package/src/player/components/automationLaneData.test.ts +94 -0
- package/src/player/components/automationLaneData.ts +23 -0
- package/src/player/components/automationLaneDragMath.ts +28 -0
- package/src/player/components/automationLaneGeometry.ts +52 -13
- package/src/player/components/groupAutomationElement.test.ts +51 -0
- package/src/player/components/groupAutomationElement.ts +37 -0
- package/src/player/components/timelineCallbacks.ts +6 -2
- package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
- package/src/player/components/timelineKeyboardNavigation.ts +25 -8
- package/src/player/components/timelineLayout.ts +0 -1
- package/src/player/components/timelineViewModel.ts +30 -1
- package/src/player/components/trackHeaderLabelRows.tsx +328 -0
- package/src/player/components/useAutomationLaneGestures.ts +66 -18
- package/src/player/components/useAutomationSegmentDrag.ts +110 -0
- package/src/player/components/useTimelineClipDisclosure.ts +41 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
- package/src/player/components/useTimelineLogicalFocus.ts +2 -2
- package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
- package/src/player/components/useTimelineLogicalRows.ts +3 -3
- package/src/player/components/useTimelineTrackDerivations.ts +47 -8
- package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
- package/src/player/components/useTimelineTrackLayout.ts +12 -4
- package/src/player/hooks/previewMessageRouter.ts +4 -11
- package/src/player/hooks/timelineSyncHydration.ts +395 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
- package/src/player/hooks/useTimelinePlayer.ts +2 -7
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
- package/src/player/lib/automationStoreSync.test.ts +26 -0
- package/src/player/lib/automationStoreSync.ts +28 -4
- package/src/player/lib/runtimeAudioId.test.ts +58 -0
- package/src/player/lib/timelineDOM.test.ts +115 -0
- package/src/player/lib/timelineDOM.ts +3 -32
- package/src/player/lib/timelineElementHelpers.ts +27 -0
- package/src/player/lib/timelineGroupInfo.ts +134 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -0
- package/src/player/store/keyframeSlice.ts +79 -5
- package/src/player/store/playerStore.ts +37 -47
- package/src/player/store/playerStoreDevHandle.ts +22 -0
- package/src/player/store/playerStoreSelection.ts +41 -0
- package/src/player/store/timelineElement.ts +32 -0
- package/src/utils/hmrStableContext.ts +64 -0
- package/src/utils/timelineInspector.test.ts +35 -1
- package/src/utils/timelineInspector.ts +38 -0
- package/dist/assets/index-Ba9zbpT9.css +0 -1
- package/dist/assets/index-DoW5v5eS.js +0 -428
- package/src/hooks/useAudioSoloBridge.ts +0 -61
- package/src/hooks/useGroupLevel.ts +0 -36
- package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
- package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
- package/src/player/components/TimelineSoloButton.tsx +0 -32
- package/src/player/store/audioSoloSlice.test.ts +0 -113
- package/src/player/store/audioSoloSlice.ts +0 -43
- package/src/player/store/groupLevels.ts +0 -33
- /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
- /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type GroupDragSnapshot,
|
|
26
26
|
type ShiftAxis,
|
|
27
27
|
} from "./automationLaneDragMath";
|
|
28
|
+
import { useAutomationSegmentDrag } from "./useAutomationSegmentDrag";
|
|
28
29
|
|
|
29
30
|
/** How far a press may travel and still count as a click rather than a drag. */
|
|
30
31
|
const CLICK_SLOP_PX = 3;
|
|
@@ -63,10 +64,16 @@ export interface UseAutomationLaneGesturesResult {
|
|
|
63
64
|
dragIndex: number | null;
|
|
64
65
|
/** Segment being bent, identified by the point that owns its curve. */
|
|
65
66
|
curveIndex: number | null;
|
|
67
|
+
/** Segment whose two endpoints are being translated together. */
|
|
68
|
+
segmentDragIndex: number | null;
|
|
69
|
+
/** Segment close enough to the pointer to offer that translation. */
|
|
70
|
+
segmentHoverIndex: number | null;
|
|
66
71
|
/** Value readout to show while a gesture is live. */
|
|
67
72
|
hint: string | null;
|
|
68
73
|
hitIndex(clientX: number, clientY: number): number | null;
|
|
69
74
|
segmentIndex(clientX: number, clientY: number): number | null;
|
|
75
|
+
/** Clear hover feedback when the pointer leaves the lane. */
|
|
76
|
+
onPointerLeave(): void;
|
|
70
77
|
onPointerDown(e: ReactPointerEvent<SVGSVGElement>): void;
|
|
71
78
|
onPointerMove(e: ReactPointerEvent<SVGSVGElement>): void;
|
|
72
79
|
/** Edge being stretched, for the cursor. Null when no stretch is live. */
|
|
@@ -227,16 +234,37 @@ export function useAutomationLaneGestures({
|
|
|
227
234
|
[lane, pointAt],
|
|
228
235
|
);
|
|
229
236
|
|
|
230
|
-
|
|
237
|
+
const segmentDrag = useAutomationSegmentDrag({
|
|
238
|
+
getBox,
|
|
239
|
+
lane,
|
|
240
|
+
range,
|
|
241
|
+
pointAt,
|
|
242
|
+
xOf,
|
|
243
|
+
yOf,
|
|
244
|
+
segmentIndex,
|
|
245
|
+
commitPoints,
|
|
246
|
+
duration,
|
|
247
|
+
snapTimes,
|
|
248
|
+
readOnly,
|
|
249
|
+
onHint: setHint,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/** What a press starts: point move, segment move, or Alt segment bend. */
|
|
231
253
|
const gestureAt = useCallback(
|
|
232
|
-
(
|
|
254
|
+
(
|
|
255
|
+
e: ReactPointerEvent<SVGSVGElement>,
|
|
256
|
+
): { kind: "point" | "segment" | "curve"; index: number } | null => {
|
|
233
257
|
const index = hitIndex(e.clientX, e.clientY);
|
|
234
|
-
if (index !== null) return {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
258
|
+
if (index !== null) return { kind: "point", index };
|
|
259
|
+
// Alt is an explicit bend gesture and retains its span-wide hit target.
|
|
260
|
+
// The unmodified translation is offered only close to the drawn line.
|
|
261
|
+
const segment = e.altKey
|
|
262
|
+
? segmentIndex(e.clientX, e.clientY)
|
|
263
|
+
: segmentDrag.hitIndex(e.clientX, e.clientY);
|
|
264
|
+
if (segment === null) return null;
|
|
265
|
+
return { kind: e.altKey ? "curve" : "segment", index: segment };
|
|
238
266
|
},
|
|
239
|
-
[hitIndex, segmentIndex],
|
|
267
|
+
[hitIndex, segmentDrag, segmentIndex],
|
|
240
268
|
);
|
|
241
269
|
|
|
242
270
|
const onPointerDown = useCallback(
|
|
@@ -274,10 +302,15 @@ export function useAutomationLaneGestures({
|
|
|
274
302
|
}
|
|
275
303
|
e.preventDefault();
|
|
276
304
|
capturePointer(e);
|
|
277
|
-
|
|
305
|
+
segmentDrag.clearHover();
|
|
306
|
+
if (gesture.kind === "curve") {
|
|
278
307
|
setCurveIndex(gesture.index);
|
|
279
308
|
return;
|
|
280
309
|
}
|
|
310
|
+
if (gesture.kind === "segment") {
|
|
311
|
+
segmentDrag.arm(gesture.index, e.clientX, e.clientY);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
281
314
|
dragOrigin.current = originOf(lane.points[gesture.index]);
|
|
282
315
|
// Pressing one of a selected set drags the whole set. Pressing a point
|
|
283
316
|
// outside the selection is an ordinary single-point drag, selection or no.
|
|
@@ -299,6 +332,7 @@ export function useAutomationLaneGestures({
|
|
|
299
332
|
// selecting a range read the previous selection, or none.
|
|
300
333
|
rangeSelection,
|
|
301
334
|
stretch,
|
|
335
|
+
segmentDrag,
|
|
302
336
|
],
|
|
303
337
|
);
|
|
304
338
|
|
|
@@ -394,6 +428,20 @@ export function useAutomationLaneGestures({
|
|
|
394
428
|
[dragIndex, duration, lane, pointAt, range, commitPoints, snapTimes, xOf, yOf, moveGroup],
|
|
395
429
|
);
|
|
396
430
|
|
|
431
|
+
/** Route a live point, bend, or segment gesture after global gestures stand down. */
|
|
432
|
+
const moveLiveGesture = useCallback(
|
|
433
|
+
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
|
434
|
+
const from = pressAt.current;
|
|
435
|
+
if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
|
|
436
|
+
pressTravelled.current = true;
|
|
437
|
+
}
|
|
438
|
+
if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
|
|
439
|
+
else if (segmentDrag.dragIndex !== null) segmentDrag.move(e);
|
|
440
|
+
else movePoint(e);
|
|
441
|
+
},
|
|
442
|
+
[bendSegment, curveIndex, movePoint, segmentDrag],
|
|
443
|
+
);
|
|
444
|
+
|
|
397
445
|
const onPointerMove = useCallback(
|
|
398
446
|
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
|
399
447
|
if (stretch.edge !== null) {
|
|
@@ -409,19 +457,15 @@ export function useAutomationLaneGestures({
|
|
|
409
457
|
rangeDrag.move(e);
|
|
410
458
|
return;
|
|
411
459
|
}
|
|
412
|
-
if (curveIndex === null && dragIndex === null) {
|
|
460
|
+
if (curveIndex === null && dragIndex === null && segmentDrag.dragIndex === null) {
|
|
413
461
|
stretch.updateHover(e);
|
|
462
|
+
segmentDrag.updateHover(e.clientX, e.clientY);
|
|
414
463
|
return;
|
|
415
464
|
}
|
|
416
465
|
e.stopPropagation();
|
|
417
|
-
|
|
418
|
-
if (from && Math.hypot(e.clientX - from.x, e.clientY - from.y) > CLICK_SLOP_PX) {
|
|
419
|
-
pressTravelled.current = true;
|
|
420
|
-
}
|
|
421
|
-
if (curveIndex !== null) bendSegment(e.clientX, e.clientY);
|
|
422
|
-
else movePoint(e);
|
|
466
|
+
moveLiveGesture(e);
|
|
423
467
|
},
|
|
424
|
-
[rangeDrag, curveIndex, dragIndex,
|
|
468
|
+
[rangeDrag, curveIndex, dragIndex, segmentDrag, moveLiveGesture, stretch],
|
|
425
469
|
);
|
|
426
470
|
|
|
427
471
|
const endDrag = useCallback(
|
|
@@ -436,12 +480,13 @@ export function useAutomationLaneGestures({
|
|
|
436
480
|
rangeDrag.finish();
|
|
437
481
|
return;
|
|
438
482
|
}
|
|
439
|
-
if (dragIndex === null && curveIndex === null) return;
|
|
483
|
+
if (dragIndex === null && curveIndex === null && segmentDrag.dragIndex === null) return;
|
|
440
484
|
e.stopPropagation();
|
|
441
485
|
const index = dragIndex;
|
|
442
486
|
const shiftClicked = index !== null && e.shiftKey && !pressTravelled.current;
|
|
443
487
|
setDragIndex(null);
|
|
444
488
|
setCurveIndex(null);
|
|
489
|
+
segmentDrag.finish();
|
|
445
490
|
dragOrigin.current = null;
|
|
446
491
|
groupDrag.current = null;
|
|
447
492
|
shiftAxis.current = null;
|
|
@@ -459,7 +504,7 @@ export function useAutomationLaneGestures({
|
|
|
459
504
|
}
|
|
460
505
|
commitPoints(lane.points, true);
|
|
461
506
|
},
|
|
462
|
-
[rangeDrag, curveIndex, dragIndex, lane, commitPoints, stretch],
|
|
507
|
+
[rangeDrag, curveIndex, dragIndex, segmentDrag, lane, commitPoints, stretch],
|
|
463
508
|
);
|
|
464
509
|
|
|
465
510
|
/**
|
|
@@ -519,12 +564,15 @@ export function useAutomationLaneGestures({
|
|
|
519
564
|
return {
|
|
520
565
|
dragIndex,
|
|
521
566
|
curveIndex,
|
|
567
|
+
segmentDragIndex: segmentDrag.dragIndex,
|
|
568
|
+
segmentHoverIndex: segmentDrag.hoverIndex,
|
|
522
569
|
edgeDrag: stretch.edge,
|
|
523
570
|
edgeHover: stretch.hover,
|
|
524
571
|
cancelDrag,
|
|
525
572
|
hint,
|
|
526
573
|
hitIndex,
|
|
527
574
|
segmentIndex,
|
|
575
|
+
onPointerLeave: segmentDrag.clearHover,
|
|
528
576
|
onPointerDown,
|
|
529
577
|
onPointerMove,
|
|
530
578
|
endDrag,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The grab target and translation gesture for one automation segment.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from the lane's gesture router: proximity is measured against
|
|
5
|
+
* the sampled envelope, while movement reuses the same shape-preserving group
|
|
6
|
+
* math as a box selection.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
10
|
+
import {
|
|
11
|
+
sampleAutomationLane,
|
|
12
|
+
type AutomationRange,
|
|
13
|
+
type HfAutomationLane,
|
|
14
|
+
} from "@hyperframes/core/audio-automation";
|
|
15
|
+
import { SEGMENT_GRAB_PX } from "./automationLaneGeometry";
|
|
16
|
+
import { armSegmentDrag, computeGroupMove, type GroupDragSnapshot } from "./automationLaneDragMath";
|
|
17
|
+
|
|
18
|
+
interface UseAutomationSegmentDragInput {
|
|
19
|
+
getBox(): DOMRect | null;
|
|
20
|
+
lane: HfAutomationLane;
|
|
21
|
+
range: AutomationRange;
|
|
22
|
+
pointAt(clientX: number, clientY: number): { t: number; v: number };
|
|
23
|
+
xOf(t: number): number;
|
|
24
|
+
yOf(v: number): number;
|
|
25
|
+
segmentIndex(clientX: number, clientY: number): number | null;
|
|
26
|
+
commitPoints(points: HfAutomationLane["points"], persist: boolean): void;
|
|
27
|
+
duration: number;
|
|
28
|
+
snapTimes: readonly number[] | undefined;
|
|
29
|
+
readOnly: boolean | undefined;
|
|
30
|
+
onHint(hint: string | null): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useAutomationSegmentDrag({
|
|
34
|
+
getBox,
|
|
35
|
+
lane,
|
|
36
|
+
range,
|
|
37
|
+
pointAt,
|
|
38
|
+
xOf,
|
|
39
|
+
yOf,
|
|
40
|
+
segmentIndex,
|
|
41
|
+
commitPoints,
|
|
42
|
+
duration,
|
|
43
|
+
snapTimes,
|
|
44
|
+
readOnly,
|
|
45
|
+
onHint,
|
|
46
|
+
}: UseAutomationSegmentDragInput) {
|
|
47
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
|
48
|
+
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
|
49
|
+
const snapshot = useRef<GroupDragSnapshot | null>(null);
|
|
50
|
+
|
|
51
|
+
/** Segment whose drawn line is close enough to grab, or null. */
|
|
52
|
+
const hitIndex = useCallback(
|
|
53
|
+
(clientX: number, clientY: number): number | null => {
|
|
54
|
+
const box = getBox();
|
|
55
|
+
if (!box) return null;
|
|
56
|
+
const index = segmentIndex(clientX, clientY);
|
|
57
|
+
if (index === null) return null;
|
|
58
|
+
const { t } = pointAt(clientX, clientY);
|
|
59
|
+
const value = sampleAutomationLane(lane, t, range.scale);
|
|
60
|
+
return Math.abs(yOf(value) - (clientY - box.top)) <= SEGMENT_GRAB_PX ? index : null;
|
|
61
|
+
},
|
|
62
|
+
[getBox, lane, pointAt, range.scale, segmentIndex, yOf],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const arm = useCallback(
|
|
66
|
+
(index: number, clientX: number, clientY: number): void => {
|
|
67
|
+
snapshot.current = armSegmentDrag(lane, index, pointAt(clientX, clientY));
|
|
68
|
+
setHoverIndex(null);
|
|
69
|
+
setDragIndex(index);
|
|
70
|
+
},
|
|
71
|
+
[lane, pointAt],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const move = useCallback(
|
|
75
|
+
(e: ReactPointerEvent<SVGSVGElement>): void => {
|
|
76
|
+
const group = snapshot.current;
|
|
77
|
+
if (!group) return;
|
|
78
|
+
const moved = computeGroupMove({
|
|
79
|
+
group,
|
|
80
|
+
raw: pointAt(e.clientX, e.clientY),
|
|
81
|
+
shiftKey: e.shiftKey,
|
|
82
|
+
altKey: e.altKey,
|
|
83
|
+
range,
|
|
84
|
+
duration,
|
|
85
|
+
snapTimes,
|
|
86
|
+
xOf,
|
|
87
|
+
yOf,
|
|
88
|
+
});
|
|
89
|
+
onHint(moved.hint);
|
|
90
|
+
commitPoints(moved.points, false);
|
|
91
|
+
},
|
|
92
|
+
[commitPoints, duration, onHint, pointAt, range, snapTimes, xOf, yOf],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const finish = useCallback((): void => {
|
|
96
|
+
snapshot.current = null;
|
|
97
|
+
setDragIndex(null);
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const updateHover = useCallback(
|
|
101
|
+
(clientX: number, clientY: number): void => {
|
|
102
|
+
setHoverIndex(readOnly ? null : hitIndex(clientX, clientY));
|
|
103
|
+
},
|
|
104
|
+
[hitIndex, readOnly],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const clearHover = useCallback((): void => setHoverIndex(null), []);
|
|
108
|
+
|
|
109
|
+
return { dragIndex, hoverIndex, hitIndex, arm, move, finish, updateHover, clearHover };
|
|
110
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening and closing a track's keyframe property lanes, with the telemetry that
|
|
3
|
+
* goes with it.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `TimelineLanes.tsx` to keep that file under the studio's 600-line
|
|
6
|
+
* cap. Both callbacks were already the only place the disclosure state and its
|
|
7
|
+
* `keyframe_lane_expand` event were written together, which is what makes them a
|
|
8
|
+
* seam rather than a shuffle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
12
|
+
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
|
|
13
|
+
|
|
14
|
+
export interface TimelineClipDisclosure {
|
|
15
|
+
/** The caret belongs to the ROW, so it opens and closes every clip on it at
|
|
16
|
+
* once. Toggling only the active clip left the row's state depending on which
|
|
17
|
+
* sibling happened to be selected: expand one, click another, and the row
|
|
18
|
+
* collapsed under a caret that still pointed down. */
|
|
19
|
+
toggleRowExpanded: (keys: readonly string[]) => void;
|
|
20
|
+
toggleClipExpanded: (key: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useTimelineClipDisclosure(): TimelineClipDisclosure {
|
|
24
|
+
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
|
25
|
+
const expandClips = usePlayerStore((s) => s.expandClips);
|
|
26
|
+
const setClipExpanded = usePlayerStore((s) => s.setClipExpanded);
|
|
27
|
+
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
toggleRowExpanded: (keys) => {
|
|
31
|
+
const willExpand = !keys.some((key) => expandedClipIds.has(key));
|
|
32
|
+
trackStudioKeyframeLaneExpand({ expanded: willExpand });
|
|
33
|
+
if (willExpand) expandClips(keys);
|
|
34
|
+
else for (const key of keys) setClipExpanded(key, false);
|
|
35
|
+
},
|
|
36
|
+
toggleClipExpanded: (key) => {
|
|
37
|
+
trackStudioKeyframeLaneExpand({ expanded: !expandedClipIds.has(key) });
|
|
38
|
+
toggleClipExpanded(key);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -6,7 +6,7 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
|
|
6
6
|
/** The four pieces of group-disclosure state a group row's header reads and writes. */
|
|
7
7
|
export function useTimelineGroupDisclosure() {
|
|
8
8
|
return {
|
|
9
|
-
|
|
9
|
+
collapsedGroupIds: usePlayerStore((s) => s.collapsedGroupIds),
|
|
10
10
|
expandedLaneOwnerIds: usePlayerStore((s) => s.expandedLaneOwnerIds),
|
|
11
11
|
toggleGroupExpanded: usePlayerStore((s) => s.toggleGroupExpanded),
|
|
12
12
|
toggleLaneOwnerExpanded: usePlayerStore((s) => s.toggleLaneOwnerExpanded),
|
|
@@ -35,7 +35,7 @@ interface TimelineLogicalFocusInput {
|
|
|
35
35
|
|
|
36
36
|
export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
|
|
37
37
|
const expandedClipIds = usePlayerStore((state) => state.expandedClipIds);
|
|
38
|
-
const
|
|
38
|
+
const collapsedGroupIds = usePlayerStore((state) => state.collapsedGroupIds);
|
|
39
39
|
const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds);
|
|
40
40
|
const projectId = usePlayerStore((state) => state.timelineProjectId);
|
|
41
41
|
const logicalRows = useTimelineLogicalRows({
|
|
@@ -45,7 +45,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
|
|
|
45
45
|
selectedElementId: input.selectedElementId,
|
|
46
46
|
selectedElementIds: input.selectedElementIds,
|
|
47
47
|
expandedClipIds,
|
|
48
|
-
|
|
48
|
+
collapsedGroupIds,
|
|
49
49
|
expandedLaneOwnerIds,
|
|
50
50
|
groups: input.groups,
|
|
51
51
|
trackGroupOf: input.trackGroupOf,
|
|
@@ -22,7 +22,7 @@ const displayTrackOrder = tracks.map(([track]) => track);
|
|
|
22
22
|
const laneCounts = new Map<string, number>();
|
|
23
23
|
const selectedElementIds = new Set<string>();
|
|
24
24
|
const expandedClipIds = new Set<string>();
|
|
25
|
-
const
|
|
25
|
+
const collapsedGroupIds = new Set<string>();
|
|
26
26
|
const expandedLaneOwnerIds = new Set<string>();
|
|
27
27
|
const groups: never[] = [];
|
|
28
28
|
const trackGroupOf = new Map();
|
|
@@ -37,7 +37,7 @@ function Harness({ snapshots }: { snapshots: Array<readonly TimelineLogicalRow[]
|
|
|
37
37
|
selectedElementId: null,
|
|
38
38
|
selectedElementIds,
|
|
39
39
|
expandedClipIds,
|
|
40
|
-
|
|
40
|
+
collapsedGroupIds,
|
|
41
41
|
expandedLaneOwnerIds,
|
|
42
42
|
groups,
|
|
43
43
|
trackGroupOf,
|
|
@@ -14,7 +14,7 @@ export function useTimelineLogicalRows({
|
|
|
14
14
|
selectedElementId,
|
|
15
15
|
selectedElementIds,
|
|
16
16
|
expandedClipIds,
|
|
17
|
-
|
|
17
|
+
collapsedGroupIds,
|
|
18
18
|
expandedLaneOwnerIds,
|
|
19
19
|
groups,
|
|
20
20
|
trackGroupOf,
|
|
@@ -29,7 +29,7 @@ export function useTimelineLogicalRows({
|
|
|
29
29
|
selectedElementId,
|
|
30
30
|
selectedElementIds,
|
|
31
31
|
expandedClipIds,
|
|
32
|
-
|
|
32
|
+
collapsedGroupIds,
|
|
33
33
|
expandedLaneOwnerIds,
|
|
34
34
|
groups,
|
|
35
35
|
trackGroupOf,
|
|
@@ -38,7 +38,7 @@ export function useTimelineLogicalRows({
|
|
|
38
38
|
[
|
|
39
39
|
displayTrackOrder,
|
|
40
40
|
expandedClipIds,
|
|
41
|
-
|
|
41
|
+
collapsedGroupIds,
|
|
42
42
|
expandedLaneOwnerIds,
|
|
43
43
|
groups,
|
|
44
44
|
trackGroupOf,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
|
|
4
4
|
|
|
5
5
|
/** One resolved audio group, positioned in the row order. */
|
|
@@ -15,12 +15,26 @@ export interface TimelineTrackGroupInfo {
|
|
|
15
15
|
anchorKey: number;
|
|
16
16
|
/** Member track numbers, ascending. */
|
|
17
17
|
memberTracks: number[];
|
|
18
|
+
/**
|
|
19
|
+
* Every clip under this group, in member-track order — INDEPENDENT of whether
|
|
20
|
+
* the group is expanded.
|
|
21
|
+
*
|
|
22
|
+
* Collapsing a group stops emitting its member rows into `tracks`, so anything
|
|
23
|
+
* that recovered member elements by looking them up there got an empty list in
|
|
24
|
+
* the default (collapsed) state — silently disabling the
|
|
25
|
+
* automation-lane count, and the bus strip's member labels. Membership is not
|
|
26
|
+
* a display concern, so it does not travel through the display list.
|
|
27
|
+
*/
|
|
28
|
+
memberElements: TimelineElement[];
|
|
18
29
|
/** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
|
|
19
30
|
volume: number;
|
|
20
31
|
/** The group element's `data-hidden`, mirrored from a member's parse (B5's group mute). */
|
|
21
32
|
hidden: boolean;
|
|
22
33
|
/** The group element's serialized `data-fx-chain`, mirrored from a member's parse (C1's FX entry). */
|
|
23
34
|
fxChain?: string;
|
|
35
|
+
/** The group element's serialized `data-automation` — the group's OWN lanes,
|
|
36
|
+
* which are what its `∿` discloses (groups doc §5). */
|
|
37
|
+
automation?: string;
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
interface GroupMembership {
|
|
@@ -30,6 +44,7 @@ interface GroupMembership {
|
|
|
30
44
|
volumeByGroup: Map<string, number>;
|
|
31
45
|
hiddenByGroup: Map<string, boolean>;
|
|
32
46
|
fxChainByGroup: Map<string, string | undefined>;
|
|
47
|
+
automationByGroup: Map<string, string | undefined>;
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
/** Which track belongs to which group, and each group's label/volume/hidden/fxChain — one pass over raw tracks. */
|
|
@@ -40,6 +55,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
|
|
|
40
55
|
const volumeByGroup = new Map<string, number>();
|
|
41
56
|
const hiddenByGroup = new Map<string, boolean>();
|
|
42
57
|
const fxChainByGroup = new Map<string, string | undefined>();
|
|
58
|
+
const automationByGroup = new Map<string, string | undefined>();
|
|
43
59
|
for (const [trackNum, elements] of rawTracks) {
|
|
44
60
|
const owner = elements.find((el) => el.audioGroup);
|
|
45
61
|
if (!owner?.audioGroup) continue;
|
|
@@ -49,6 +65,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
|
|
|
49
65
|
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
|
|
50
66
|
hiddenByGroup.set(owner.audioGroup, owner.audioGroupHidden ?? false);
|
|
51
67
|
fxChainByGroup.set(owner.audioGroup, owner.audioGroupFxChain);
|
|
68
|
+
automationByGroup.set(owner.audioGroup, owner.audioGroupAutomation);
|
|
52
69
|
}
|
|
53
70
|
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
|
|
54
71
|
members.push(trackNum);
|
|
@@ -61,6 +78,7 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
|
|
|
61
78
|
volumeByGroup,
|
|
62
79
|
hiddenByGroup,
|
|
63
80
|
fxChainByGroup,
|
|
81
|
+
automationByGroup,
|
|
64
82
|
};
|
|
65
83
|
}
|
|
66
84
|
|
|
@@ -69,33 +87,50 @@ function buildGroupInfo(
|
|
|
69
87
|
groupId: string,
|
|
70
88
|
fallbackTrackNum: number,
|
|
71
89
|
membership: GroupMembership,
|
|
90
|
+
rawByTrack: ReadonlyMap<number, TimelineElement[]>,
|
|
72
91
|
): TimelineTrackGroupInfo {
|
|
73
92
|
const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
|
|
74
93
|
(a, b) => a - b,
|
|
75
94
|
);
|
|
76
95
|
const fxChain = membership.fxChainByGroup.get(groupId);
|
|
96
|
+
const automation = membership.automationByGroup.get(groupId);
|
|
77
97
|
return {
|
|
78
98
|
id: groupId,
|
|
79
99
|
label: membership.labelByGroup.get(groupId) ?? groupId,
|
|
100
|
+
// Exactly x.5, which sub-composition child rows are now kept strictly below
|
|
101
|
+
// (`useExpandedTimelineElements`) — they used to be able to land here and
|
|
102
|
+
// collide, duplicating the group header.
|
|
80
103
|
anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
|
|
81
104
|
memberTracks,
|
|
105
|
+
memberElements: memberTracks.flatMap((track) => rawByTrack.get(track) ?? []),
|
|
82
106
|
volume: membership.volumeByGroup.get(groupId) ?? 1,
|
|
83
107
|
hidden: membership.hiddenByGroup.get(groupId) ?? false,
|
|
84
108
|
...(fxChain ? { fxChain } : {}),
|
|
109
|
+
...(automation ? { automation } : {}),
|
|
85
110
|
};
|
|
86
111
|
}
|
|
87
112
|
|
|
88
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Push a group's synthetic anchor row plus its members' rows, contiguously.
|
|
115
|
+
*
|
|
116
|
+
* A collapsed group emits its anchor and nothing else. `buildTimelineLogicalRows`
|
|
117
|
+
* already stops at the anchor when collapsed, so leaving the member rows in
|
|
118
|
+
* `tracks` left row geometry reserving a full row each for rows that then
|
|
119
|
+
* rendered as `null` — a header trailed by its members' worth of blank,
|
|
120
|
+
* unreachable dead space. Membership still lands in `trackGroupOf`: a collapsed
|
|
121
|
+
* member is hidden, not ungrouped.
|
|
122
|
+
*/
|
|
89
123
|
function emitGroupRows(
|
|
90
124
|
info: TimelineTrackGroupInfo,
|
|
91
125
|
rawByTrack: ReadonlyMap<number, TimelineElement[]>,
|
|
92
126
|
trackGroupOf: Map<number, TimelineTrackGroupInfo>,
|
|
93
127
|
tracks: [number, TimelineElement[]][],
|
|
128
|
+
expanded: boolean,
|
|
94
129
|
): void {
|
|
95
130
|
tracks.push([info.anchorKey, []]);
|
|
96
131
|
for (const member of info.memberTracks) {
|
|
97
132
|
trackGroupOf.set(member, info);
|
|
98
|
-
tracks.push([member, rawByTrack.get(member) ?? []]);
|
|
133
|
+
if (expanded) tracks.push([member, rawByTrack.get(member) ?? []]);
|
|
99
134
|
}
|
|
100
135
|
}
|
|
101
136
|
|
|
@@ -105,7 +140,10 @@ function emitGroupRows(
|
|
|
105
140
|
* their position; a group's members move up to sit under its anchor even when
|
|
106
141
|
* other (ungrouped) tracks were interleaved between them.
|
|
107
142
|
*/
|
|
108
|
-
function groupTimelineTracks(
|
|
143
|
+
function groupTimelineTracks(
|
|
144
|
+
rawTracks: [number, TimelineElement[]][],
|
|
145
|
+
collapsedGroupIds: ReadonlySet<string>,
|
|
146
|
+
): {
|
|
109
147
|
tracks: [number, TimelineElement[]][];
|
|
110
148
|
groups: TimelineTrackGroupInfo[];
|
|
111
149
|
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
|
|
@@ -125,9 +163,9 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
|
|
|
125
163
|
}
|
|
126
164
|
if (emitted.has(groupId)) continue;
|
|
127
165
|
emitted.add(groupId);
|
|
128
|
-
const info = buildGroupInfo(groupId, trackNum, membership);
|
|
166
|
+
const info = buildGroupInfo(groupId, trackNum, membership, rawByTrack);
|
|
129
167
|
groups.push(info);
|
|
130
|
-
emitGroupRows(info, rawByTrack, trackGroupOf, tracks);
|
|
168
|
+
emitGroupRows(info, rawByTrack, trackGroupOf, tracks, !collapsedGroupIds.has(groupId));
|
|
131
169
|
}
|
|
132
170
|
return { tracks, groups, trackGroupOf };
|
|
133
171
|
}
|
|
@@ -156,9 +194,10 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
|
|
|
156
194
|
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
|
157
195
|
}, [expandedElements]);
|
|
158
196
|
|
|
197
|
+
const collapsedGroupIds = usePlayerStore((s) => s.collapsedGroupIds);
|
|
159
198
|
const { tracks, groups, trackGroupOf } = useMemo(
|
|
160
|
-
() => groupTimelineTracks(rawTracks),
|
|
161
|
-
[rawTracks],
|
|
199
|
+
() => groupTimelineTracks(rawTracks, collapsedGroupIds),
|
|
200
|
+
[rawTracks, collapsedGroupIds],
|
|
162
201
|
);
|
|
163
202
|
|
|
164
203
|
const trackStyles = useMemo(() => {
|
|
@@ -38,6 +38,125 @@ function renderTrackLayout(
|
|
|
38
38
|
return { layout, unmount: () => act(() => root.unmount()) };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
describe("collapsed audio groups", () => {
|
|
42
|
+
const member = (id: string, track: number): TimelineElement => ({
|
|
43
|
+
id,
|
|
44
|
+
domId: id,
|
|
45
|
+
tag: "audio",
|
|
46
|
+
start: 0,
|
|
47
|
+
duration: 5,
|
|
48
|
+
track,
|
|
49
|
+
audioGroup: "voiceover",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** `collapsed` seeds the collapsed set — expanded is the default state. */
|
|
53
|
+
function renderGrouped(collapsed = false): {
|
|
54
|
+
layout: ReturnType<typeof useTimelineTrackLayout>;
|
|
55
|
+
unmount: () => void;
|
|
56
|
+
} {
|
|
57
|
+
if (collapsed) usePlayerStore.setState({ collapsedGroupIds: new Set(["voiceover"]) });
|
|
58
|
+
const elements = [member("voice-1", 0), member("voice-2", 1)];
|
|
59
|
+
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
|
60
|
+
function Probe() {
|
|
61
|
+
layout = useTimelineTrackLayout(elements, new Map(), null, new Set());
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const root = createRoot(document.createElement("div"));
|
|
65
|
+
act(() => root.render(React.createElement(Probe)));
|
|
66
|
+
if (!layout) throw new Error("Timeline track layout did not render");
|
|
67
|
+
return { layout, unmount: () => act(() => root.unmount()) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The `∿` area holds the group's own automation rows. Sized without them,
|
|
71
|
+
// every lane the count had just promised was clipped out of the row — which
|
|
72
|
+
// is what "expanding automation on a group doesn't show the automation"
|
|
73
|
+
// looked like from outside.
|
|
74
|
+
it("reserves room for the group's own automation rows, not just the strip", () => {
|
|
75
|
+
const automation = JSON.stringify({
|
|
76
|
+
version: 1,
|
|
77
|
+
lanes: [
|
|
78
|
+
{
|
|
79
|
+
target: "volume",
|
|
80
|
+
points: [
|
|
81
|
+
{ t: 0, v: 1 },
|
|
82
|
+
{ t: 5, v: 0.4 },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
const elements = [
|
|
88
|
+
{ ...member("voice-1", 0), audioGroupAutomation: automation },
|
|
89
|
+
{ ...member("voice-2", 1), audioGroupAutomation: automation },
|
|
90
|
+
];
|
|
91
|
+
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
|
92
|
+
function Probe() {
|
|
93
|
+
layout = useTimelineTrackLayout(elements, new Map(), null, new Set());
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const root = createRoot(document.createElement("div"));
|
|
97
|
+
act(() => {
|
|
98
|
+
usePlayerStore.setState({ expandedLaneOwnerIds: new Set(["voiceover"]) });
|
|
99
|
+
root.render(React.createElement(Probe));
|
|
100
|
+
});
|
|
101
|
+
// The group's anchor row: the first member track minus 0.5.
|
|
102
|
+
const anchorIndex = layout!.tracks.findIndex(([track]) => track === -0.5);
|
|
103
|
+
expect(anchorIndex).toBeGreaterThanOrEqual(0);
|
|
104
|
+
const openHeight = layout!.rowHeights[anchorIndex];
|
|
105
|
+
// One lane of headroom beyond the header row itself.
|
|
106
|
+
expect(openHeight).toBe(TRACK_H + AUTOMATION_LANE_H);
|
|
107
|
+
act(() => root.unmount());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// buildTimelineLogicalRows stops emitting member rows once a group is
|
|
111
|
+
// collapsed, so TimelineLanes renders null for them. Rows left in `tracks`
|
|
112
|
+
// still reserve height, turning that null into visible dead space — the row
|
|
113
|
+
// list and the logical rows have to agree.
|
|
114
|
+
it("emits only the anchor row while the group is collapsed", () => {
|
|
115
|
+
const { layout, unmount } = renderGrouped(true);
|
|
116
|
+
expect(layout.groups).toHaveLength(1);
|
|
117
|
+
expect(layout.groups[0]!.memberTracks).toEqual([0, 1]);
|
|
118
|
+
// The anchor (0 - 0.5) and nothing else.
|
|
119
|
+
expect(layout.trackOrder).toEqual([-0.5]);
|
|
120
|
+
expect(layout.rowGeometry.rowHeights).toHaveLength(1);
|
|
121
|
+
// Membership still resolves — collapsed is hidden, not ungrouped.
|
|
122
|
+
expect(layout.trackGroupOf.get(0)?.id).toBe("voiceover");
|
|
123
|
+
unmount();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Membership is not a display concern. Half-lit solo, the automation-lane
|
|
127
|
+
// count and the bus strip's member labels all read the group's members, and
|
|
128
|
+
// all three silently degraded to empty when those were recovered from the
|
|
129
|
+
// display list — which a collapsed group does not appear in. Collapsed is the
|
|
130
|
+
// default, so that was every group until someone opened it.
|
|
131
|
+
it("carries its member elements even while collapsed", () => {
|
|
132
|
+
const { layout, unmount } = renderGrouped(true);
|
|
133
|
+
expect(layout.trackOrder).toEqual([-0.5]); // collapsed: no member rows
|
|
134
|
+
expect(layout.groups[0]!.memberElements.map((el) => el.id)).toEqual(["voice-1", "voice-2"]);
|
|
135
|
+
unmount();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// The reason the set is stored inverted. As an expanded-set, "absent" could
|
|
139
|
+
// not tell never-touched from deliberately-collapsed, so a freshly created
|
|
140
|
+
// group started collapsed — grouping three tracks made all three vanish
|
|
141
|
+
// behind a header the user had not yet learned to open.
|
|
142
|
+
it("is expanded by default, with nothing seeded", () => {
|
|
143
|
+
const { layout, unmount } = renderGrouped();
|
|
144
|
+
expect(usePlayerStore.getState().collapsedGroupIds.size).toBe(0);
|
|
145
|
+
expect(layout.trackOrder).toEqual([-0.5, 0, 1]);
|
|
146
|
+
unmount();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("emits the member rows once the group is expanded", () => {
|
|
150
|
+
// Expanded is the default now — nothing to seed.
|
|
151
|
+
const { layout, unmount } = renderGrouped();
|
|
152
|
+
expect(layout.trackOrder).toEqual([-0.5, 0, 1]);
|
|
153
|
+
for (const track of layout.groups[0]!.memberTracks) {
|
|
154
|
+
expect(layout.rowGeometry.getRowHeight(layout.rowGeometry.getRowIndex(track))).toBe(TRACK_H);
|
|
155
|
+
}
|
|
156
|
+
unmount();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
41
160
|
describe("useTimelineTrackLayout", () => {
|
|
42
161
|
it("counts a flat tween lane and reserves its expanded row height", () => {
|
|
43
162
|
const elements: TimelineElement[] = [
|