@hyperframes/studio 0.8.47 → 0.8.49

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 (123) hide show
  1. package/dist/assets/hyperframes-player-CqON2Rzo.js +464 -0
  2. package/dist/assets/index-B9LfWNyM.css +1 -0
  3. package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
  4. package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
  5. package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
  6. package/dist/index.d.ts +70 -26
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1452 -918
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +17 -26
  12. package/src/components/AskAgentModal.tsx +9 -7
  13. package/src/components/TimelineToolbar.tsx +26 -1
  14. package/src/components/editor/PropertyPanelFlat.tsx +8 -8
  15. package/src/components/editor/domEditing.ts +7 -1
  16. package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
  17. package/src/components/editor/domEditingAgentPrompt.ts +136 -34
  18. package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
  19. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  20. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  21. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  22. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  23. package/src/components/editor/useVolumeAutomation.ts +23 -2
  24. package/src/components/panels/VariablesPanel.tsx +5 -8
  25. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  27. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  28. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  29. package/src/contexts/FileManagerContext.tsx +0 -3
  30. package/src/hooks/appHotkeysDispatch.ts +256 -0
  31. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  32. package/src/hooks/domEditDeleteMembers.ts +19 -0
  33. package/src/hooks/useActiveComposition.ts +69 -0
  34. package/src/hooks/useAppHotkeys.test.ts +23 -1
  35. package/src/hooks/useAppHotkeys.ts +6 -239
  36. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  37. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  38. package/src/hooks/useBlockHandlers.ts +7 -2
  39. package/src/hooks/useDomEditCommits.ts +13 -15
  40. package/src/hooks/useDomEditSession.test.tsx +90 -1
  41. package/src/hooks/useDomEditSession.ts +31 -34
  42. package/src/hooks/useDomSelectionTypes.ts +7 -2
  43. package/src/hooks/useElementLifecycleOps.ts +0 -8
  44. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  45. package/src/hooks/useFileManager.ts +0 -2
  46. package/src/hooks/useFileTree.test.tsx +214 -0
  47. package/src/hooks/useFileTree.ts +66 -24
  48. package/src/hooks/useGroupCommits.test.ts +98 -0
  49. package/src/hooks/useGroupCommits.ts +66 -9
  50. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  51. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  52. package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
  53. package/src/hooks/useTimelineAssetDropOps.ts +24 -18
  54. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  55. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  56. package/src/hooks/useTimelineEditing.ts +1 -0
  57. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  58. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  59. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  60. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  61. package/src/index.ts +10 -0
  62. package/src/player/components/Timeline.test.ts +0 -4
  63. package/src/player/components/Timeline.tsx +0 -1
  64. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  65. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  66. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  67. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  68. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  69. package/src/player/components/automationLaneData.test.ts +11 -3
  70. package/src/player/components/automationLaneData.ts +5 -4
  71. package/src/player/components/automationLaneGeometry.ts +15 -0
  72. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  73. package/src/player/components/timelineClipDragCommit.ts +18 -49
  74. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  75. package/src/player/components/timelineClipDragPreview.ts +8 -1
  76. package/src/player/components/timelineCollision.test.ts +23 -0
  77. package/src/player/components/timelineCollision.ts +27 -0
  78. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  79. package/src/player/components/timelineDragDrop.ts +9 -25
  80. package/src/player/components/timelineDragLanding.ts +108 -0
  81. package/src/player/components/timelineDropReveal.test.ts +34 -0
  82. package/src/player/components/timelineDropReveal.ts +12 -0
  83. package/src/player/components/timelineGapCommit.test.ts +96 -0
  84. package/src/player/components/timelineGapCommit.ts +56 -11
  85. package/src/player/components/timelineGaps.ts +2 -2
  86. package/src/player/components/timelineLayout.test.ts +0 -1
  87. package/src/player/components/timelineLayout.ts +1 -6
  88. package/src/player/components/timelineZones.test.ts +20 -1
  89. package/src/player/components/timelineZones.ts +10 -3
  90. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  91. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  92. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  93. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  94. package/src/player/components/useTimelineGeometry.ts +7 -2
  95. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  96. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  98. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  99. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  100. package/src/player/lib/timelineElementHelpers.ts +8 -0
  101. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  103. package/src/player/store/playerStore.ts +16 -21
  104. package/src/player/store/readinessSlice.ts +4 -3
  105. package/src/utils/blockInstaller.test.ts +4 -0
  106. package/src/utils/blockInstaller.ts +9 -3
  107. package/src/utils/razorSplitTransaction.test.ts +10 -0
  108. package/src/utils/razorSplitTransaction.ts +5 -0
  109. package/src/utils/retryBudget.ts +20 -0
  110. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  111. package/src/utils/sdkCutover.test.ts +51 -0
  112. package/src/utils/sdkCutover.ts +7 -4
  113. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  114. package/src/utils/sdkCutoverEligibility.ts +34 -11
  115. package/src/utils/studioHelpers.test.ts +18 -0
  116. package/src/utils/studioHelpers.ts +41 -5
  117. package/src/utils/studioUiPreferences.test.ts +14 -0
  118. package/src/utils/studioUiPreferences.ts +6 -0
  119. package/src/utils/studioUrlState.ts +15 -7
  120. package/src/utils/timelineCompositionInsert.ts +2 -1
  121. package/src/utils/timelineInspector.ts +10 -0
  122. package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
  123. package/dist/assets/index-DnRfAiK2.css +0 -1
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
3
  import {
4
4
  computeDragPreview,
@@ -7,6 +7,7 @@ import {
7
7
  type DragPreviewContext,
8
8
  } from "./timelineClipDragPreview";
9
9
  import type { DraggedClipState } from "./timelineClipDragTypes";
10
+ import { commitDraggedClipMove } from "./timelineClipDragCommit";
10
11
  import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
11
12
 
12
13
  // ─────────────────────────────────────────────────────────────────────────────
@@ -198,6 +199,72 @@ describe("computeDragPreview — plain horizontal drag never arms a phantom inse
198
199
  });
