@hyperframes/studio 0.8.111 → 0.8.112
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-DvJeHBHi.js → hyperframes-player-CBpg41JM.js} +1 -1
- package/dist/assets/{index-Bt4vDkfw.js → index-BFitquwi.js} +1 -1
- package/dist/assets/{index-SFktf7O1.js → index-BKy_birQ.js} +194 -194
- package/dist/assets/{index--G6c4Q_x.js → index-CZfrSyU9.js} +1 -1
- package/dist/assets/{index-C7apTvpr.css → index-DbfKVTId.css} +1 -1
- package/dist/{chunk-PQFEBX32.js → chunk-BIUXP3PG.js} +2 -2
- package/dist/{chunk-6LUNLTHR.js → chunk-K47E2ZJA.js} +13 -8
- package/dist/chunk-K47E2ZJA.js.map +1 -0
- package/dist/{chunk-QUXZVHOJ.js → chunk-VWT7STY3.js} +2 -2
- package/dist/{domEditingLayers-JQPQGGT7.js → domEditingLayers-V67MS4WP.js} +3 -3
- package/dist/index.d.ts +80 -46
- package/dist/index.html +2 -2
- package/dist/index.js +7294 -6844
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +10 -8
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
- package/src/hooks/timelineEditingHelpers.ts +7 -0
- package/src/hooks/timelineMoveAdapter.test.ts +12 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -4
- package/src/hooks/useTimelineGroupEditing.test.tsx +77 -0
- package/src/hooks/useTimelineGroupEditing.ts +5 -1
- package/src/index.ts +7 -0
- package/src/main.tsx +4 -0
- package/src/player/components/ClipBadges.tsx +5 -5
- package/src/player/components/Timeline.ghostLane.test.tsx +8 -61
- package/src/player/components/Timeline.trackPadding.test.tsx +11 -56
- package/src/player/components/TimelineCanvas.tsx +6 -2
- package/src/player/components/TimelineClip.filmstrip.test.tsx +156 -0
- package/src/player/components/TimelineClip.test.tsx +7 -7
- package/src/player/components/TimelineClip.tsx +23 -7
- package/src/player/components/TimelineClipFades.tsx +41 -43
- package/src/player/components/TimelineGestureOverlay.test.tsx +41 -0
- package/src/player/components/TimelineGestureOverlay.tsx +59 -21
- package/src/player/components/TimelineLanes.tsx +18 -16
- package/src/player/components/timelineCallbacks.ts +6 -4
- package/src/player/components/timelineClipChildren.tsx +7 -13
- package/src/player/components/timelineClipDragCommit.test.ts +51 -0
- package/src/player/components/timelineClipDragCommit.ts +63 -21
- package/src/player/components/timelineClipDragGestureLifecycle.ts +18 -2
- package/src/player/components/timelineClipDragPreview.test.ts +47 -31
- package/src/player/components/timelineClipDragPreview.ts +81 -17
- package/src/player/components/timelineClipDragTypes.ts +3 -3
- package/src/player/components/timelineClipFadeGeometry.test.ts +29 -0
- package/src/player/components/timelineClipFadeGeometry.ts +74 -0
- package/src/player/components/timelineCollision.ts +51 -11
- package/src/player/components/timelineDragLanding.ts +20 -6
- package/src/player/components/timelineLaneProps.ts +3 -8
- package/src/player/components/timelineMotionStyles.test.ts +6 -13
- package/src/player/components/timelineMountFixtures.tsx +65 -0
- package/src/player/components/timelineOptimisticRevision.ts +15 -4
- package/src/player/components/timelineTheme.ts +2 -0
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackInsertLayout.test.ts +249 -0
- package/src/player/components/timelineTrackInsertLayout.ts +71 -0
- package/src/player/components/timelineZMirror.ts +5 -11
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +163 -0
- package/src/player/components/useTimelineClipDrag.ts +116 -12
- package/src/player/components/useTimelineDisplayLayout.test.tsx +59 -0
- package/src/player/components/useTimelineKeyboardActor.ts +86 -7
- package/src/player/components/useTimelineProviderState.tsx +3 -1
- package/src/player/components/useTimelineRowVirtualization.test.tsx +56 -0
- package/src/player/components/useTimelineRowVirtualization.ts +3 -1
- package/src/player/components/useTimelineTrackDerivations.test.ts +14 -1
- package/src/player/components/useTimelineTrackDerivations.ts +32 -18
- package/src/player/components/useTimelineTrackLayout.ts +43 -14
- package/src/player/hooks/timelinePlayerSync.test.ts +7 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/lib/automationStoreSync.test.ts +15 -0
- package/src/player/lib/automationStoreSync.ts +13 -5
- package/src/player/lib/storedVolume.ts +7 -0
- package/src/player/lib/thumbnailPageLifecycle.test.ts +123 -0
- package/src/player/lib/thumbnailPageLifecycle.ts +27 -0
- package/src/player/lib/thumbnailScheduler.ts +20 -2
- package/src/player/lib/timelineElementHelpers.ts +2 -8
- package/src/styles/components.css +71 -19
- package/src/styles/theme.css +4 -1
- package/dist/chunk-6LUNLTHR.js.map +0 -1
- /package/dist/{chunk-PQFEBX32.js.map → chunk-BIUXP3PG.js.map} +0 -0
- /package/dist/{chunk-QUXZVHOJ.js.map → chunk-VWT7STY3.js.map} +0 -0
- /package/dist/{domEditingLayers-JQPQGGT7.js.map → domEditingLayers-V67MS4WP.js.map} +0 -0
|
@@ -16,6 +16,7 @@ import type { StackingPatch } from "./timelineStackingSync";
|
|
|
16
16
|
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
17
17
|
import {
|
|
18
18
|
computeDragPreview,
|
|
19
|
+
createKeyboardClipDrag,
|
|
19
20
|
computeResizePreview,
|
|
20
21
|
trimPreviewTime,
|
|
21
22
|
previewGroupResize,
|
|
@@ -29,6 +30,19 @@ import type {
|
|
|
29
30
|
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
|
|
30
31
|
import { dropMisalignedTrimPartners, linkedGestureKeys } from "./audioClipLink";
|
|
31
32
|
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
|
|
33
|
+
import { useTimelineClipCapabilities } from "./timelineReadOnly";
|
|
34
|
+
import { timelineClipFocusId } from "./timelineNavigationIdentity";
|
|
35
|
+
import {
|
|
36
|
+
handleClipPickupKeyboardEvent,
|
|
37
|
+
scrollKeyboardInsertRow,
|
|
38
|
+
timelineKeyboardEventTarget,
|
|
39
|
+
} from "./useTimelineKeyboardActor";
|
|
40
|
+
import {
|
|
41
|
+
timelineTrackOrderChanged,
|
|
42
|
+
nextKeyboardInsertRow,
|
|
43
|
+
keyboardPickupInsertRow,
|
|
44
|
+
type TimelineTrackInsertLayout,
|
|
45
|
+
} from "./timelineTrackInsertLayout";
|
|
32
46
|
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
33
47
|
import {
|
|
34
48
|
mountTimelineClipDragGestureLifecycle,
|
|
@@ -50,17 +64,10 @@ interface UseTimelineClipDragInput {
|
|
|
50
64
|
ppsRef: React.RefObject<number>;
|
|
51
65
|
durationRef: React.RefObject<number>;
|
|
52
66
|
trackOrderRef: React.RefObject<number[]>;
|
|
67
|
+
trackInsertLayoutRef?: React.RefObject<TimelineTrackInsertLayout | undefined>;
|
|
53
68
|
rowGeometryRef?: React.RefObject<TimelineRowGeometry>;
|
|
54
|
-
onMoveElement?:
|
|
55
|
-
|
|
56
|
-
updates: Pick<TimelineElement, "start" | "track">,
|
|
57
|
-
) => Promise<void> | void;
|
|
58
|
-
onMoveElements?: (
|
|
59
|
-
edits: Array<{
|
|
60
|
-
element: TimelineElement;
|
|
61
|
-
updates: Pick<TimelineElement, "start" | "track">;
|
|
62
|
-
}>,
|
|
63
|
-
) => Promise<void> | void;
|
|
69
|
+
onMoveElement?: TimelineEditCallbacks["onMoveElement"];
|
|
70
|
+
onMoveElements?: TimelineEditCallbacks["onMoveElements"];
|
|
64
71
|
onResizeElement?: (
|
|
65
72
|
element: TimelineElement,
|
|
66
73
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
@@ -92,6 +99,7 @@ export function useTimelineClipDrag({
|
|
|
92
99
|
ppsRef,
|
|
93
100
|
durationRef,
|
|
94
101
|
trackOrderRef,
|
|
102
|
+
trackInsertLayoutRef,
|
|
95
103
|
rowGeometryRef,
|
|
96
104
|
onMoveElement,
|
|
97
105
|
onMoveElements,
|
|
@@ -107,6 +115,8 @@ export function useTimelineClipDrag({
|
|
|
107
115
|
refreshAfterLaneMove,
|
|
108
116
|
sessionEpoch = 0,
|
|
109
117
|
}: UseTimelineClipDragInput) {
|
|
118
|
+
const getClipCapabilities = useTimelineClipCapabilities();
|
|
119
|
+
const commitGestureRef = useRef<() => void>(() => {});
|
|
110
120
|
const updateElement = usePlayerStore((s) => s.updateElement);
|
|
111
121
|
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
|
|
112
122
|
const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
|
|
@@ -207,7 +217,7 @@ export function useTimelineClipDrag({
|
|
|
207
217
|
const cancelGestureRef = useRef<
|
|
208
218
|
(options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
|
|
209
219
|
>(() => false);
|
|
210
|
-
const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number) => {
|
|
220
|
+
const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number | null) => {
|
|
211
221
|
if (lifecycleRef.current.phase === "active") cancelGestureRef.current();
|
|
212
222
|
lifecycleRef.current = {
|
|
213
223
|
kind,
|
|
@@ -310,14 +320,25 @@ export function useTimelineClipDrag({
|
|
|
310
320
|
pps: ppsRef.current,
|
|
311
321
|
duration: durationRef.current,
|
|
312
322
|
trackOrder: trackOrderRef.current,
|
|
323
|
+
allowedInsertRows: trackInsertLayoutRef?.current?.allowedRows,
|
|
324
|
+
groupTracks: trackInsertLayoutRef?.current?.groupTracks,
|
|
313
325
|
rowHeights: rowGeometryRef?.current.rowHeights,
|
|
326
|
+
rowGeometry: rowGeometryRef?.current,
|
|
314
327
|
elements: elementsRef.current,
|
|
315
328
|
selectedKeys: gestureSelectedKeysRef.current,
|
|
316
329
|
buildSnapTargets,
|
|
317
330
|
audioTracks: dragAudioTracksRef.current,
|
|
318
331
|
});
|
|
319
332
|
},
|
|
320
|
-
[
|
|
333
|
+
[
|
|
334
|
+
scrollRef,
|
|
335
|
+
ppsRef,
|
|
336
|
+
durationRef,
|
|
337
|
+
trackOrderRef,
|
|
338
|
+
trackInsertLayoutRef,
|
|
339
|
+
rowGeometryRef,
|
|
340
|
+
buildSnapTargets,
|
|
341
|
+
],
|
|
321
342
|
);
|
|
322
343
|
|
|
323
344
|
// Recompute the trim preview for a pointer x. Shared by the pointermove resize
|
|
@@ -445,6 +466,8 @@ export function useTimelineClipDrag({
|
|
|
445
466
|
|
|
446
467
|
useMountEffect(() =>
|
|
447
468
|
mountTimelineClipDragGestureLifecycle({
|
|
469
|
+
trackInsertLayoutRef,
|
|
470
|
+
commitGestureRef,
|
|
448
471
|
onStackingPatchesRef,
|
|
449
472
|
refreshAfterLaneMoveRef,
|
|
450
473
|
readZIndexRef,
|
|
@@ -479,10 +502,91 @@ export function useTimelineClipDrag({
|
|
|
479
502
|
}),
|
|
480
503
|
);
|
|
481
504
|
|
|
505
|
+
useEffect(() => {
|
|
506
|
+
const movePickup = (drag: DraggedClipState, step: -1 | 1, viewport: HTMLDivElement | null) => {
|
|
507
|
+
const audioTracks = getTimelineElementIndexes(elementsRef.current).audioTracks;
|
|
508
|
+
const row = nextKeyboardInsertRow({
|
|
509
|
+
current: drag.insertRow ?? 0,
|
|
510
|
+
step,
|
|
511
|
+
order: trackOrderRef.current,
|
|
512
|
+
layout: trackInsertLayoutRef?.current,
|
|
513
|
+
audioTracks,
|
|
514
|
+
isAudio: audioTracks.has(drag.element.track),
|
|
515
|
+
});
|
|
516
|
+
if (row === null) return;
|
|
517
|
+
publishDraggedClip({ ...drag, insertRow: row });
|
|
518
|
+
if (viewport && rowGeometryRef)
|
|
519
|
+
scrollKeyboardInsertRow(viewport, rowGeometryRef.current, row);
|
|
520
|
+
};
|
|
521
|
+
const activePickup = (
|
|
522
|
+
event: KeyboardEvent,
|
|
523
|
+
drag: DraggedClipState,
|
|
524
|
+
viewport: HTMLDivElement | null,
|
|
525
|
+
) => {
|
|
526
|
+
handleClipPickupKeyboardEvent(event, {
|
|
527
|
+
move: (step) => movePickup(drag, step, viewport),
|
|
528
|
+
commit: commitGestureRef.current,
|
|
529
|
+
cancel: cancelGestureRef.current,
|
|
530
|
+
focus: () =>
|
|
531
|
+
usePlayerStore
|
|
532
|
+
.getState()
|
|
533
|
+
.requestTimelineFocus(timelineClipFocusId(drag.element.key ?? drag.element.id)),
|
|
534
|
+
});
|
|
535
|
+
};
|
|
536
|
+
const pickup = (event: KeyboardEvent, viewport: HTMLDivElement) => {
|
|
537
|
+
const target = timelineKeyboardEventTarget(event.target, viewport);
|
|
538
|
+
const key = target?.dataset.elId;
|
|
539
|
+
if (key === undefined || !onMoveElementRef.current) return;
|
|
540
|
+
const element = getTimelineElementIndexes(elementsRef.current).byKey.get(key);
|
|
541
|
+
if (!element || !getClipCapabilities(element).canMove) return;
|
|
542
|
+
event.preventDefault();
|
|
543
|
+
event.stopImmediatePropagation();
|
|
544
|
+
setShowPopover(false);
|
|
545
|
+
setRangeSelectionRef.current?.(null);
|
|
546
|
+
const row = keyboardPickupInsertRow(
|
|
547
|
+
trackOrderRef.current.indexOf(element.track),
|
|
548
|
+
trackInsertLayoutRef?.current?.allowedRows,
|
|
549
|
+
);
|
|
550
|
+
setDraggedClip(createKeyboardClipDrag(element, row, viewport.scrollLeft, viewport.scrollTop));
|
|
551
|
+
};
|
|
552
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
553
|
+
const drag = draggedClipRef.current;
|
|
554
|
+
const viewport = scrollRef.current;
|
|
555
|
+
if (drag) {
|
|
556
|
+
if (drag.pointerId === null) activePickup(event, drag, viewport);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (resizingClipRef.current) return;
|
|
560
|
+
if (event.key !== " " || event.repeat) return;
|
|
561
|
+
if (viewport) pickup(event, viewport);
|
|
562
|
+
};
|
|
563
|
+
window.addEventListener("keydown", handleKeyDown, true);
|
|
564
|
+
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
565
|
+
}, [
|
|
566
|
+
getClipCapabilities,
|
|
567
|
+
publishDraggedClip,
|
|
568
|
+
rowGeometryRef,
|
|
569
|
+
scrollRef,
|
|
570
|
+
setDraggedClip,
|
|
571
|
+
setRangeSelectionRef,
|
|
572
|
+
setShowPopover,
|
|
573
|
+
trackOrderRef,
|
|
574
|
+
trackInsertLayoutRef,
|
|
575
|
+
]);
|
|
576
|
+
|
|
482
577
|
useEffect(() => {
|
|
483
578
|
cancelGestureRef.current();
|
|
484
579
|
}, [sessionEpoch]);
|
|
485
580
|
|
|
581
|
+
const previousOrderRef = useRef(trackOrderRef.current);
|
|
582
|
+
useEffect(() => {
|
|
583
|
+
const order = trackOrderRef.current;
|
|
584
|
+
const previous = previousOrderRef.current;
|
|
585
|
+
previousOrderRef.current = order;
|
|
586
|
+
if (draggedClipRef.current && timelineTrackOrderChanged(previous, order))
|
|
587
|
+
cancelGestureRef.current();
|
|
588
|
+
});
|
|
589
|
+
|
|
486
590
|
return {
|
|
487
591
|
draggedClip,
|
|
488
592
|
setDraggedClip,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import { useTimelineDisplayLayout } from "./useTimelineTrackLayout";
|
|
6
|
+
import { createTimelineRowGeometry } from "./timelineLayout";
|
|
7
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
8
|
+
|
|
9
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
10
|
+
|
|
11
|
+
it("opens one full lane at the insertion index without changing committed tracks", () => {
|
|
12
|
+
const order = [7, 3, 9];
|
|
13
|
+
const geometry = createTimelineRowGeometry(order, [48, 104, 48], { top: 0 });
|
|
14
|
+
const host = document.createElement("div");
|
|
15
|
+
const root = createRoot(host);
|
|
16
|
+
const drag: DraggedClipState = {
|
|
17
|
+
pointerId: 1,
|
|
18
|
+
element: { id: "clip", tag: "div", start: 1, duration: 2, track: 9 },
|
|
19
|
+
originClientX: 0,
|
|
20
|
+
originClientY: 0,
|
|
21
|
+
originScrollLeft: 0,
|
|
22
|
+
originScrollTop: 0,
|
|
23
|
+
pointerClientX: 0,
|
|
24
|
+
pointerClientY: 0,
|
|
25
|
+
pointerOffsetX: 0,
|
|
26
|
+
pointerOffsetY: 0,
|
|
27
|
+
previewStart: 1,
|
|
28
|
+
previewTrack: 3,
|
|
29
|
+
insertRow: 1,
|
|
30
|
+
snapTime: null,
|
|
31
|
+
snapType: null,
|
|
32
|
+
started: true,
|
|
33
|
+
};
|
|
34
|
+
function Probe({ active }: { active: DraggedClipState | null }) {
|
|
35
|
+
const layout = useTimelineDisplayLayout(active, order, geometry);
|
|
36
|
+
return (
|
|
37
|
+
<output>
|
|
38
|
+
{JSON.stringify({
|
|
39
|
+
order: layout.displayTrackOrder,
|
|
40
|
+
pinned: layout.draggedRowKey,
|
|
41
|
+
heights: layout.displayRowHeights,
|
|
42
|
+
below: layout.rowGeometry.getRowTop(2),
|
|
43
|
+
})}
|
|
44
|
+
</output>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
act(() => root.render(<Probe active={drag} />));
|
|
48
|
+
const seen = JSON.parse(host.textContent!);
|
|
49
|
+
expect(seen.order).toHaveLength(4);
|
|
50
|
+
expect(seen.pinned).toBe(-1);
|
|
51
|
+
expect(seen.order.filter((key: number) => order.includes(key))).toEqual(order);
|
|
52
|
+
expect(order.includes(seen.order[1])).toBe(false);
|
|
53
|
+
expect(seen.heights).toEqual([48, 48, 104, 48]);
|
|
54
|
+
expect(seen.below).toBe(120);
|
|
55
|
+
expect(order).toEqual([7, 3, 9]);
|
|
56
|
+
act(() => root.render(<Probe active={null} />));
|
|
57
|
+
expect(JSON.parse(host.textContent!).order).toEqual(order);
|
|
58
|
+
act(() => root.unmount());
|
|
59
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
|
-
import type
|
|
3
|
+
import { RULER_H, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
|
|
4
4
|
import {
|
|
5
5
|
isTimelineNavigationKey,
|
|
6
6
|
locateTimelineLogicalTarget,
|
|
@@ -17,14 +17,92 @@ interface TimelineKeyboardActorInput {
|
|
|
17
17
|
onDrillDown?: (element: TimelineElement) => void;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
type ClipPickupAction =
|
|
21
|
+
| { kind: "move"; step: -1 | 1 }
|
|
22
|
+
| { kind: "commit" }
|
|
23
|
+
| { kind: "cancel" }
|
|
24
|
+
| { kind: "focus" }
|
|
25
|
+
| { kind: "consume" };
|
|
26
|
+
|
|
27
|
+
function clipPickupKeyboardAction(event: {
|
|
28
|
+
key: string;
|
|
29
|
+
repeat: boolean;
|
|
30
|
+
}): ClipPickupAction | null {
|
|
31
|
+
switch (event.key) {
|
|
32
|
+
case "ArrowUp":
|
|
33
|
+
return { kind: "move", step: -1 };
|
|
34
|
+
case "ArrowDown":
|
|
35
|
+
return { kind: "move", step: 1 };
|
|
36
|
+
case "Enter":
|
|
37
|
+
return { kind: event.repeat ? "focus" : "commit" };
|
|
38
|
+
case "Escape":
|
|
39
|
+
return { kind: "cancel" };
|
|
40
|
+
case " ":
|
|
41
|
+
return { kind: "consume" };
|
|
42
|
+
default:
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function handleClipPickupKeyboardEvent(
|
|
48
|
+
event: globalThis.KeyboardEvent,
|
|
49
|
+
actions: {
|
|
50
|
+
move: (step: -1 | 1) => void;
|
|
51
|
+
commit: () => void;
|
|
52
|
+
cancel: () => void;
|
|
53
|
+
focus: () => void;
|
|
54
|
+
},
|
|
55
|
+
): void {
|
|
56
|
+
const action = clipPickupKeyboardAction(event);
|
|
57
|
+
if (!action) return;
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
event.stopImmediatePropagation();
|
|
60
|
+
switch (action.kind) {
|
|
61
|
+
case "move":
|
|
62
|
+
actions.move(action.step);
|
|
63
|
+
break;
|
|
64
|
+
case "commit":
|
|
65
|
+
actions.commit();
|
|
66
|
+
actions.focus();
|
|
67
|
+
break;
|
|
68
|
+
case "cancel":
|
|
69
|
+
actions.cancel();
|
|
70
|
+
actions.focus();
|
|
71
|
+
break;
|
|
72
|
+
case "focus":
|
|
73
|
+
actions.focus();
|
|
74
|
+
break;
|
|
75
|
+
case "consume":
|
|
76
|
+
break;
|
|
77
|
+
default: {
|
|
78
|
+
const unreachable: never = action;
|
|
79
|
+
throw new Error(`Unknown pickup action: ${unreachable}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function scrollKeyboardInsertRow(
|
|
85
|
+
viewport: HTMLDivElement,
|
|
86
|
+
geometry: TimelineRowGeometry,
|
|
87
|
+
row: number,
|
|
88
|
+
): void {
|
|
89
|
+
const top = geometry.getRowTop(row);
|
|
90
|
+
if (top < viewport.scrollTop + RULER_H) viewport.scrollTop = Math.max(0, top - RULER_H);
|
|
91
|
+
else if (top + TRACK_H > viewport.scrollTop + viewport.clientHeight)
|
|
92
|
+
viewport.scrollTop = top + TRACK_H - viewport.clientHeight;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function timelineKeyboardEventTarget(
|
|
96
|
+
target: EventTarget | null,
|
|
97
|
+
container: HTMLElement,
|
|
98
|
+
): HTMLElement | null {
|
|
99
|
+
if (!(target instanceof Element)) return null;
|
|
22
100
|
// Header actions stay native Tab stops because they have no row-level shortcut.
|
|
23
101
|
// The nearest interactive ancestor wins so their events never masquerade as row events.
|
|
24
|
-
const
|
|
102
|
+
const nearest = target.closest<HTMLElement>(
|
|
25
103
|
"button, input, select, textarea, a[href], [contenteditable], [role='slider'], [data-timeline-focus-id]",
|
|
26
104
|
);
|
|
27
|
-
return
|
|
105
|
+
return nearest?.dataset.timelineFocusId && container.contains(nearest) ? nearest : null;
|
|
28
106
|
}
|
|
29
107
|
|
|
30
108
|
function viewportPageSize(
|
|
@@ -87,7 +165,8 @@ export function useTimelineKeyboardActor({
|
|
|
87
165
|
|
|
88
166
|
const onFocus = useCallback(
|
|
89
167
|
(event: FocusEvent<HTMLElement>) => {
|
|
90
|
-
const id =
|
|
168
|
+
const id = timelineKeyboardEventTarget(event.target, event.currentTarget)?.dataset
|
|
169
|
+
.timelineFocusId;
|
|
91
170
|
if (id && id !== focusedTargetId) usePlayerStore.getState().requestTimelineFocus(id);
|
|
92
171
|
},
|
|
93
172
|
// ponytail: This closure must see the current id or coordinator-driven focus bumps the nonce twice.
|
|
@@ -98,7 +177,7 @@ export function useTimelineKeyboardActor({
|
|
|
98
177
|
// One handler owns navigation, context-menu, and disclosure keyboard semantics.
|
|
99
178
|
// fallow-ignore-next-line complexity
|
|
100
179
|
(event: KeyboardEvent<HTMLElement>) => {
|
|
101
|
-
const targetElement =
|
|
180
|
+
const targetElement = timelineKeyboardEventTarget(event.target, event.currentTarget);
|
|
102
181
|
const id = targetElement?.dataset.timelineFocusId;
|
|
103
182
|
if (!targetElement || !id) return;
|
|
104
183
|
const located = locateTimelineLogicalTarget(logicalRows, id);
|
|
@@ -155,6 +155,7 @@ export function useTimelineProviderState({
|
|
|
155
155
|
trackStyles,
|
|
156
156
|
trackOrder,
|
|
157
157
|
trackOrderRef,
|
|
158
|
+
trackInsertLayoutRef,
|
|
158
159
|
laneCounts,
|
|
159
160
|
rowGeometry,
|
|
160
161
|
rowGeometryRef,
|
|
@@ -213,6 +214,7 @@ export function useTimelineProviderState({
|
|
|
213
214
|
ppsRef,
|
|
214
215
|
durationRef,
|
|
215
216
|
trackOrderRef,
|
|
217
|
+
trackInsertLayoutRef,
|
|
216
218
|
rowGeometryRef,
|
|
217
219
|
onMoveElement: pinnedOnMoveElement,
|
|
218
220
|
onMoveElements: pinnedOnMoveElements,
|
|
@@ -288,7 +290,7 @@ export function useTimelineProviderState({
|
|
|
288
290
|
allowHorizontal: zoomMode === "manual",
|
|
289
291
|
viewport,
|
|
290
292
|
sessionEpoch,
|
|
291
|
-
draggedRowKey:
|
|
293
|
+
draggedRowKey: displayLayout.draggedRowKey,
|
|
292
294
|
resizingElementIds,
|
|
293
295
|
clipContextMenuRowKey: clipContextMenu?.element.track,
|
|
294
296
|
keyframeContextMenuRowKey: kfContextMenu?.element.track,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import { createTimelineRowGeometry, type TimelineRowGeometry } from "./timelineLayout";
|
|
6
|
+
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
|
|
7
|
+
|
|
8
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
9
|
+
|
|
10
|
+
it("leaves the viewport still when a live preview lane is removed before the committed row arrives", () => {
|
|
11
|
+
const scroll = document.createElement("div");
|
|
12
|
+
scroll.scrollTop = 160;
|
|
13
|
+
const scrollRef = { current: scroll };
|
|
14
|
+
const lastScrollLeftRef = { current: 0 };
|
|
15
|
+
const host = document.createElement("div");
|
|
16
|
+
const root = createRoot(host);
|
|
17
|
+
const normal = createTimelineRowGeometry([0, 1, 2, 3, 4, 5], [48, 48, 48, 48, 48, 48]);
|
|
18
|
+
const preview = createTimelineRowGeometry([0, -1, 1, 2, 3, 4, 5], [48, 48, 48, 48, 48, 48, 48]);
|
|
19
|
+
const committed = createTimelineRowGeometry([0, 1, 2, 3, 4, 5, 6], [48, 48, 48, 48, 48, 48, 48]);
|
|
20
|
+
function Probe({
|
|
21
|
+
geometry,
|
|
22
|
+
draggedRowKey,
|
|
23
|
+
}: {
|
|
24
|
+
geometry: TimelineRowGeometry;
|
|
25
|
+
draggedRowKey?: number;
|
|
26
|
+
}) {
|
|
27
|
+
useTimelineRowVirtualization({
|
|
28
|
+
scrollRef,
|
|
29
|
+
rowGeometry: geometry,
|
|
30
|
+
draggedRowKey,
|
|
31
|
+
sessionEpoch: 0,
|
|
32
|
+
viewport: {
|
|
33
|
+
scrollTop: 160,
|
|
34
|
+
scrollLeft: 0,
|
|
35
|
+
clientWidth: 800,
|
|
36
|
+
clientHeight: 200,
|
|
37
|
+
scrollWidth: 800,
|
|
38
|
+
scrollHeight: 600,
|
|
39
|
+
isScrolling: false,
|
|
40
|
+
},
|
|
41
|
+
elements: [],
|
|
42
|
+
selectedElementId: null,
|
|
43
|
+
lastScrollLeftRef,
|
|
44
|
+
syncScrollViewport: () => {},
|
|
45
|
+
});
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
act(() => root.render(<Probe geometry={normal} />));
|
|
49
|
+
act(() => root.render(<Probe geometry={preview} draggedRowKey={-1} />));
|
|
50
|
+
expect(scroll.scrollTop).toBe(160);
|
|
51
|
+
act(() => root.render(<Probe geometry={normal} />));
|
|
52
|
+
expect(scroll.scrollTop).toBe(160);
|
|
53
|
+
act(() => root.render(<Probe geometry={committed} />));
|
|
54
|
+
expect(scroll.scrollTop).toBe(160);
|
|
55
|
+
act(() => root.unmount());
|
|
56
|
+
});
|
|
@@ -103,6 +103,7 @@ export function useTimelineRowVirtualization({
|
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
const previousLayoutRef = useRef(rowGeometry);
|
|
106
|
+
const previousDraggedRowKeyRef = useRef(draggedRowKey);
|
|
106
107
|
const previousSessionEpochRef = useRef(sessionEpoch);
|
|
107
108
|
useLayoutEffect(() => {
|
|
108
109
|
const scroll = scrollRef.current;
|
|
@@ -120,7 +121,7 @@ export function useTimelineRowVirtualization({
|
|
|
120
121
|
previousGeometry,
|
|
121
122
|
rowGeometry,
|
|
122
123
|
scroll.scrollTop,
|
|
123
|
-
draggedRowKey !== undefined,
|
|
124
|
+
draggedRowKey !== undefined || previousDraggedRowKeyRef.current !== undefined,
|
|
124
125
|
);
|
|
125
126
|
if (nextScrollTop !== scroll.scrollTop) {
|
|
126
127
|
scroll.scrollTop = nextScrollTop;
|
|
@@ -128,6 +129,7 @@ export function useTimelineRowVirtualization({
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
previousLayoutRef.current = rowGeometry;
|
|
132
|
+
previousDraggedRowKeyRef.current = draggedRowKey;
|
|
131
133
|
}, [draggedRowKey, lastScrollLeftRef, rowGeometry, scrollRef, sessionEpoch, syncScrollViewport]);
|
|
132
134
|
|
|
133
135
|
return {
|
|
@@ -15,7 +15,7 @@ import React, { act } from "react";
|
|
|
15
15
|
import { createRoot } from "react-dom/client";
|
|
16
16
|
import { afterEach, describe, expect, it } from "vitest";
|
|
17
17
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
18
|
-
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
|
18
|
+
import { displayTrackOrder, useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
|
19
19
|
|
|
20
20
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
21
21
|
|
|
@@ -100,4 +100,17 @@ describe("useTimelineTrackDerivations", () => {
|
|
|
100
100
|
const { groups } = derive([clip("sfx-1", 0, { audioGroup: "sfx" })]);
|
|
101
101
|
expect(groups[0]).toMatchObject({ id: "sfx", label: "sfx", volume: 1, hidden: false });
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
it("gives hosts the drawn row order, members left out while their group is collapsed", () => {
|
|
105
|
+
const elements = [
|
|
106
|
+
clip("vo-1", 0, { audioGroup: "voiceover" }),
|
|
107
|
+
clip("music", 1),
|
|
108
|
+
clip("vo-2", 2, { audioGroup: "voiceover" }),
|
|
109
|
+
];
|
|
110
|
+
expect(displayTrackOrder(elements, new Set())).toEqual(derive(elements).trackOrder);
|
|
111
|
+
|
|
112
|
+
usePlayerStore.setState({ collapsedGroupIds: new Set(["voiceover"]) });
|
|
113
|
+
expect(derive(elements).trackOrder).toEqual([-0.5, 1]);
|
|
114
|
+
expect(displayTrackOrder(elements, new Set(["voiceover"]))).toEqual([-0.5, 1]);
|
|
115
|
+
});
|
|
103
116
|
});
|
|
@@ -170,12 +170,38 @@ function groupTimelineTracks(
|
|
|
170
170
|
return { tracks, groups, trackGroupOf };
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/** The timeline's rows top to bottom, as drawn: tracks by number, each audio group's anchor
|
|
174
|
+
* row with its members under it. */
|
|
175
|
+
function timelineDisplayTracks(
|
|
176
|
+
elements: readonly TimelineElement[],
|
|
177
|
+
collapsedGroupIds: ReadonlySet<string>,
|
|
178
|
+
) {
|
|
179
|
+
const byTrack = new Map<number, TimelineElement[]>();
|
|
180
|
+
for (const el of elements) {
|
|
181
|
+
const list = byTrack.get(el.track) ?? [];
|
|
182
|
+
list.push(el);
|
|
183
|
+
byTrack.set(el.track, list);
|
|
184
|
+
}
|
|
185
|
+
const rawTracks = Array.from(byTrack.entries()).sort(([a], [b]) => a - b);
|
|
186
|
+
const grouped = groupTimelineTracks(rawTracks, collapsedGroupIds);
|
|
187
|
+
return { ...grouped, trackOrder: grouped.tracks.map(([trackNum]) => trackNum) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The track number of each timeline row, top to bottom: pass the player store's `elements` and
|
|
192
|
+
* `collapsedGroupIds`. A group's anchor row is its first member's track minus 0.5.
|
|
193
|
+
*/
|
|
194
|
+
export function displayTrackOrder(
|
|
195
|
+
elements: readonly TimelineElement[],
|
|
196
|
+
collapsedGroupIds: ReadonlySet<string>,
|
|
197
|
+
): number[] {
|
|
198
|
+
return timelineDisplayTracks(elements, collapsedGroupIds).trackOrder;
|
|
199
|
+
}
|
|
200
|
+
|
|
173
201
|
/**
|
|
174
202
|
* Per-render track derivations Timeline.tsx feeds the canvas/lanes: the lane →
|
|
175
203
|
* clip grouping (`tracks`, group-aware order), per-lane visual styles, the
|
|
176
|
-
* matching `trackOrder`, and audio-group membership.
|
|
177
|
-
* Timeline.tsx as a cohesive unit (600-line studio cap); each memo keys on the
|
|
178
|
-
* expanded display element set exactly as before.
|
|
204
|
+
* matching `trackOrder`, and audio-group membership.
|
|
179
205
|
*/
|
|
180
206
|
export function useTimelineTrackDerivations(expandedElements: TimelineElement[]): {
|
|
181
207
|
tracks: [number, TimelineElement[]][];
|
|
@@ -184,20 +210,10 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
|
|
|
184
210
|
groups: TimelineTrackGroupInfo[];
|
|
185
211
|
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
|
|
186
212
|
} {
|
|
187
|
-
const rawTracks = useMemo(() => {
|
|
188
|
-
const map = new Map<number, TimelineElement[]>();
|
|
189
|
-
for (const el of expandedElements) {
|
|
190
|
-
const list = map.get(el.track) ?? [];
|
|
191
|
-
list.push(el);
|
|
192
|
-
map.set(el.track, list);
|
|
193
|
-
}
|
|
194
|
-
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
|
195
|
-
}, [expandedElements]);
|
|
196
|
-
|
|
197
213
|
const collapsedGroupIds = usePlayerStore((s) => s.collapsedGroupIds);
|
|
198
|
-
const { tracks, groups, trackGroupOf } = useMemo(
|
|
199
|
-
() =>
|
|
200
|
-
[
|
|
214
|
+
const { tracks, groups, trackGroupOf, trackOrder } = useMemo(
|
|
215
|
+
() => timelineDisplayTracks(expandedElements, collapsedGroupIds),
|
|
216
|
+
[expandedElements, collapsedGroupIds],
|
|
201
217
|
);
|
|
202
218
|
|
|
203
219
|
const trackStyles = useMemo(() => {
|
|
@@ -208,7 +224,5 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
|
|
|
208
224
|
return map;
|
|
209
225
|
}, [tracks]);
|
|
210
226
|
|
|
211
|
-
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
|
|
212
|
-
|
|
213
227
|
return { tracks, trackStyles, trackOrder, groups, trackGroupOf };
|
|
214
228
|
}
|
|
@@ -6,6 +6,7 @@ import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneDa
|
|
|
6
6
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
7
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
8
8
|
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
|
9
|
+
import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
|
|
9
10
|
import {
|
|
10
11
|
TRACK_H,
|
|
11
12
|
createTimelineRowGeometry,
|
|
@@ -246,6 +247,12 @@ export function useTimelineTrackLayout(
|
|
|
246
247
|
useTimelineTrackDerivations(expandedElements);
|
|
247
248
|
const trackOrderRef = useRef(trackOrder);
|
|
248
249
|
trackOrderRef.current = trackOrder;
|
|
250
|
+
const trackInsertLayout = useMemo(
|
|
251
|
+
() => (groups.length > 0 ? buildTimelineTrackInsertLayout(trackOrder, groups) : undefined),
|
|
252
|
+
[trackOrder, groups],
|
|
253
|
+
);
|
|
254
|
+
const trackInsertLayoutRef = useRef(trackInsertLayout);
|
|
255
|
+
trackInsertLayoutRef.current = trackInsertLayout;
|
|
249
256
|
const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
|
|
250
257
|
tracks,
|
|
251
258
|
gsapAnimations,
|
|
@@ -260,6 +267,7 @@ export function useTimelineTrackLayout(
|
|
|
260
267
|
trackStyles,
|
|
261
268
|
trackOrder,
|
|
262
269
|
trackOrderRef,
|
|
270
|
+
trackInsertLayoutRef,
|
|
263
271
|
laneCounts,
|
|
264
272
|
rowGeometry,
|
|
265
273
|
rowGeometryRef,
|
|
@@ -283,27 +291,40 @@ function useDisplayRowHeights(
|
|
|
283
291
|
);
|
|
284
292
|
}
|
|
285
293
|
|
|
294
|
+
function insertPreviewTrack(trackOrder: number[], preview: number) {
|
|
295
|
+
const result: number[] = [];
|
|
296
|
+
let inserted = false;
|
|
297
|
+
for (const key of trackOrder) {
|
|
298
|
+
if (!inserted && Number.isInteger(key) && key > preview) {
|
|
299
|
+
result.push(preview);
|
|
300
|
+
inserted = true;
|
|
301
|
+
}
|
|
302
|
+
result.push(key);
|
|
303
|
+
}
|
|
304
|
+
if (!inserted) result.push(preview);
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
|
|
286
308
|
function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
|
|
309
|
+
const started = draggedClip?.started === true;
|
|
310
|
+
const insertRow = draggedClip?.insertRow ?? null;
|
|
311
|
+
const previewTrack = draggedClip?.previewTrack;
|
|
287
312
|
return useMemo(() => {
|
|
288
|
-
if (!
|
|
313
|
+
if (!started || previewTrack === undefined) return trackOrder;
|
|
314
|
+
if (insertRow !== null) {
|
|
315
|
+
const pendingKey = trackOrder.reduce((lowest, key) => Math.min(lowest, key), 0) - 1;
|
|
316
|
+
const displayOrder = [...trackOrder];
|
|
317
|
+
displayOrder.splice(insertRow, 0, pendingKey);
|
|
318
|
+
return displayOrder;
|
|
319
|
+
}
|
|
320
|
+
if (trackOrder.includes(previewTrack)) return trackOrder;
|
|
289
321
|
// A group's members sit out of raw numeric order (pulled under their
|
|
290
322
|
// anchor row), so a plain numeric sort here would undo that grouping the
|
|
291
323
|
// moment a clip drags onto a brand-new track. Insert the new preview
|
|
292
324
|
// track only relative to other REAL (integer) tracks, leaving any
|
|
293
325
|
// fractional group-anchor keys exactly where grouping placed them.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
let inserted = false;
|
|
297
|
-
for (const key of trackOrder) {
|
|
298
|
-
if (!inserted && Number.isInteger(key) && key > preview) {
|
|
299
|
-
result.push(preview);
|
|
300
|
-
inserted = true;
|
|
301
|
-
}
|
|
302
|
-
result.push(key);
|
|
303
|
-
}
|
|
304
|
-
if (!inserted) result.push(preview);
|
|
305
|
-
return result;
|
|
306
|
-
}, [draggedClip, trackOrder]);
|
|
326
|
+
return insertPreviewTrack(trackOrder, previewTrack);
|
|
327
|
+
}, [started, insertRow, previewTrack, trackOrder]);
|
|
307
328
|
}
|
|
308
329
|
|
|
309
330
|
export function useTimelineDisplayLayout(
|
|
@@ -317,7 +338,15 @@ export function useTimelineDisplayLayout(
|
|
|
317
338
|
() => createTimelineRowGeometry(displayTrackOrder, displayRowHeights, rowGeometry.padding),
|
|
318
339
|
[displayTrackOrder, displayRowHeights, rowGeometry.padding],
|
|
319
340
|
);
|
|
341
|
+
let draggedRowKey: number | undefined;
|
|
342
|
+
if (draggedClip?.started) {
|
|
343
|
+
draggedRowKey =
|
|
344
|
+
draggedClip.insertRow !== null
|
|
345
|
+
? displayRowGeometry.rowKeys[draggedClip.insertRow]
|
|
346
|
+
: draggedClip.previewTrack;
|
|
347
|
+
}
|
|
320
348
|
return {
|
|
349
|
+
draggedRowKey,
|
|
321
350
|
displayTrackOrder,
|
|
322
351
|
displayRowHeights: displayRowGeometry.rowHeights,
|
|
323
352
|
rowGeometry: displayRowGeometry,
|