@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.
Files changed (33) hide show
  1. package/dist/assets/{hyperframes-player-zT2Bdx_l.js → hyperframes-player-DKJPDHUp.js} +1 -1
  2. package/dist/assets/{index-V-0rfhe6.js → index-C5_j5-3u.js} +153 -153
  3. package/dist/assets/{index-DSOLcE8X.js → index-CHZ4kFWm.js} +1 -1
  4. package/dist/assets/{index-DV-z19zB.js → index-DpzpqJ_j.js} +1 -1
  5. package/dist/index.d.ts +6 -11
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +8770 -8791
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  11. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  12. package/src/player/components/Timeline.rulerSeek.test.tsx +7 -7
  13. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  14. package/src/player/components/timelineClipDragCommit.test.ts +14 -58
  15. package/src/player/components/timelineClipDragCommit.ts +14 -37
  16. package/src/player/components/timelineClipDragPreview.test.ts +213 -22
  17. package/src/player/components/timelineClipDragPreview.ts +46 -81
  18. package/src/player/components/timelineClipDragTypes.ts +3 -8
  19. package/src/player/components/timelineCollision.test.ts +100 -302
  20. package/src/player/components/timelineCollision.ts +73 -160
  21. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  22. package/src/player/components/timelineDragDrop.ts +7 -9
  23. package/src/player/components/timelineDragLanding.ts +0 -49
  24. package/src/player/components/timelineLayout.test.ts +0 -25
  25. package/src/player/components/timelineLayout.ts +0 -16
  26. package/src/player/components/timelineMultiDragPreview.test.ts +34 -25
  27. package/src/player/components/timelineMultiDragPreview.ts +25 -27
  28. package/src/player/components/timelineProviderStateBuilders.ts +7 -4
  29. package/src/player/components/timelineShellMeta.tsx +99 -0
  30. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  31. package/src/player/components/useTimelineProviderState.tsx +29 -67
  32. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  33. 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 (updateDraggedClipPreview
13
- * runs clampGroupMoveDelta before setting it), so this delta is the clamped delta:
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
- * selected clip by this same delta (see timelineClipDragCommit / useTimelineClipDrag).
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
- * Clamp a group move so the WHOLE selection moves as ONE rigid formation.
86
- *
87
- * The grabbed clip proposes a raw delta (its desired preview start minus its
88
- * origin start, after its own snapping). Applied naively, a passenger could be
89
- * pushed below 0 (or past any other member bound), and the commit's per-clip
90
- * `Math.max(0, …)` would then deform the formation — the grabbed clip out-runs
91
- * the group while a passenger sticks at the wall. This ports main's model
92
- * (useTimelineClipGroupDrag / clampTimelineGroupMoveDelta): the applied delta is
93
- * bounded by the MOST-CONSTRAINED member, so the grabbed clip STOPS the instant
94
- * any member hits 0 and the formation never deforms.
95
- *
96
- * `memberStarts` are the pre-drag starts of every selected clip (the grabbed clip
97
- * included). Only the lower bound (start ≥ 0) constrains a move; the timeline has
98
- * no fixed right wall (the composition grows on commit).
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
- if (members.length === 0) return rawDelta;
105
- // The member nearest its own floor bounds the move: delta ≥ -(start - minStart) for each.
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 { MultiDragPreviewInput } from "./timelineMultiDragPreview";
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; selected companions follow the same
16
- // clamped delta so the formation stays rigid at the lane boundary.
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, type TimelineElement } from "../store/playerStore";
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
- buildTimelineMeta,
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, LABEL_COL_W } from "./timelineLayout";
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 { TimelineRazorGuideOverlay, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
32
+ import { useTimelineRazorInteraction } from "./TimelineRazorInteraction";
37
33
  import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
38
- import { getEffectiveTimelineDuration, getTimelinePreviewElement } from "./timelineViewModel";
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 namedAnimations = usePlayerStore((s) => s.gsapAnimations);
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 = resolveResizingElementIds(resizingClip);
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: resolveSnapGuide(draggedClip, resizingClip),
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 = buildTimelineMeta({
524
- emptyState: {
525
- isDragOver: assetDrop.isDragOver,
526
- onFileDrop: !!onFileDrop,
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
- container: {
532
- ref: setContainerRef,
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
- labelColumnWidth: LABEL_COL_W,
574
- razorGuide:
575
- activeTool === "razor" && razorGuideX !== null ? (
576
- <TimelineRazorGuideOverlay x={razorGuideX} />
577
- ) : null,
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
- /** The lane a dropped clip is written on and the source with any lanes pushed down to make room. */
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: Pick<TimelineElement, "id" | "tag" | "start" | "duration">;
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) return { source, track: placement.track };
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
  }