199
200
  });
200
201
 
202
+ describe("computeDragPreview — magnetic first clip on an empty main track", () => {
203
+ // v-lower sits alone on lane 1; lane 0 (the main track) is empty.
204
+ const vLower = clip("v-lower", 1, 10, 4, 5);
205
+
206
+ // Grab v-lower mid-body on lane 1, aim at `targetRowFloat` (same x — no
207
+ // horizontal move), against the given sibling elements and selection.
208
+ function dragUpToMainTrack(
209
+ elements: TimelineElement[],
210
+ targetRowFloat = 0.5,
211
+ selectedKeys: ReadonlySet<string> = new Set(),
212
+ ) {
213
+ const originClientY = yForRow(1.5);
214
+ const drag: DraggedClipState = {
215
+ pointerId: 0,
216
+ element: vLower,
217
+ originClientX: 800,
218
+ originClientY,
219
+ originScrollLeft: 0,
220
+ originScrollTop: 0,
221
+ pointerClientX: 800,
222
+ pointerClientY: originClientY,
223
+ pointerOffsetX: 0,
224
+ pointerOffsetY: 0,
225
+ previewStart: vLower.start,
226
+ previewTrack: vLower.track,
227
+ insertRow: null,
228
+ snapTime: null,
229
+ snapType: null,
230
+ started: true,
231
+ };
232
+ return computeDragPreview(drag, 800, yForRow(targetRowFloat), {
233
+ ...ctx(undefined, elements),
234
+ trackOrder: [0, 1],
235
+ selectedKeys,
236
+ });
237
+ }
238
+
239
+ it("dragging straight up onto the empty main track snaps the preview start to 0", () => {
240
+ const next = dragUpToMainTrack([vLower]);
241
+ expect(next.previewTrack).toBe(0);
242
+ expect(next.insertRow).toBeNull();
243
+ expect(next.previewStart).toBe(0);
244
+ });
245
+
246
+ it("does not touch the start once the main track already holds a clip", () => {
247
+ const vMain = clip("v-main", 0, 0, 3, 5);
248
+ const next = dragUpToMainTrack([vLower, vMain]);
249
+ expect(next.previewStart).toBe(10); // unchanged — main track wasn't empty
250
+ });
251
+
252
+ it("aiming the top gutter over an empty main track snaps the ghost: the insert renumbers onto track 0", () => {
253
+ const next = dragUpToMainTrack([vLower], -0.6);
254
+ expect(next.insertRow).toBe(0);
255
+ expect(next.previewStart).toBe(0);
256
+ });
257
+
258
+ it("does not retime the rest of a multi-selection when the grabbed clip lands on the empty main track", () => {
259
+ const vOther = clip("v-other", 1, 15, 3, 5);
260
+ const next = dragUpToMainTrack([vLower, vOther], 0.5, new Set(["v-lower", "v-other"]));
261
+ expect(next.previewTrack).toBe(0);
262
+ // The grabbed clip's OWN vertical-only move must not force a horizontal
263
+ // shift that resolveMultiSelection would then apply to v-other.
264
+ expect(next.previewStart).toBe(10);
265
+ });
266
+ });
267
+
201
268
  describe("computeResizePreview — composition source continuity", () => {
202
269
  it("seeds a legacy composition offset and advances it at playback rate", () => {
203
270
  const element = {
@@ -254,3 +321,108 @@ describe("getTimelineDragOverlayPosition", () => {
254
321
  expect(getTimelineDragOverlayPosition(drag, null)).toBeNull();
255
322
  });
256
323
  });
324
+
325
+ describe("computeDragPreview — the ghost start is the committed start", () => {
326
+ function preview(
327
+ element: TimelineElement,
328
+ elements: TimelineElement[],
329
+ originRow: number,
330
+ targetRowFloat: number,
331
+ selectedKeys: ReadonlySet<string> = new Set(),
332
+ ): DraggedClipState {
333
+ const originClientY = yForRow(originRow + 0.5);
334
+ const drag: DraggedClipState = {
335
+ pointerId: 0,
336
+ element,
337
+ originClientX: 800,
338
+ originClientY,
339
+ originScrollLeft: 0,
340
+ originScrollTop: 0,
341
+ pointerClientX: 800,
342
+ pointerClientY: originClientY,
343
+ pointerOffsetX: 0,
344
+ pointerOffsetY: 0,
345
+ previewStart: element.start,
346
+ previewTrack: element.track,
347
+ insertRow: null,
348
+ snapTime: null,
349
+ snapType: null,
350
+ started: true,
351
+ };
352
+ return computeDragPreview(drag, 800, yForRow(targetRowFloat), {
353
+ ...ctx(undefined, elements),
354
+ trackOrder: [0, 1, 2],
355
+ selectedKeys,
356
+ });
357
+ }
358
+
359
+ function committedStart(
360
+ ghost: DraggedClipState,
361
+ committed: TimelineElement,
362
+ elements: TimelineElement[],
363
+ selectedKeys: ReadonlySet<string> = new Set(),
364
+ ): number | undefined {
365
+ const onMoveElement = vi.fn();
366
+ const onMoveElements = vi.fn();
367
+ commitDraggedClipMove(ghost, {
368
+ elements,
369
+ trackOrder: [0, 1, 2],
370
+ updateElement: vi.fn(),
371
+ onMoveElement,
372
+ onMoveElements,
373
+ selectedKeys,
374
+ });
375
+ const edits = onMoveElements.mock.calls[0]?.[0] as
376
+ | Array<{ element: TimelineElement; updates: { start: number } }>
377
+ | undefined;
378
+ const single = onMoveElement.mock.calls[0];
379
+ if (single) return single[1].start;
380
+ return edits?.find((e) => e.element.id === committed.id)?.updates.start;
381
+ }
382
+
383
+ const lower = clip("lower", 1, 10, 4, 5);
384
+
385
+ it("plain move onto the empty main track", () => {
386
+ const ghost = preview(lower, [lower], 1, 0.5);
387
+ expect(ghost.previewStart).toBe(0);
388
+ expect(committedStart(ghost, lower, [lower])).toBe(ghost.previewStart);
389
+ });
390
+
391
+ it("top-gutter insert that pushes the old track-0 clip down lands on track 0 at 0", () => {
392
+ const oldMain = clip("old-main", 0, 0, 3, 5);
393
+ const elements = [oldMain, lower];
394
+ const ghost = preview(lower, elements, 1, -0.6);
395
+ expect(ghost.insertRow).toBe(0);
396
+ expect(ghost.previewStart).toBe(0);
397
+ expect(committedStart(ghost, lower, elements)).toBe(0);
398
+ });
399
+
400
+ it("top-gutter insert with a clip staying on track 0 keeps the pointer start", () => {
401
+ const stays = clip("stays", 0, 0, 3, 5);
402
+ const elements = [stays, lower];
403
+ // Insert between lane 0 and 1: the resident track-0 clip does not move.
404
+ const ghost = preview(lower, elements, 1, 1.0);
405
+ expect(ghost.previewStart).toBe(10);
406
+ expect(committedStart(ghost, lower, elements)).toBe(10);
407
+ });
408
+
409
+ it("expanded child dragged with its host: the host commits at 0 and the ghost matches", () => {
410
+ for (const [hostStart, childStart] of [
411
+ [30, 32],
412
+ [20, 22],
413
+ ]) {
414
+ const host = clip("host", 1, hostStart, 10, 5);
415
+ const child: TimelineElement = {
416
+ ...clip("child", 2, childStart, 4, 5),
417
+ expandedHostKey: "host",
418
+ expandedParentStart: hostStart,
419
+ };
420
+ const elements = [host, child];
421
+ const keys = new Set(["host", "child"]);
422
+ const ghost = preview(child, elements, 2, 0.5, keys);
423
+ expect(ghost.previewTrack).toBe(0);
424
+ expect(ghost.previewStart).toBe(childStart - hostStart);
425
+ expect(committedStart(ghost, host, elements, keys)).toBe(0);
426
+ }
427
+ });
428
+ });
@@ -20,6 +20,7 @@ import {
20
20
  } from "./timelineGroupEditing";
21
21
  import { clampGroupMoveDelta } from "./timelineMultiDragPreview";
22
22
  import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
23
+ import { resolveDragLandingStart } from "./timelineDragLanding";
23
24
 
24
25
  /** Snap-target builder closure supplied by the hook (closes over refs + store). */
25
26
  type BuildSnapTargets = (
@@ -200,12 +201,18 @@ export function computeDragPreview(
200
201
  nextMove.track,
201
202
  ctx,
202
203
  );
204
+ const placed = { ...drag, previewStart, previewTrack, insertRow };
205
+ const snappedStart = resolveDragLandingStart(placed, {
206
+ elements,
207
+ trackOrder,
208
+ selectedKeys,
209
+ });
203
210
  return {
204
211
  ...drag,
205
212
  started: true,
206
213
  pointerClientX: clientX,
207
214
  pointerClientY: clientY,
208
- previewStart,
215
+ previewStart: snappedStart,
209
216
  previewTrack,
210
217
  // The lane the POINTER aims at (pre-collision): the commit reads it to tell a
211
218
  // deliberate vertical lane change from a horizontal drag merely bumped sideways.
@@ -5,6 +5,7 @@ import {
5
5
  isInsertAllowedForZone,
6
6
  isLaneFree,
7
7
  resolveInsertRow,
8
+ resolveMainTrackDropStart,
8
9
  resolvePlacement,
9
10
  resolveZoneDropPlacement,
10
11
  timeRangesOverlap,
@@ -493,3 +494,25 @@ describe("resolveZoneDropPlacement (the whole drop decision, no same-track overl
493
494
  expect(result).toEqual({ track: 0, insertRow: 1 });
494
495
  });
495
496
  });
497
+
498
+ describe("resolveMainTrackDropStart (magnetic first clip on an empty main track)", () => {
499
+ it("snaps to 0 when landing on an empty main track", () => {
500
+ expect(resolveMainTrackDropStart([], 1, 0, false, 7)).toBe(0);
501
+ });
502
+
503
+ it("leaves the drop start alone once the main track already holds another clip", () => {
504
+ expect(resolveMainTrackDropStart([el("other", 0, 0, 3)], 1, 0, false, 7)).toBe(7);
505
+ });
506
+
507
+ it("leaves the drop start alone when landing off the main track", () => {
508
+ expect(resolveMainTrackDropStart([], 1, 2, false, 7)).toBe(7);
509
+ });
510
+
511
+ it("a clip already resident on the main track doing a horizontal move is a no-op", () => {
512
+ expect(resolveMainTrackDropStart([], 0, 0, false, 7)).toBe(7);
513
+ });
514
+
515
+ it("an audio clip landing on track 0 is not the main track (visual zone only)", () => {
516
+ expect(resolveMainTrackDropStart([], 1, 0, true, 7)).toBe(7);
517
+ });
518
+ });
@@ -1,5 +1,6 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
  import { INSERT_BOUNDARY_BAND } from "./timelineLayout";
3
+ import { isMainTrackElement } from "./timelineZones";
3
4
 
4
5
  /**
5
6
  * Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
@@ -140,6 +141,32 @@ export function resolveZoneDropPlacement(input: {
140
141
  return { track: placement.track, insertRow: null };
141
142
  }
142
143
 
144
+ function landsOnMainTrack(landingTrack: number, isAudio: boolean): boolean {
145
+ return isMainTrackElement({
146
+ id: "",
147
+ tag: isAudio ? "audio" : "video",
148
+ start: 0,
149
+ duration: 0,
150
+ track: landingTrack,
151
+ });
152
+ }
153
+
154
+ /** An existing clip newly landing on an empty main track commits at start=0.
155
+ * No-op once the main track holds another clip, off the main track, or for
156
+ * a clip already resident there. `others` excludes the landing clip itself. */
157
+ export function resolveMainTrackDropStart(
158
+ others: readonly TimelineElement[],
159
+ originTrack: number,
160
+ landingTrack: number,
161
+ isAudio: boolean,
162
+ desiredStart: number,
163
+ ): number {
164
+ if (originTrack === landingTrack || !landsOnMainTrack(landingTrack, isAudio)) {
165
+ return desiredStart;
166
+ }
167
+ return others.some(isMainTrackElement) ? desiredStart : 0;
168
+ }
169
+
143
170
  /**
144
171
  * Decide whether a vertical drag is inserting a new track at a lane boundary.
145
172
  * `rowFloat` is the pointer's position in track-height units from the top of the
@@ -56,7 +56,6 @@ function renderHarness(
56
56
  api = useTimelineAssetDrop({
57
57
  scrollRef: { current: scroll },
58
58
  ppsRef: { current: 40 },
59
- durationRef: { current: 120 },
60
59
  trackOrderRef: { current: tracks },
61
60
  rowGeometryRef: { current: geometry },
62
61
  contentOrigin: 0,
@@ -138,7 +137,6 @@ describe("useTimelineAssetDrop", () => {
138
137
  it("drops once on a model row outside the mounted window and appends below the last row", () => {
139
138
  const onAssetDrop = vi.fn();
140
139
  const view = renderHarness(onAssetDrop);
141
- usePlayerStore.getState().setCurrentTime(12.5);
142
140
  view.scroll.scrollTop = view.scroll.scrollHeight - view.scroll.clientHeight;
143
141
  const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
144
142
 
@@ -148,11 +146,29 @@ describe("useTimelineAssetDrop", () => {
148
146
  });
149
147
 
150
148
  expect(onAssetDrop).toHaveBeenCalledTimes(1);
151
- expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 12.5, track: 100 });
149
+ // pps=40, clientX=400 -> 10s at the pointer, not the playhead.
150
+ expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 10, track: 100 });
152
151
  expect(view.api.isDragOver).toBe(false);
153
152
  act(() => view.root.unmount());
154
153
  });
155
154
 
155
+ it("places the drop at the pointer x, ignoring the playhead", () => {
156
+ const onAssetDrop = vi.fn();
157
+ const view = renderHarness(onAssetDrop);
158
+ usePlayerStore.getState().setCurrentTime(50);
159
+ const transfer = assetTransfer(JSON.stringify({ path: "/media/hero.mp4" }));
160
+
161
+ act(() => {
162
+ view.api.handleAssetDragOver(dragEvent(transfer, 80, 100));
163
+ view.api.handleAssetDrop(dragEvent(transfer, 80, 100));
164
+ });
165
+
166
+ // pps=40, clientX=80 -> 2s, far from the 50s playhead: proves start tracks
167
+ // the drop position, not usePlayerStore.currentTime.
168
+ expect(onAssetDrop).toHaveBeenCalledWith("/media/hero.mp4", { start: 2, track: 0 });
169
+ act(() => view.root.unmount());
170
+ });
171
+
156
172
  it("ignores malformed payloads and clears the actor on project reset", () => {
157
173
  const onAssetDrop = vi.fn();
158
174
  const view = renderHarness(onAssetDrop, 1);
@@ -190,7 +206,8 @@ describe("useTimelineAssetDrop", () => {
190
206
  });
191
207
 
192
208
  expect(onAssetDrop).not.toHaveBeenCalled();
193
- expect(onBlockDrop).toHaveBeenCalledExactlyOnceWith("title-card", { start: 0, track: 0 });
209
+ // pps=40, clientX=400 -> 10s at the pointer.
210
+ expect(onBlockDrop).toHaveBeenCalledExactlyOnceWith("title-card", { start: 10, track: 0 });
194
211
  act(() => view.root.unmount());
195
212
  });
196
213
 
@@ -4,7 +4,6 @@ import {
4
4
  parseTimelineCompositionPayload,
5
5
  TIMELINE_COMPOSITION_MIME,
6
6
  } from "../../utils/timelineCompositionDrop";
7
- import { usePlayerStore } from "../store/playerStore";
8
7
  import { resolveTimelineAssetDrop, type TimelineRowGeometry } from "./timelineLayout";
9
8
  import type { TimelineDropCallbacks } from "./timelineCallbacks";
10
9
  import {
@@ -15,7 +14,6 @@ import {
15
14
  interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
16
15
  scrollRef: RefObject<HTMLDivElement | null>;
17
16
  ppsRef: RefObject<number>;
18
- durationRef: RefObject<number>;
19
17
  trackOrderRef: RefObject<number[]>;
20
18
  rowGeometryRef: RefObject<TimelineRowGeometry>;
21
19
  contentOrigin: number;
@@ -53,11 +51,6 @@ function invokeDropCallback(callback: () => Promise<void> | void): void {
53
51
  }
54
52
  }
55
53
 
56
- function resolveDropStart(usePointerStart: boolean, pointerStart: number): number {
57
- if (usePointerStart) return pointerStart;
58
- return Math.max(0, usePlayerStore.getState().currentTime);
59
- }
60
-
61
54
  function applyFileDrop(
62
55
  transfer: DataTransfer,
63
56
  onFileDrop: TimelineDropCallbacks["onFileDrop"],
@@ -87,17 +80,14 @@ function applyTypedJsonDrop(
87
80
  }
88
81
 
89
82
  /**
90
- * Dropping an asset/file/block onto the timeline places it at the PLAYHEAD —
91
- * start is the current playhead time, only the track comes from the drop y.
92
- * Deliberate product choice (user preference, 2026-07-09): every add lands at
93
- * the playhead regardless of drop x, like CapCut's add-to-timeline. External
94
- * OS file drops and internal asset drops share this same placement path, so
95
- * both land identically.
83
+ * Dropping an asset/file/block/composition onto the timeline places it at the
84
+ * exact time and track it was dropped on, like CapCut (pointer placement on
85
+ * every track but the magnetic main track). Supersedes the prior playhead
86
+ * decision (#2291); playhead adds stay available, see useAddAssetAtPlayhead.
96
87
  */
97
88
  export function useTimelineAssetDrop({
98
89
  scrollRef,
99
90
  ppsRef,
100
- durationRef,
101
91
  trackOrderRef,
102
92
  rowGeometryRef,
103
93
  contentOrigin,
@@ -185,10 +175,10 @@ export function useTimelineAssetDrop({
185
175
  );
186
176
 
187
177
  const resolveDropPlacement = useCallback(
188
- (clientX: number, clientY: number, usePointerStart = false): TimelinePlacement => {
178
+ (clientX: number, clientY: number): TimelinePlacement => {
189
179
  const scroll = scrollRef.current;
190
180
  const rect = scroll?.getBoundingClientRect();
191
- const pointer = resolveTimelineAssetDrop(
181
+ return resolveTimelineAssetDrop(
192
182
  {
193
183
  rectLeft: rect?.left ?? 0,
194
184
  rectTop: rect?.top ?? 0,
@@ -196,20 +186,14 @@ export function useTimelineAssetDrop({
196
186
  scrollTop: scroll?.scrollTop ?? 0,
197
187
  contentOrigin,
198
188
  pixelsPerSecond: ppsRef.current,
199
- duration: durationRef.current,
200
- clampStartToDuration: !usePointerStart,
201
189
  rowHeights: rowGeometryRef.current.rowHeights,
202
190
  trackOrder: trackOrderRef.current,
203
191
  },
204
192
  clientX,
205
193
  clientY,
206
194
  );
207
- return {
208
- start: resolveDropStart(usePointerStart, pointer.start),
209
- track: pointer.track,
210
- };
211
195
  },
212
- [scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, contentOrigin],
196
+ [scrollRef, ppsRef, trackOrderRef, rowGeometryRef, contentOrigin],
213
197
  );
214
198
 
215
199
  const handleAssetDrop = useCallback(
@@ -218,15 +202,15 @@ export function useTimelineAssetDrop({
218
202
  const canCommit = activeDropEpochRef.current === sessionEpoch;
219
203
  clearDropPreview();
220
204
  if (!canCommit) return;
205
+ const placement = resolveDropPlacement(e.clientX, e.clientY);
206
+
221
207
  const compositionPayload = parseTimelineCompositionPayload(
222
208
  e.dataTransfer.getData(TIMELINE_COMPOSITION_MIME),
223
209
  );
224
210
  if (compositionPayload && onCompositionDrop) {
225
- const placement = resolveDropPlacement(e.clientX, e.clientY, true);
226
211
  invokeDropCallback(() => onCompositionDrop(compositionPayload.sourcePath, placement));
227
212
  return;
228
213
  }
229
- const placement = resolveDropPlacement(e.clientX, e.clientY);
230
214
 
231
215
  if (applyFileDrop(e.dataTransfer, onFileDrop, placement)) return;
232
216
  if (applyTypedJsonDrop(e.dataTransfer, TIMELINE_ASSET_MIME, "path", onAssetDrop, placement)) {
@@ -0,0 +1,108 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import type { DragCommitDeps } from "./timelineClipDragCommit";
3
+ import type { DraggedClipState } from "./timelineClipDragTypes";
4
+ import { classifyZone, normalizeToZones } from "./timelineZones";
5
+ import { resolveMainTrackDropStart } from "./timelineCollision";
6
+ import { isAudioTimelineElement } from "../../utils/timelineInspector";
7
+ import { resolveExpandedHostAlias } from "./timelineAuthoredMoveTarget";
8
+ import { sameSourceFile } from "./timelineAuthoredTrack";
9
+ import { round3 } from "./timelineGaps";
10
+
11
+ const keyOf = (e: TimelineElement) => e.key ?? e.id;
12
+
13
+ /** A fractional track for a new lane at boundary `insertRow` (0 = above the top);
14
+ * normalizeToZones compacts it to an integer lane and shifts the clips below. */
15
+ function insertTrackValue(trackOrder: number[], insertRow: number): number {
16
+ if (trackOrder.length === 0) return 0;
17
+ if (insertRow <= 0) return trackOrder[0] - 0.5;
18
+ if (insertRow >= trackOrder.length) return trackOrder[trackOrder.length - 1] + 0.5;
19
+ return (trackOrder[insertRow - 1] + trackOrder[insertRow]) / 2;
20
+ }
21
+
22
+ /** The source-file lane layout after inserting `element` at boundary `insertRow`:
23
+ * the whole-set renumber both the commit and the drag preview read. */
24
+ export function layoutAfterTrackInsert(
25
+ element: TimelineElement,
26
+ previewStart: number,
27
+ insertRow: number,
28
+ multi: {
29
+ keys: ReadonlySet<string>;
30
+ movedStart: (e: TimelineElement) => number;
31
+ } | null,
32
+ deps: Pick<DragCommitDeps, "elements" | "trackOrder">,
33
+ ): {
34
+ normalized: TimelineElement[];
35
+ targetTrack: number;
36
+ writable: (src: TimelineElement) => boolean;
37
+ } | null {
38
+ const { elements, trackOrder } = deps;
39
+ const editKey = keyOf(element);
40
+ // Expanded-child rows are synthetic host lanes, not source-file topology.
41
+ if (element.expandedParentStart != null) return null;
42
+ const targetTrack = insertTrackValue(trackOrder, insertRow);
43
+ // Foreign display rows and the opposite zone must not affect this topology.
44
+ const writableZone = classifyZone(element);
45
+ const writable = (src: TimelineElement): boolean =>
46
+ sameSourceFile(src, element) &&
47
+ classifyZone(src) === writableZone &&
48
+ src.expandedParentStart == null;
49
+ const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
50
+ (a, b) => a - b,
51
+ );
52
+ const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
53
+ const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
54
+ const normalized = normalizeToZones(
55
+ elements.filter(writable).map((e) => {
56
+ if (keyOf(e) === editKey) {
57
+ return { ...e, start: previewStart, track: topologyTargetTrack };
58
+ }
59
+ if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
60
+ return e;
61
+ }),
62
+ );
63
+ return { normalized, targetTrack, writable };
64
+ }
65
+
66
+ /** The start a solo clip lands at, for both the ghost and the commit: 0 on an empty
67
+ * main track, judged on the final lane (after the renumber and the host alias).
68
+ * Returned in the dragged clip's own frame. */
69
+ export function resolveDragLandingStart(
70
+ drag: DraggedClipState,
71
+ deps: Pick<DragCommitDeps, "elements" | "trackOrder" | "selectedKeys">,
72
+ ): number {
73
+ const alias = resolveExpandedHostAlias(drag, deps);
74
+ const target = alias?.drag ?? drag;
75
+ const keys = alias?.selectedKeys ?? deps.selectedKeys;
76
+ const element = target.element;
77
+ const key = keyOf(element);
78
+ if (keys && keys.size > 1 && keys.has(key)) return drag.previewStart;
79
+
80
+ let landingTrack = target.previewTrack;
81
+ let renumbered = new Map<string, number>();
82
+ if (target.insertRow != null) {
83
+ const layout = layoutAfterTrackInsert(
84
+ element,
85
+ target.previewStart,
86
+ target.insertRow,
87
+ null,
88
+ deps,
89
+ );
90
+ if (layout) {
91
+ renumbered = new Map(layout.normalized.map((n) => [keyOf(n), n.track]));
92
+ landingTrack = renumbered.get(key) ?? layout.targetTrack;
93
+ }
94
+ }
95
+ const others = deps.elements
96
+ .filter((e) => keyOf(e) !== key)
97
+ .map((e) => (renumbered.has(keyOf(e)) ? { ...e, track: renumbered.get(keyOf(e))! } : e));
98
+ const snapped = resolveMainTrackDropStart(
99
+ others,
100
+ element.track,
101
+ landingTrack,
102
+ isAudioTimelineElement(element),
103
+ target.previewStart,
104
+ );
105
+ return snapped === target.previewStart
106
+ ? drag.previewStart
107
+ : round3(drag.previewStart + snapped - target.previewStart);
108
+ }
@@ -0,0 +1,34 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { beforeEach, describe, expect, it } from "vitest";
4
+ import { usePlayerStore } from "../store/playerStore";
5
+ import { timelineClipFocusId } from "./timelineNavigationIdentity";
6
+ import { selectAndRevealTimelineElement } from "./timelineDropReveal";
7
+
8
+ beforeEach(() => {
9
+ usePlayerStore.getState().reset();
10
+ });
11
+
12
+ describe("selectAndRevealTimelineElement", () => {
13
+ it("selects the element and requests a scroll-to-focus on the same key", () => {
14
+ selectAndRevealTimelineElement("index.html#clip-1");
15
+
16
+ expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip-1");
17
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(
18
+ timelineClipFocusId("index.html#clip-1"),
19
+ );
20
+ });
21
+
22
+ it("moves the focus request to the new element when called again", () => {
23
+ selectAndRevealTimelineElement("index.html#clip-1");
24
+ const firstNonce = usePlayerStore.getState().timelineFocus?.nonce;
25
+
26
+ selectAndRevealTimelineElement("index.html#clip-2");
27
+
28
+ expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip-2");
29
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(
30
+ timelineClipFocusId("index.html#clip-2"),
31
+ );
32
+ expect(usePlayerStore.getState().timelineFocus?.nonce).not.toBe(firstNonce);
33
+ });
34
+ });
@@ -0,0 +1,12 @@
1
+ import { usePlayerStore } from "../store/playerStore";
2
+ import { timelineClipFocusId } from "./timelineNavigationIdentity";
3
+
4
+ /**
5
+ * Select a newly-created clip and scroll the timeline to it, the same pair of
6
+ * calls AssetCard/AudioRow use to reveal an existing clip on activation.
7
+ */
8
+ export function selectAndRevealTimelineElement(key: string): void {
9
+ const store = usePlayerStore.getState();
10
+ store.setSelectedElementId(key);
11
+ store.requestTimelineFocus(timelineClipFocusId(key));
12
+ }