@hyperframes/studio 0.8.91 → 0.8.92
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-zT2Bdx_l.js → hyperframes-player-DKJPDHUp.js} +1 -1
- package/dist/assets/{index-V-0rfhe6.js → index-C5_j5-3u.js} +153 -153
- package/dist/assets/{index-DSOLcE8X.js → index-CHZ4kFWm.js} +1 -1
- package/dist/assets/{index-DV-z19zB.js → index-DpzpqJ_j.js} +1 -1
- package/dist/index.d.ts +6 -11
- package/dist/index.html +1 -1
- package/dist/index.js +8770 -8791
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
- package/src/hooks/useTimelineAssetDropOps.ts +40 -18
- package/src/player/components/Timeline.rulerSeek.test.tsx +7 -7
- package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
- package/src/player/components/timelineClipDragCommit.test.ts +14 -58
- package/src/player/components/timelineClipDragCommit.ts +14 -37
- package/src/player/components/timelineClipDragPreview.test.ts +213 -22
- package/src/player/components/timelineClipDragPreview.ts +46 -81
- package/src/player/components/timelineClipDragTypes.ts +3 -8
- package/src/player/components/timelineCollision.test.ts +100 -302
- package/src/player/components/timelineCollision.ts +73 -160
- package/src/player/components/timelineDragDrop.test.tsx +19 -5
- package/src/player/components/timelineDragDrop.ts +7 -9
- package/src/player/components/timelineDragLanding.ts +0 -49
- package/src/player/components/timelineLayout.test.ts +0 -25
- package/src/player/components/timelineLayout.ts +0 -16
- package/src/player/components/timelineMultiDragPreview.test.ts +34 -25
- package/src/player/components/timelineMultiDragPreview.ts +25 -27
- package/src/player/components/timelineProviderStateBuilders.ts +7 -4
- package/src/player/components/timelineShellMeta.tsx +99 -0
- package/src/player/components/useTimelineDragPreviewState.ts +34 -0
- package/src/player/components/useTimelineProviderState.tsx +29 -67
- package/src/utils/timelineDropTrackInsert.test.ts +60 -1
- package/src/utils/timelineDropTrackInsert.ts +36 -5
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { clampToHostStart } from "../store/timelineElement";
|
|
3
|
+
import { canMoveTimelineElement } from "./timelineAuthoredMoveTarget";
|
|
4
|
+
|
|
1
5
|
/**
|
|
2
6
|
* Pure geometry for the LIVE multi-selection drag preview.
|
|
3
7
|
*
|
|
@@ -9,11 +13,11 @@
|
|
|
9
13
|
* behind individually — the whole formation moves by one delta, spacing locked.
|
|
10
14
|
*
|
|
11
15
|
* That single delta is the GRABBED clip's `draggedPreviewStart − draggedOriginStart`.
|
|
12
|
-
* The preview start is ALREADY group-clamped upstream (
|
|
13
|
-
*
|
|
16
|
+
* The preview start is ALREADY group-clamped upstream (computeDragPreview floors it
|
|
17
|
+
* at groupMoveFloor), so this delta is the clamped delta:
|
|
14
18
|
* the instant any member would cross 0 the grabbed clip stops and every passenger
|
|
15
19
|
* stops with it — the formation never deforms. On DROP the commit shifts every
|
|
16
|
-
*
|
|
20
|
+
* moving clip (resolveGroupMovers) by this same delta (see timelineClipDragCommit).
|
|
17
21
|
*
|
|
18
22
|
* Track changes apply to the grabbed clip only (mirroring the commit); passengers
|
|
19
23
|
* keep their lanes, so only their x moves.
|
|
@@ -81,29 +85,23 @@ export function multiDragPassengerOffsetPx(
|
|
|
81
85
|
return multiDragDeltaSeconds(input) * pps;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
export function clampGroupMoveDelta(
|
|
101
|
-
rawDelta: number,
|
|
102
|
-
members: readonly { start: number; minStart?: number }[],
|
|
88
|
+
/** The selected clips a group drag moves: a locked clip swept into the selection stays put.
|
|
89
|
+
* Null when the grabbed clip is not part of a multi-selection. */
|
|
90
|
+
export function resolveGroupMovers(
|
|
91
|
+
elements: readonly TimelineElement[],
|
|
92
|
+
selectedKeys: ReadonlySet<string> | null | undefined,
|
|
93
|
+
dragKey: string,
|
|
94
|
+
): TimelineElement[] | null {
|
|
95
|
+
if (!selectedKeys || selectedKeys.size <= 1 || !selectedKeys.has(dragKey)) return null;
|
|
96
|
+
return elements.filter((e) => selectedKeys.has(e.key ?? e.id) && canMoveTimelineElement(e));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The lowest start the grabbed clip may take so the whole group moves rigidly and no clip
|
|
100
|
+
* crosses its own floor, its host composition's start (the commit floors each one there). */
|
|
101
|
+
export function groupMoveFloor(
|
|
102
|
+
grabbed: TimelineElement,
|
|
103
|
+
movers: readonly TimelineElement[],
|
|
103
104
|
): number {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const room = Math.min(...members.map((m) => m.start - (m.minStart ?? 0)));
|
|
107
|
-
const minDelta = room === 0 ? 0 : -room; // avoid -0
|
|
108
|
-
return rawDelta < minDelta ? minDelta : rawDelta;
|
|
105
|
+
const room = Math.min(...[grabbed, ...movers].map((e) => e.start - clampToHostStart(e, 0)));
|
|
106
|
+
return grabbed.start - room;
|
|
109
107
|
}
|
|
@@ -5,23 +5,26 @@ import type {
|
|
|
5
5
|
} from "./TimelineProvider";
|
|
6
6
|
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
7
7
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
8
|
-
import type
|
|
8
|
+
import { resolveGroupMovers, type MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
|
9
9
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
10
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
10
11
|
|
|
11
12
|
export function resolveMultiDragPreview(
|
|
12
13
|
draggedClip: DraggedClipState | null,
|
|
13
14
|
selectedKeys: ReadonlySet<string>,
|
|
15
|
+
elements: readonly TimelineElement[],
|
|
14
16
|
): MultiDragPreviewInput | null {
|
|
15
|
-
// The dragged clip is a free ghost;
|
|
16
|
-
// clamped delta so the formation stays rigid
|
|
17
|
+
// The dragged clip is a free ghost; the clips that move with it follow the same
|
|
18
|
+
// clamped delta so the formation stays rigid. A locked selected clip stays put.
|
|
17
19
|
if (!draggedClip?.started) return null;
|
|
18
20
|
const draggedKey = getTimelineElementIdentity(draggedClip.element);
|
|
21
|
+
const movers = resolveGroupMovers(elements, selectedKeys, draggedKey);
|
|
19
22
|
return {
|
|
20
23
|
dragStarted: true,
|
|
21
24
|
draggedKey,
|
|
22
25
|
draggedOriginStart: draggedClip.element.start,
|
|
23
26
|
draggedPreviewStart: draggedClip.previewStart,
|
|
24
|
-
selectedKeys,
|
|
27
|
+
selectedKeys: movers ? new Set(movers.map(getTimelineElementIdentity)) : selectedKeys,
|
|
25
28
|
};
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { MutableRefObject, PointerEvent } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
TimelineContainerProps,
|
|
4
|
+
TimelineMeta,
|
|
5
|
+
TimelineViewportProps,
|
|
6
|
+
} from "./TimelineProvider";
|
|
7
|
+
import {
|
|
8
|
+
buildTimelineMeta,
|
|
9
|
+
shouldIgnoreTimelinePointerDown,
|
|
10
|
+
} from "./timelineProviderStateBuilders";
|
|
11
|
+
import { TimelineRazorGuideOverlay } from "./TimelineRazorInteraction";
|
|
12
|
+
import { LABEL_COL_W } from "./timelineLayout";
|
|
13
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
14
|
+
|
|
15
|
+
type ViewportHandlers = Pick<
|
|
16
|
+
TimelineViewportProps,
|
|
17
|
+
"onDragOver" | "onDragLeave" | "onDrop" | "onPointerMove" | "onPointerUp" | "onPointerCancel"
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
export interface TimelineShellMetaInputs {
|
|
21
|
+
isDragOver: boolean;
|
|
22
|
+
hasFileDrop: boolean;
|
|
23
|
+
drop: Pick<ViewportHandlers, "onDragOver" | "onDragLeave" | "onDrop">;
|
|
24
|
+
pointer: Pick<ViewportHandlers, "onPointerMove" | "onPointerUp" | "onPointerCancel"> & {
|
|
25
|
+
onPointerDown: TimelineViewportProps["onPointerDown"];
|
|
26
|
+
};
|
|
27
|
+
setContainerRef: TimelineContainerProps["ref"];
|
|
28
|
+
setScrollRef: TimelineViewportProps["ref"];
|
|
29
|
+
focusProps: Pick<TimelineViewportProps, "onFocus" | "onBlur">;
|
|
30
|
+
elementCount: number;
|
|
31
|
+
activeTool: string;
|
|
32
|
+
shiftHeld: boolean;
|
|
33
|
+
labelMode: boolean;
|
|
34
|
+
contentOrigin: number;
|
|
35
|
+
zoomMode: "fit" | "manual";
|
|
36
|
+
theme: Pick<TimelineTheme, "shellBackground" | "shellBorder">;
|
|
37
|
+
razor: {
|
|
38
|
+
razorGuideX: number | null;
|
|
39
|
+
updateRazorGuide: TimelineContainerProps["onMouseMove"];
|
|
40
|
+
clearRazorGuide: () => void;
|
|
41
|
+
splitAllAtPointer: (event: PointerEvent<HTMLDivElement>) => boolean;
|
|
42
|
+
};
|
|
43
|
+
lastScrollLeftRef: MutableRefObject<number>;
|
|
44
|
+
recordTimelineScroll: (scroll: HTMLDivElement) => void;
|
|
45
|
+
syncScrollViewport: (scroll: HTMLDivElement, isScrolling?: boolean) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The timeline shell's container and scroll viewport props: drop, pointer, scroll and razor wiring. */
|
|
49
|
+
export function buildTimelineShellMeta(input: TimelineShellMetaInputs): TimelineMeta {
|
|
50
|
+
const { drop, pointer, razor, theme, activeTool } = input;
|
|
51
|
+
return buildTimelineMeta({
|
|
52
|
+
emptyState: { isDragOver: input.isDragOver, onFileDrop: input.hasFileDrop, ...drop },
|
|
53
|
+
container: {
|
|
54
|
+
ref: input.setContainerRef,
|
|
55
|
+
"aria-label": "Timeline track view",
|
|
56
|
+
"data-timeline-element-count": input.elementCount,
|
|
57
|
+
isDragOver: input.isDragOver,
|
|
58
|
+
activeTool,
|
|
59
|
+
shiftHeld: input.shiftHeld,
|
|
60
|
+
accentClass: "ring-1 ring-inset ring-studio-accent/60",
|
|
61
|
+
onMouseMove: razor.updateRazorGuide,
|
|
62
|
+
onMouseLeave: razor.clearRazorGuide,
|
|
63
|
+
style: {
|
|
64
|
+
touchAction: "pan-x pan-y",
|
|
65
|
+
background: theme.shellBackground,
|
|
66
|
+
borderColor: theme.shellBorder,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
viewport: {
|
|
70
|
+
ref: input.setScrollRef,
|
|
71
|
+
tabIndex: -1,
|
|
72
|
+
labelMode: input.labelMode,
|
|
73
|
+
contentOrigin: input.contentOrigin,
|
|
74
|
+
zoomMode: input.zoomMode,
|
|
75
|
+
onScroll: (e) => {
|
|
76
|
+
input.lastScrollLeftRef.current = e.currentTarget.scrollLeft;
|
|
77
|
+
input.recordTimelineScroll(e.currentTarget);
|
|
78
|
+
input.syncScrollViewport(e.currentTarget, true);
|
|
79
|
+
},
|
|
80
|
+
...input.focusProps,
|
|
81
|
+
...drop,
|
|
82
|
+
onPointerDown: (e) => {
|
|
83
|
+
if (shouldIgnoreTimelinePointerDown(e.target)) return;
|
|
84
|
+
if (razor.splitAllAtPointer(e)) return;
|
|
85
|
+
pointer.onPointerDown(e);
|
|
86
|
+
},
|
|
87
|
+
onPointerMove: pointer.onPointerMove,
|
|
88
|
+
onPointerUp: pointer.onPointerUp,
|
|
89
|
+
onPointerCancel: pointer.onPointerCancel,
|
|
90
|
+
onLostPointerCapture: pointer.onPointerCancel,
|
|
91
|
+
},
|
|
92
|
+
elementCount: input.elementCount,
|
|
93
|
+
labelColumnWidth: LABEL_COL_W,
|
|
94
|
+
razorGuide:
|
|
95
|
+
activeTool === "razor" && razor.razorGuideX !== null ? (
|
|
96
|
+
<TimelineRazorGuideOverlay x={razor.razorGuideX} />
|
|
97
|
+
) : null,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
4
|
+
import type { ResizingClipState } from "./useTimelineClipDrag";
|
|
5
|
+
import {
|
|
6
|
+
resolveMultiDragPreview,
|
|
7
|
+
resolveResizingElementIds,
|
|
8
|
+
} from "./timelineProviderStateBuilders";
|
|
9
|
+
import { getTimelinePreviewElement } from "./timelineViewModel";
|
|
10
|
+
import { resolveSnapGuide } from "./timelineSnapping";
|
|
11
|
+
|
|
12
|
+
/** What the lanes draw for a live move or trim: the ghost, the clips riding along, the snap guide. */
|
|
13
|
+
export function useTimelineDragPreviewState(
|
|
14
|
+
draggedClip: DraggedClipState | null,
|
|
15
|
+
resizingClip: ResizingClipState | null,
|
|
16
|
+
selectedElementIds: ReadonlySet<string>,
|
|
17
|
+
timelineElements: readonly TimelineElement[],
|
|
18
|
+
) {
|
|
19
|
+
const getPreviewElement = useCallback(
|
|
20
|
+
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
|
|
21
|
+
[resizingClip],
|
|
22
|
+
);
|
|
23
|
+
const multiDragPreview = useMemo(
|
|
24
|
+
() => resolveMultiDragPreview(draggedClip, selectedElementIds, timelineElements),
|
|
25
|
+
[draggedClip, selectedElementIds, timelineElements],
|
|
26
|
+
);
|
|
27
|
+
return {
|
|
28
|
+
resizingElementIds: resolveResizingElementIds(resizingClip),
|
|
29
|
+
getPreviewElement,
|
|
30
|
+
draggedElement: draggedClip?.element ?? null,
|
|
31
|
+
multiDragPreview,
|
|
32
|
+
snapGuide: resolveSnapGuide(draggedClip, resizingClip),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useRef, useMemo, useCallback, useState } from "react";
|
|
2
2
|
import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
|
|
3
|
-
import { usePlayerStore
|
|
3
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
4
4
|
import { defaultTimelineTheme } from "./timelineTheme";
|
|
5
5
|
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
|
6
6
|
import { useTimelineZoom } from "./useTimelineZoom";
|
|
@@ -8,18 +8,14 @@ import { useTimelineAssetDrop } from "./timelineDragDrop";
|
|
|
8
8
|
import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
|
9
9
|
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
|
10
10
|
import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvider";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
resolveMultiDragPreview,
|
|
14
|
-
resolveResizingElementIds,
|
|
15
|
-
shouldIgnoreTimelinePointerDown,
|
|
16
|
-
} from "./timelineProviderStateBuilders";
|
|
11
|
+
import { buildTimelineShellMeta } from "./timelineShellMeta";
|
|
12
|
+
import { useTimelineDragPreviewState } from "./useTimelineDragPreviewState";
|
|
17
13
|
import { useTimelineOverlaysState } from "./useTimelineOverlaysState";
|
|
18
14
|
import { useTimelineEditPinning } from "./useTimelineEditPinning";
|
|
19
15
|
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
|
20
16
|
import { useTimelineGeometry } from "./useTimelineGeometry";
|
|
21
17
|
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
|
22
|
-
import { GUTTER
|
|
18
|
+
import { GUTTER } from "./timelineLayout";
|
|
23
19
|
import { useTimelineLabelColumn } from "./useTimelineLabelColumn";
|
|
24
20
|
import { useTimelineKeyframeData } from "./useTimelineKeyframeData";
|
|
25
21
|
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
|
@@ -33,9 +29,9 @@ import {
|
|
|
33
29
|
} from "./useTimelineTrackLayout";
|
|
34
30
|
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
|
35
31
|
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
|
36
|
-
import {
|
|
32
|
+
import { useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
|
37
33
|
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
|
|
38
|
-
import { getEffectiveTimelineDuration
|
|
34
|
+
import { getEffectiveTimelineDuration } from "./timelineViewModel";
|
|
39
35
|
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
|
40
36
|
import { useTimelineTicks } from "./useTimelineTicks";
|
|
41
37
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
@@ -44,7 +40,6 @@ import { useTimelineActiveClips } from "./useTimelineActiveClips";
|
|
|
44
40
|
import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
|
|
45
41
|
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
|
|
46
42
|
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
47
|
-
import { resolveSnapGuide } from "./timelineSnapping";
|
|
48
43
|
export function useTimelineProviderState({
|
|
49
44
|
onSeek,
|
|
50
45
|
onDrillDown,
|
|
@@ -107,8 +102,7 @@ export function useTimelineProviderState({
|
|
|
107
102
|
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
|
108
103
|
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
|
109
104
|
const { gsapAnimations, keyframeCache } = useTimelineKeyframeData(showKeyframes);
|
|
110
|
-
const
|
|
111
|
-
const { labelMode, contentOrigin } = useTimelineLabelColumn(namedAnimations, timelineElements);
|
|
105
|
+
const { labelMode, contentOrigin } = useTimelineLabelColumn(gsapAnimations, timelineElements);
|
|
112
106
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
113
107
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
114
108
|
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
|
@@ -223,7 +217,8 @@ export function useTimelineProviderState({
|
|
|
223
217
|
sessionEpoch,
|
|
224
218
|
});
|
|
225
219
|
const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry);
|
|
226
|
-
const resizingElementIds =
|
|
220
|
+
const { resizingElementIds, getPreviewElement, draggedElement, multiDragPreview, snapGuide } =
|
|
221
|
+
useTimelineDragPreviewState(draggedClip, resizingClip, selectedElementIds, timelineElements);
|
|
227
222
|
const { recordTimelineScroll } = useTimelinePerformanceTelemetry({
|
|
228
223
|
totalClipCount: timelineElements.length,
|
|
229
224
|
totalRowCount: displayLayout.displayTrackOrder.length,
|
|
@@ -422,12 +417,6 @@ export function useTimelineProviderState({
|
|
|
422
417
|
timeDisplayMode,
|
|
423
418
|
timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
|
|
424
419
|
);
|
|
425
|
-
const getPreviewElement = useCallback(
|
|
426
|
-
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
|
|
427
|
-
[resizingClip],
|
|
428
|
-
);
|
|
429
|
-
const draggedElement = draggedClip?.element ?? null;
|
|
430
|
-
const multiDragPreview = resolveMultiDragPreview(draggedClip, selectedElementIds);
|
|
431
420
|
const canvasProps = {
|
|
432
421
|
major,
|
|
433
422
|
minor,
|
|
@@ -502,7 +491,7 @@ export function useTimelineProviderState({
|
|
|
502
491
|
onMoveElement,
|
|
503
492
|
beatDragging,
|
|
504
493
|
draggedElement,
|
|
505
|
-
snapGuide
|
|
494
|
+
snapGuide,
|
|
506
495
|
multiDragPreview,
|
|
507
496
|
onToggleTrackHidden: editContext.onToggleTrackHidden,
|
|
508
497
|
onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
|
|
@@ -520,61 +509,34 @@ export function useTimelineProviderState({
|
|
|
520
509
|
)),
|
|
521
510
|
[holdNewClipContent, renderClipContent],
|
|
522
511
|
);
|
|
523
|
-
const timelineMeta =
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
512
|
+
const timelineMeta = buildTimelineShellMeta({
|
|
513
|
+
isDragOver: assetDrop.isDragOver,
|
|
514
|
+
hasFileDrop: !!onFileDrop,
|
|
515
|
+
drop: {
|
|
527
516
|
onDragOver: assetDrop.handleAssetDragOver,
|
|
528
517
|
onDragLeave: assetDrop.handleAssetDragLeave,
|
|
529
518
|
onDrop: assetDrop.handleAssetDrop,
|
|
530
519
|
},
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
"aria-label": "Timeline track view",
|
|
534
|
-
"data-timeline-element-count": timelineElements.length,
|
|
535
|
-
isDragOver: assetDrop.isDragOver,
|
|
536
|
-
activeTool,
|
|
537
|
-
shiftHeld,
|
|
538
|
-
accentClass: "ring-1 ring-inset ring-studio-accent/60",
|
|
539
|
-
onMouseMove: updateRazorGuide,
|
|
540
|
-
onMouseLeave: clearRazorGuide,
|
|
541
|
-
style: {
|
|
542
|
-
touchAction: "pan-x pan-y",
|
|
543
|
-
background: theme.shellBackground,
|
|
544
|
-
borderColor: theme.shellBorder,
|
|
545
|
-
},
|
|
546
|
-
},
|
|
547
|
-
viewport: {
|
|
548
|
-
ref: setScrollRef,
|
|
549
|
-
tabIndex: -1,
|
|
550
|
-
labelMode,
|
|
551
|
-
contentOrigin,
|
|
552
|
-
zoomMode,
|
|
553
|
-
onScroll: (e) => {
|
|
554
|
-
lastScrollLeftRef.current = e.currentTarget.scrollLeft;
|
|
555
|
-
recordTimelineScroll(e.currentTarget);
|
|
556
|
-
syncScrollViewport(e.currentTarget, true);
|
|
557
|
-
},
|
|
558
|
-
...timelineFocus.timelineFocusProps,
|
|
559
|
-
onDragOver: assetDrop.handleAssetDragOver,
|
|
560
|
-
onDragLeave: assetDrop.handleAssetDragLeave,
|
|
561
|
-
onDrop: assetDrop.handleAssetDrop,
|
|
562
|
-
onPointerDown: (e) => {
|
|
563
|
-
if (shouldIgnoreTimelinePointerDown(e.target)) return;
|
|
564
|
-
if (splitAllAtPointer(e)) return;
|
|
565
|
-
handlePointerDown(e);
|
|
566
|
-
},
|
|
520
|
+
pointer: {
|
|
521
|
+
onPointerDown: handlePointerDown,
|
|
567
522
|
onPointerMove: handlePointerMove,
|
|
568
523
|
onPointerUp: handlePointerUp,
|
|
569
524
|
onPointerCancel: handlePointerCancel,
|
|
570
|
-
onLostPointerCapture: handlePointerCancel,
|
|
571
525
|
},
|
|
526
|
+
setContainerRef,
|
|
527
|
+
setScrollRef,
|
|
528
|
+
focusProps: timelineFocus.timelineFocusProps,
|
|
572
529
|
elementCount: timelineElements.length,
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
530
|
+
activeTool,
|
|
531
|
+
shiftHeld,
|
|
532
|
+
labelMode,
|
|
533
|
+
contentOrigin,
|
|
534
|
+
zoomMode,
|
|
535
|
+
theme,
|
|
536
|
+
razor: { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer },
|
|
537
|
+
lastScrollLeftRef,
|
|
538
|
+
recordTimelineScroll,
|
|
539
|
+
syncScrollViewport,
|
|
578
540
|
});
|
|
579
541
|
const contextValue: TimelineContextValue = {
|
|
580
542
|
state: {
|
|
@@ -99,6 +99,54 @@ describe("applyTrackRenumbers", () => {
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
describe("resolveDropTrack", () => {
|
|
102
|
+
// Title shows on row 0; Music is written on track 0 too but shows on audio row 1.
|
|
103
|
+
const shown = (
|
|
104
|
+
id: string,
|
|
105
|
+
tag: string,
|
|
106
|
+
row: number,
|
|
107
|
+
track: number,
|
|
108
|
+
start: number,
|
|
109
|
+
length: number,
|
|
110
|
+
) => ({
|
|
111
|
+
...clip(id, row, start),
|
|
112
|
+
tag,
|
|
113
|
+
duration: length,
|
|
114
|
+
authoredTrack: track,
|
|
115
|
+
});
|
|
116
|
+
const title = shown("title", "div", 0, 0, 0, 10);
|
|
117
|
+
const music = shown("music", "audio", 1, 0, 2, 3);
|
|
118
|
+
const song = { id: "song", tag: "audio", start: 2, duration: 3 };
|
|
119
|
+
const drop = (elements: TimelineElement[], row: number, dropped = song) =>
|
|
120
|
+
resolveDropTrack({ source, elements, placement: { track: row }, dropped });
|
|
121
|
+
|
|
122
|
+
it("joins an audio row's file track at the nearest free time, not a new track", () => {
|
|
123
|
+
expect(drop([title, music], 1)).toMatchObject({ track: 0, start: 5 });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("keeps an audio file dropped on a visual row off the audio clip on the track it is written to", () => {
|
|
127
|
+
expect(drop([title, music], 0)).toMatchObject({ track: 0, start: 5 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("writes a drop on a shown row to that row's file track and checks the clips there", () => {
|
|
131
|
+
const top = shown("top", "div", 0, 0, 0, 4);
|
|
132
|
+
const lower = shown("lower", "div", 1, 2, 0, 4); // file track 2, shown as row 1
|
|
133
|
+
const image = { id: "image", tag: "img", start: 1, duration: 3 };
|
|
134
|
+
expect(drop([top, lower], 1, image)).toMatchObject({ track: 2, start: 4 });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("lets only clips of the dropped file's kind block it on the row", () => {
|
|
138
|
+
const title = clip("title", 0, 0);
|
|
139
|
+
const music = { ...clip("music", 0, 0), tag: "audio" };
|
|
140
|
+
const song = { id: "song", tag: "audio", start: 1, duration: 3 };
|
|
141
|
+
// An audio file shows in the audio rows wherever it is written, so a visual clip never blocks it.
|
|
142
|
+
expect(
|
|
143
|
+
resolveDropTrack({ source, elements: [title], placement: { track: 0 }, dropped: song }).start,
|
|
144
|
+
).toBe(1);
|
|
145
|
+
expect(
|
|
146
|
+
resolveDropTrack({ source, elements: [music], placement: { track: 0 }, dropped: song }).start,
|
|
147
|
+
).toBe(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
102
150
|
it("keeps the aimed lane and the source when no insert is asked for", () => {
|
|
103
151
|
const out = resolveDropTrack({
|
|
104
152
|
source,
|
|
@@ -106,7 +154,17 @@ describe("resolveDropTrack", () => {
|
|
|
106
154
|
placement: { track: 2 },
|
|
107
155
|
dropped,
|
|
108
156
|
});
|
|
109
|
-
expect(out).toEqual({ source, track: 2 });
|
|
157
|
+
expect(out).toEqual({ source, track: 2, start: 5 });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("moves a drop on an occupied row to that row's nearest free time, with no new track", () => {
|
|
161
|
+
const out = resolveDropTrack({
|
|
162
|
+
source,
|
|
163
|
+
elements,
|
|
164
|
+
placement: { track: 1 },
|
|
165
|
+
dropped: { ...dropped, start: 1 },
|
|
166
|
+
});
|
|
167
|
+
expect(out).toEqual({ source, track: 1, start: 2 });
|
|
110
168
|
});
|
|
111
169
|
|
|
112
170
|
it("returns the new lane and the source with the lanes below pushed down", () => {
|
|
@@ -117,6 +175,7 @@ describe("resolveDropTrack", () => {
|
|
|
117
175
|
dropped,
|
|
118
176
|
});
|
|
119
177
|
expect(out.track).toBe(1);
|
|
178
|
+
expect(out.start).toBe(5);
|
|
120
179
|
expect(out.source).toContain('id="b" data-start="0" data-track-index="2"');
|
|
121
180
|
});
|
|
122
181
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { TimelineElement } from "../player";
|
|
2
2
|
import { layoutAfterTrackInsert } from "../player/components/timelineDragLanding";
|
|
3
3
|
import { canMoveTimelineElement } from "../player/components/timelineAuthoredMoveTarget";
|
|
4
|
+
import { resolveNearestFreeStart } from "../player/components/timelineCollision";
|
|
5
|
+
import { authoredTrackForLane } from "../player/components/timelineAuthoredTrack";
|
|
6
|
+
import { isAudioTimelineElement } from "./timelineInspector";
|
|
4
7
|
import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
|
|
5
8
|
import { applyPatchByTarget, readAttributeByTarget } from "./sourcePatcher";
|
|
6
9
|
import { buildPatchTarget } from "../hooks/timelineEditingHelpers";
|
|
@@ -67,17 +70,45 @@ export function applyTrackRenumbers(source: string, plan: DropTrackInsertPlan):
|
|
|
67
70
|
return out;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
|
|
73
|
+
type DroppedClip = Pick<TimelineElement, "id" | "tag" | "start" | "duration">;
|
|
74
|
+
|
|
75
|
+
/** The file track a drop on display row `lane` is written to, and its nearest free start there.
|
|
76
|
+
* The timeline's own clips decide the track; `placed` clips only block, like same-kind clips there. */
|
|
77
|
+
function resolveRowDrop(
|
|
78
|
+
elements: TimelineElement[],
|
|
79
|
+
placed: readonly TimelineElement[],
|
|
80
|
+
lane: number,
|
|
81
|
+
dropped: DroppedClip,
|
|
82
|
+
): { track: number; start: number } {
|
|
83
|
+
const audio = isAudioTimelineElement(dropped);
|
|
84
|
+
const row = elements.filter((e) => e.track === lane);
|
|
85
|
+
const otherKindRow = row.length > 0 && !row.some((e) => isAudioTimelineElement(e) === audio);
|
|
86
|
+
const asClip = { ...dropped, key: dropped.id, track: lane, sourceFile: elements[0]?.sourceFile };
|
|
87
|
+
const track = otherKindRow ? lane : authoredTrackForLane(lane, elements, asClip);
|
|
88
|
+
const onTrack = [...elements, ...placed]
|
|
89
|
+
.filter((e) => isAudioTimelineElement(e) === audio && (e.authoredTrack ?? e.track) === track)
|
|
90
|
+
.map((e) => ({ ...e, track }));
|
|
91
|
+
return {
|
|
92
|
+
track,
|
|
93
|
+
start: resolveNearestFreeStart(onTrack, track, dropped.start, dropped.duration, null),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Where a dropped clip is written: the aimed row's file track at its nearest free time, or a new track. */
|
|
71
98
|
export function resolveDropTrack(input: {
|
|
72
99
|
source: string;
|
|
73
100
|
elements: TimelineElement[];
|
|
101
|
+
/** Clips this drop gesture already wrote, which `elements` does not hold yet. */
|
|
102
|
+
placed?: readonly TimelineElement[];
|
|
74
103
|
placement: TimelineDropPlacement;
|
|
75
|
-
dropped:
|
|
76
|
-
}): { source: string; track: number } {
|
|
104
|
+
dropped: DroppedClip;
|
|
105
|
+
}): { source: string; track: number; start: number } {
|
|
77
106
|
const { source, elements, placement, dropped } = input;
|
|
78
|
-
if (placement.insertRow == null)
|
|
107
|
+
if (placement.insertRow == null) {
|
|
108
|
+
return { source, ...resolveRowDrop(elements, input.placed ?? [], placement.track, dropped) };
|
|
109
|
+
}
|
|
79
110
|
const { insertRow, trackOrder } = placement;
|
|
80
111
|
const plan = planDropTrackInsert({ elements, trackOrder, insertRow, dropped });
|
|
81
112
|
if (!plan) throw new Error("Cannot open a new track here: a locked clip would have to move.");
|
|
82
|
-
return { source: applyTrackRenumbers(source, plan), track: plan.track };
|
|
113
|
+
return { source: applyTrackRenumbers(source, plan), track: plan.track, start: dropped.start };
|
|
83
114
|
}
|