@hyperframes/studio 0.8.110 → 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.
Files changed (83) hide show
  1. package/dist/assets/{hyperframes-player-c6WR3RsF.js → hyperframes-player-CBpg41JM.js} +1 -1
  2. package/dist/assets/{index-BJpQzV6h.js → index-BFitquwi.js} +1 -1
  3. package/dist/assets/{index-Co6W8IVk.js → index-BKy_birQ.js} +194 -194
  4. package/dist/assets/{index-C0KfbPAo.js → index-CZfrSyU9.js} +1 -1
  5. package/dist/assets/{index-C7apTvpr.css → index-DbfKVTId.css} +1 -1
  6. package/dist/{chunk-PQFEBX32.js → chunk-BIUXP3PG.js} +2 -2
  7. package/dist/{chunk-6LUNLTHR.js → chunk-K47E2ZJA.js} +13 -8
  8. package/dist/chunk-K47E2ZJA.js.map +1 -0
  9. package/dist/{chunk-QUXZVHOJ.js → chunk-VWT7STY3.js} +2 -2
  10. package/dist/{domEditingLayers-JQPQGGT7.js → domEditingLayers-V67MS4WP.js} +3 -3
  11. package/dist/index.d.ts +81 -46
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +7295 -6844
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +10 -8
  18. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
  19. package/src/hooks/timelineEditingHelpers.ts +7 -0
  20. package/src/hooks/timelineMoveAdapter.test.ts +12 -0
  21. package/src/hooks/timelineMoveAdapter.ts +9 -4
  22. package/src/hooks/useTimelineGroupEditing.test.tsx +77 -0
  23. package/src/hooks/useTimelineGroupEditing.ts +5 -1
  24. package/src/index.ts +8 -0
  25. package/src/main.tsx +4 -0
  26. package/src/player/components/ClipBadges.tsx +5 -5
  27. package/src/player/components/Timeline.ghostLane.test.tsx +8 -61
  28. package/src/player/components/Timeline.trackPadding.test.tsx +11 -56
  29. package/src/player/components/TimelineCanvas.tsx +6 -2
  30. package/src/player/components/TimelineClip.filmstrip.test.tsx +156 -0
  31. package/src/player/components/TimelineClip.test.tsx +7 -7
  32. package/src/player/components/TimelineClip.tsx +23 -7
  33. package/src/player/components/TimelineClipFades.tsx +41 -43
  34. package/src/player/components/TimelineGestureOverlay.test.tsx +41 -0
  35. package/src/player/components/TimelineGestureOverlay.tsx +59 -21
  36. package/src/player/components/TimelineLanes.tsx +18 -16
  37. package/src/player/components/timelineCallbacks.ts +6 -4
  38. package/src/player/components/timelineClipChildren.tsx +7 -13
  39. package/src/player/components/timelineClipDragCommit.test.ts +51 -0
  40. package/src/player/components/timelineClipDragCommit.ts +63 -21
  41. package/src/player/components/timelineClipDragGestureLifecycle.ts +18 -2
  42. package/src/player/components/timelineClipDragPreview.test.ts +47 -31
  43. package/src/player/components/timelineClipDragPreview.ts +81 -17
  44. package/src/player/components/timelineClipDragTypes.ts +3 -3
  45. package/src/player/components/timelineClipFadeGeometry.test.ts +29 -0
  46. package/src/player/components/timelineClipFadeGeometry.ts +74 -0
  47. package/src/player/components/timelineCollision.ts +51 -11
  48. package/src/player/components/timelineDragLanding.ts +20 -6
  49. package/src/player/components/timelineLaneProps.ts +3 -8
  50. package/src/player/components/timelineMotionStyles.test.ts +6 -13
  51. package/src/player/components/timelineMountFixtures.tsx +65 -0
  52. package/src/player/components/timelineOptimisticRevision.ts +15 -4
  53. package/src/player/components/timelineTheme.ts +2 -0
  54. package/src/player/components/timelineTrackDisplay.ts +1 -1
  55. package/src/player/components/timelineTrackInsertLayout.test.ts +249 -0
  56. package/src/player/components/timelineTrackInsertLayout.ts +71 -0
  57. package/src/player/components/timelineZMirror.ts +5 -11
  58. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +163 -0
  59. package/src/player/components/useTimelineClipDrag.ts +116 -12
  60. package/src/player/components/useTimelineDisplayLayout.test.tsx +59 -0
  61. package/src/player/components/useTimelineKeyboardActor.ts +86 -7
  62. package/src/player/components/useTimelineProviderState.tsx +3 -1
  63. package/src/player/components/useTimelineRowVirtualization.test.tsx +56 -0
  64. package/src/player/components/useTimelineRowVirtualization.ts +3 -1
  65. package/src/player/components/useTimelineTrackDerivations.test.ts +14 -1
  66. package/src/player/components/useTimelineTrackDerivations.ts +32 -18
  67. package/src/player/components/useTimelineTrackLayout.ts +43 -14
  68. package/src/player/hooks/timelinePlayerSync.test.ts +7 -0
  69. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  70. package/src/player/lib/automationStoreSync.test.ts +15 -0
  71. package/src/player/lib/automationStoreSync.ts +13 -5
  72. package/src/player/lib/storedVolume.ts +7 -0
  73. package/src/player/lib/thumbnailPageLifecycle.test.ts +123 -0
  74. package/src/player/lib/thumbnailPageLifecycle.ts +27 -0
  75. package/src/player/lib/thumbnailScheduler.ts +20 -2
  76. package/src/player/lib/timelineElementHelpers.ts +2 -8
  77. package/src/styles/components.css +71 -19
  78. package/src/styles/theme.css +4 -1
  79. package/src/utils/studioPendingEdits.test.ts +18 -0
  80. package/dist/chunk-6LUNLTHR.js.map +0 -1
  81. /package/dist/{chunk-PQFEBX32.js.map → chunk-BIUXP3PG.js.map} +0 -0
  82. /package/dist/{chunk-QUXZVHOJ.js.map → chunk-VWT7STY3.js.map} +0 -0
  83. /package/dist/{domEditingLayers-JQPQGGT7.js.map → domEditingLayers-V67MS4WP.js.map} +0 -0
@@ -54,7 +54,6 @@ export function TimelineLanes({
54
54
  pinnedClipIdentities,
55
55
  trackOrder,
56
56
  tracks,
57
- trackStyles,
58
57
  groups,
59
58
  laneCounts,
60
59
  selectedElementId,
@@ -197,9 +196,26 @@ export function TimelineLanes({
197
196
  actorWindows,
198
197
  )
199
198
  : els;
200
- const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
201
199
  const isPendingTrack =
202
200
  draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
201
+ if (isPendingTrack)
202
+ return (
203
+ <div
204
+ key={rowKey}
205
+ data-timeline-new-track-lane={row}
206
+ aria-hidden="true"
207
+ className={rowsVirtualized ? "absolute" : "relative"}
208
+ style={{
209
+ top: rowsVirtualized ? rowGeometry.getRowTop(row) : undefined,
210
+ marginLeft: contentOrigin,
211
+ width: trackContentWidth,
212
+ height: TRACK_H,
213
+ border: "1px dashed var(--timeline-accent)",
214
+ background: "color-mix(in srgb, var(--timeline-accent) 5%, transparent)",
215
+ pointerEvents: "none",
216
+ }}
217
+ />
218
+ );
203
219
  const rowBackground = theme.rowBackground;
204
220
  const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
205
221
  selectedElementId,
@@ -343,20 +359,6 @@ export function TimelineLanes({
343
359
  renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
344
360
  />
345
361
  )}
346
- {isPendingTrack && (
347
- <div
348
- className="absolute inset-0 flex items-center"
349
- style={{
350
- paddingLeft: 16,
351
- color: ts.label,
352
- fontSize: 11,
353
- letterSpacing: "0.06em",
354
- textTransform: "uppercase",
355
- }}
356
- >
357
- New track
358
- </div>
359
- )}
360
362
  {
361
363
  // fallow-ignore-next-line complexity
362
364
  renderElements.map((el) => {
@@ -1,8 +1,10 @@
1
1
  // fallow-ignore-file code-duplication
2
- // fallow-ignore-file dead-code
3
2
  import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
4
3
  import type { TimelineElement } from "../store/playerStore";
5
- import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
4
+ import type {
5
+ TimelineMoveOperation,
6
+ TimelineAtomicMoveUpdates,
7
+ } from "../../hooks/timelineMoveAdapter";
6
8
  import type { BlockedTimelineEditIntent } from "./timelineEditing";
7
9
  import type { PropertyGroupName } from "@hyperframes/core/gsap-parser";
8
10
  import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
@@ -50,7 +52,7 @@ export type TimelineLinkEdit =
50
52
  export interface TimelineEditCallbacks {
51
53
  onMoveElement?: (
52
54
  element: TimelineElement,
53
- updates: Pick<TimelineElement, "start" | "track">,
55
+ updates: TimelineAtomicMoveUpdates,
54
56
  ) => Promise<void> | void;
55
57
  /** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
56
58
  * `coalesceKey` (drag-commit gesture id) merges the move history entry with a
@@ -58,7 +60,7 @@ export interface TimelineEditCallbacks {
58
60
  * widens that entry's fold window when a server round-trip separates the
59
61
  * gesture's records (per-gesture-unique keys keep the fold gesture-scoped). */
60
62
  onMoveElements?: (
61
- edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
63
+ edits: Array<{ element: TimelineElement; updates: TimelineAtomicMoveUpdates }>,
62
64
  coalesceKey?: string,
63
65
  operation?: TimelineMoveOperation,
64
66
  coalesceMs?: number,
@@ -2,7 +2,7 @@ import { useState, type ReactNode } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
3
  import type { TimelineTimeRange } from "../lib/timelineClipIndex";
4
4
  import type { TrackVisualStyle } from "./timelineIcons";
5
- import type { TimelineClipRenderContext } from "./TimelineTypes";
5
+ import type { TimelineProps, TimelineClipRenderContext } from "./TimelineTypes";
6
6
 
7
7
  export function resolveClipRenderContext(
8
8
  element: TimelineElement,
@@ -23,7 +23,7 @@ function ClipLintDot({ element }: { element: TimelineElement }) {
23
23
  return (
24
24
  <span
25
25
  className="absolute w-1.5 h-1.5 rounded-full bg-warning-ink"
26
- style={{ top: 7, right: 7 }}
26
+ style={{ bottom: 7, right: 7 }}
27
27
  title={lint.messages.join("\n")}
28
28
  />
29
29
  );
@@ -42,14 +42,8 @@ export function ClipContentOnceShown({ hold, children }: { hold: boolean; childr
42
42
  export function renderClipChildren(
43
43
  element: TimelineElement,
44
44
  clipStyle: TrackVisualStyle,
45
- renderClipContent:
46
- | ((
47
- element: TimelineElement,
48
- style: { clip: string; label: string },
49
- context: TimelineClipRenderContext,
50
- ) => ReactNode)
51
- | undefined,
52
- renderClipOverlay: ((element: TimelineElement) => ReactNode) | undefined,
45
+ renderClipContent: TimelineProps["renderClipContent"],
46
+ renderClipOverlay: TimelineProps["renderClipOverlay"],
53
47
  context: TimelineClipRenderContext = { priority: "visible", rich: false },
54
48
  ): ReactNode {
55
49
  return (
@@ -58,11 +52,11 @@ export function renderClipChildren(
58
52
  {!renderClipContent && <ClipLintDot element={element} />}
59
53
  {renderClipContent && (
60
54
  // The picture can paint above the trim handles, so it takes no input and presses reach them.
61
- // borderRadius: inherit clips it to the clip's rounded corners; the clip itself is
55
+ // The content inherits the clip's rounded corners; the clip itself is
62
56
  // overflow-visible because keyframe diamonds hang outside its bounds.
63
57
  <div
64
- className="absolute inset-0 overflow-hidden"
65
- style={{ borderRadius: "inherit", pointerEvents: "none" }}
58
+ className="timeline-clip__content absolute inset-0 overflow-hidden"
59
+ style={{ pointerEvents: "none" }}
66
60
  >
67
61
  {renderClipContent(element, clipStyle, context)}
68
62
  </div>
@@ -1483,6 +1483,57 @@ describe("persistMoveEdits: a nested row dropped before its host", () => {
1483
1483
  });
1484
1484
 
1485
1485
  describe("persistMoveEdits convergence", () => {
1486
+ it.each([
1487
+ { successor: "timing", detach: false, expectedGroup: "G" },
1488
+ { successor: "detach", detach: true, expectedGroup: undefined },
1489
+ ])(
1490
+ "preserves newer $successor while rolling back a refused detach",
1491
+ async ({ detach, expectedGroup }) => {
1492
+ let current: TimelineElement = { ...el("grouped", 0, 0, 4), audioGroup: "G" };
1493
+ let rejectDetach!: (error: Error) => void;
1494
+ let resolveSuccessor!: () => void;
1495
+ const refused = new Promise<void>((_resolve, reject) => {
1496
+ rejectDetach = reject;
1497
+ });
1498
+ const successor = new Promise<void>((resolve) => {
1499
+ resolveSuccessor = resolve;
1500
+ });
1501
+ const updateElement = (_key: string, updates: Partial<TimelineElement>) => {
1502
+ current = { ...current, ...updates };
1503
+ };
1504
+ const deps = { elements: [current], trackOrder: [0, 1], updateElement };
1505
+ const first = persistMoveEdits(
1506
+ [{ element: current, updates: { start: 0, track: 1, audioGroup: null } }],
1507
+ { ...deps, onMoveElements: () => refused },
1508
+ undefined,
1509
+ "track-insert",
1510
+ );
1511
+ expect(current.audioGroup).toBeUndefined();
1512
+ const second = persistMoveEdits(
1513
+ [
1514
+ {
1515
+ element: current,
1516
+ updates: { start: 5, track: 1, ...(detach ? { audioGroup: null } : {}) },
1517
+ },
1518
+ ],
1519
+ { ...deps, onMoveElements: () => successor },
1520
+ );
1521
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
1522
+ try {
1523
+ rejectDetach(new Error("save refused"));
1524
+ await expect(first).resolves.toBe(false);
1525
+ expect(current).toMatchObject({ start: 5, track: 1 });
1526
+ expect(current.audioGroup).toBe(expectedGroup);
1527
+ resolveSuccessor();
1528
+ await expect(second).resolves.toBe(true);
1529
+ expect(current).toMatchObject({ start: 5, track: 1 });
1530
+ expect(current.audioGroup).toBe(expectedGroup);
1531
+ } finally {
1532
+ consoleError.mockRestore();
1533
+ }
1534
+ },
1535
+ );
1536
+
1486
1537
  it("reasserts a saved lane after a stale runtime sync", async () => {
1487
1538
  const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
1488
1539
  let releaseSave: (() => void) | undefined;
@@ -6,12 +6,17 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
6
6
  import type { ZMirrorLaneMove } from "./timelineZMirror";
7
7
  import { classifyZone } from "./timelineZones";
8
8
  import { layoutAfterTrackInsert } from "./timelineDragLanding";
9
+ import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
9
10
  import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
10
11
  import {
11
12
  canMoveTimelineElement as canMoveElement,
12
13
  resolveExpandedHostAlias,
13
14
  } from "./timelineAuthoredMoveTarget";
14
- import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
15
+ import type {
16
+ TimelineMoveOperation,
17
+ TimelineAtomicMoveUpdates,
18
+ TimelineAtomicMoveEdit,
19
+ } from "../../hooks/timelineMoveAdapter";
15
20
  import {
16
21
  beginTimelineOptimisticGesture,
17
22
  isLatestTimelineOptimisticGesture,
@@ -21,10 +26,8 @@ import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
21
26
  import { authoredTrackForLane } from "./timelineAuthoredTrack";
22
27
  import { resolveGroupMovers } from "./timelineMultiDragPreview";
23
28
 
24
- type StartTrack = Pick<TimelineElement, "start" | "track">;
25
- export interface TimelineMoveEdit {
26
- element: TimelineElement;
27
- updates: StartTrack;
29
+ type StartTrack = TimelineAtomicMoveUpdates;
30
+ export interface TimelineMoveEdit extends TimelineAtomicMoveEdit {
28
31
  /**
29
32
  * File-space track override for the persist. The store's `updates.track` is a
30
33
  * DISPLAY lane; when the source file's numbering is sparse (authored tracks
@@ -37,6 +40,7 @@ export interface TimelineMoveEdit {
37
40
  export interface DragCommitDeps {
38
41
  elements: TimelineElement[];
39
42
  trackOrder: number[];
43
+ trackInsertLayout?: TimelineTrackInsertLayout;
40
44
  updateElement: (key: string, updates: Partial<TimelineElement>) => void;
41
45
  /** Single-clip, SDK-cutover-aware persist (pure time-moves keep this path). */
42
46
  onMoveElement?: (element: TimelineElement, updates: StartTrack) => Promise<void> | void;
@@ -85,6 +89,17 @@ const round3 = (v: number) => Math.round(v * 1000) / 1000;
85
89
  // One deterministic coalesce key shared by both records in a lane-change gesture.
86
90
  let laneChangeGestureSeq = 0;
87
91
 
92
+ function moveStoreUpdates(edit: TimelineMoveEdit) {
93
+ const writtenTrack =
94
+ edit.persistTrack ??
95
+ (edit.updates.track !== edit.element.track ? edit.updates.track : undefined);
96
+ const { audioGroup, ...timing } = edit.updates;
97
+ return {
98
+ timing: writtenTrack == null ? timing : { ...timing, authoredTrack: writtenTrack },
99
+ detach: audioGroup === null,
100
+ };
101
+ }
102
+
88
103
  /**
89
104
  * Optimistically apply + persist a batch of moves with rollback on failure.
90
105
  *
@@ -123,26 +138,42 @@ export function persistMoveEdits(
123
138
  }
124
139
  const prev = edits.map((e) => ({
125
140
  key: keyOf(e.element),
126
- start: e.element.start,
127
- track: e.element.track,
128
- authoredTrack: e.element.authoredTrack,
141
+ updates: {
142
+ start: e.element.start,
143
+ track: e.element.track,
144
+ authoredTrack: e.element.authoredTrack,
145
+ ...(e.updates.audioGroup === null ? { audioGroup: e.element.audioGroup } : {}),
146
+ },
129
147
  }));
130
148
  const revision = beginTimelineOptimisticGesture(
131
149
  updateElement,
132
150
  edits.map((edit) => keyOf(edit.element)),
133
151
  );
152
+ const membershipRevision = beginTimelineOptimisticGesture(
153
+ updateElement,
154
+ edits.filter((edit) => edit.updates.audioGroup === null).map((edit) => keyOf(edit.element)),
155
+ "membership",
156
+ );
134
157
  // The file write below targets `persistTrack` (authored space) when supplied,
135
158
  // or `updates.track` on a genuine lane write (track insert renumber). Mirror
136
159
  // that written value into the store's `authoredTrack` so a SECOND drag before
137
160
  // any reload resolves authored tracks from what the file now says, not stale
138
161
  // pre-edit data. Pure time-moves leave authoredTrack untouched.
139
- const applyEdit = (e: TimelineMoveEdit) => {
140
- const writtenTrack =
141
- e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
142
- updateElement(
143
- keyOf(e.element),
144
- writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
145
- );
162
+ const applyEdit = (e: TimelineMoveEdit, reassert = false) => {
163
+ const { timing, detach } = moveStoreUpdates(e);
164
+ const key = keyOf(e.element);
165
+ const updates: Partial<TimelineElement> = {};
166
+ if (!reassert || isLatestTimelineOptimisticGesture(updateElement, revision, key)) {
167
+ Object.assign(updates, timing);
168
+ }
169
+ if (
170
+ detach &&
171
+ (!reassert ||
172
+ isLatestTimelineOptimisticGesture(updateElement, membershipRevision, key, "membership"))
173
+ ) {
174
+ updates.audioGroup = undefined;
175
+ }
176
+ if (Object.keys(updates).length) updateElement(key, updates);
146
177
  };
147
178
  for (const e of edits) applyEdit(e);
148
179
  // The store above gets DISPLAY lanes; the file below gets the authored-space
@@ -161,17 +192,21 @@ export function persistMoveEdits(
161
192
  // restore the preview manifest's pre-gesture lane. Reassert the durable
162
193
  // result after persistence, but only while this remains the latest
163
194
  // optimistic gesture so an older save can never clobber a newer drag.
164
- for (const e of edits) {
165
- const key = keyOf(e.element);
166
- if (isLatestTimelineOptimisticGesture(updateElement, revision, key)) applyEdit(e);
167
- }
195
+ for (const e of edits) applyEdit(e, true);
168
196
  return true;
169
197
  },
170
198
  (error) => {
171
199
  for (const p of prev) {
200
+ const { audioGroup, ...timing } = p.updates;
201
+ const updates: Partial<TimelineElement> = {};
172
202
  if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
173
- updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
203
+ Object.assign(updates, timing);
174
204
  }
205
+ if (
206
+ isLatestTimelineOptimisticGesture(updateElement, membershipRevision, p.key, "membership")
207
+ )
208
+ updates.audioGroup = audioGroup;
209
+ if (Object.keys(updates).length) updateElement(p.key, updates);
175
210
  }
176
211
  console.error("[Timeline] Failed to persist clip edits", error);
177
212
  return false;
@@ -370,7 +405,14 @@ function buildTrackInsertEdits(
370
405
  let start = src.start;
371
406
  if (normKey === editKey) start = previewStart;
372
407
  else if (multi?.keys.has(normKey)) start = multi.movedStart(src);
373
- edits.push({ element: src, updates: { start, track: norm.track } });
408
+ edits.push({
409
+ element: src,
410
+ updates: {
411
+ start,
412
+ track: norm.track,
413
+ ...(normKey === editKey && src.audioGroup ? { audioGroup: null } : {}),
414
+ },
415
+ });
374
416
  }
375
417
  return { candidate, edits };
376
418
  }
@@ -1,3 +1,4 @@
1
+ import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
1
2
  import type { Dispatch, RefObject, SetStateAction } from "react";
2
3
  import { resolveTimelineDragEscape } from "./timelineEditing";
3
4
  import { commitDraggedClipMove } from "./timelineClipDragCommit";
@@ -37,6 +38,8 @@ interface TimelineGestureCommit {
37
38
  type UpdateElement = ReturnType<typeof usePlayerStore.getState>["updateElement"];
38
39
 
39
40
  interface TimelineClipDragGestureLifecycleInput {
41
+ commitGestureRef?: RefObject<() => void>;
42
+ trackInsertLayoutRef?: RefObject<TimelineTrackInsertLayout | undefined>;
40
43
  lifecycleRef: RefObject<TimelineGestureLifecycle>;
41
44
  sessionEpochRef: RefObject<number>;
42
45
  cancelGestureRef: RefObject<
@@ -81,6 +84,8 @@ interface TimelineClipDragGestureLifecycleInput {
81
84
  export function mountTimelineClipDragGestureLifecycle({
82
85
  // The explicit destructuring mirrors the single call site's dependency object by design.
83
86
  // fallow-ignore-next-line code-duplication
87
+ commitGestureRef,
88
+ trackInsertLayoutRef,
84
89
  lifecycleRef,
85
90
  sessionEpochRef,
86
91
  cancelGestureRef,
@@ -120,6 +125,7 @@ export function mountTimelineClipDragGestureLifecycle({
120
125
  };
121
126
 
122
127
  const pointerMatchesGesture = (event: PointerEvent): boolean => {
128
+ if (draggedClipRef.current?.pointerId === null) return false;
123
129
  const pointerId = lifecycleRef.current.pointerId;
124
130
  return pointerId === null || event.pointerId === pointerId;
125
131
  };
@@ -293,6 +299,7 @@ export function mountTimelineClipDragGestureLifecycle({
293
299
  commitDraggedClipMove(drag, {
294
300
  elements: elementsRef.current,
295
301
  trackOrder: trackOrderRef.current,
302
+ trackInsertLayout: trackInsertLayoutRef?.current,
296
303
  updateElement,
297
304
  onMoveElement: onMoveElementRef.current,
298
305
  onMoveElements: onMoveElementsRef.current,
@@ -315,10 +322,12 @@ export function mountTimelineClipDragGestureLifecycle({
315
322
  );
316
323
  };
317
324
 
318
- const claimActiveGesture = (event: PointerEvent): TimelineGestureCommit | "ignored" | null => {
325
+ const claimActiveGesture = (
326
+ event: PointerEvent | null,
327
+ ): TimelineGestureCommit | "ignored" | null => {
319
328
  const lifecycle = lifecycleRef.current;
320
329
  if (lifecycle.phase !== "active") return null;
321
- if (!pointerMatchesGesture(event)) return "ignored";
330
+ if (event && !pointerMatchesGesture(event)) return "ignored";
322
331
  if (lifecycle.sessionEpoch !== sessionEpochRef.current) {
323
332
  cancelGesture();
324
333
  return "ignored";
@@ -358,6 +367,12 @@ export function mountTimelineClipDragGestureLifecycle({
358
367
  }
359
368
  };
360
369
 
370
+ if (commitGestureRef)
371
+ commitGestureRef.current = () => {
372
+ const claimed = claimActiveGesture(null);
373
+ if (claimed && claimed !== "ignored") commitClaimedGesture(claimed);
374
+ };
375
+
361
376
  const handleWindowPointerUp = (event: PointerEvent) => {
362
377
  if (releasedOutsideWindow(event)) return handleWindowPointerCancel(event);
363
378
  const claimed = claimActiveGesture(event);
@@ -411,6 +426,7 @@ export function mountTimelineClipDragGestureLifecycle({
411
426
  return () => {
412
427
  cancelGesture({ updateReact: false });
413
428
  cancelGestureRef.current = () => false;
429
+ if (commitGestureRef) commitGestureRef.current = () => {};
414
430
  window.removeEventListener("pointermove", handleWindowPointerMove);
415
431
  window.removeEventListener("pointerup", handleWindowPointerUp);
416
432
  window.removeEventListener("pointercancel", handleWindowPointerCancel);
@@ -7,8 +7,18 @@ import {
7
7
  type DragPreviewContext,
8
8
  } from "./timelineClipDragPreview";
9
9
  import type { DraggedClipState } from "./timelineClipDragTypes";
10
- import { commitDraggedClipMove, persistMoveEdits } from "./timelineClipDragCommit";
11
- import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
10
+ import {
11
+ commitDraggedClipMove,
12
+ persistMoveEdits,
13
+ type TimelineMoveEdit,
14
+ } from "./timelineClipDragCommit";
15
+ import {
16
+ LANE_H,
17
+ RULER_H,
18
+ TRACKS_TOP_PAD,
19
+ TRACK_H,
20
+ createTimelineRowGeometry,
21
+ } from "./timelineLayout";
12
22
  import { isMultiDragPassenger } from "./timelineMultiDragPreview";
13
23
  import { resolveMultiDragPreview } from "./timelineProviderStateBuilders";
14
24
 
@@ -110,6 +120,20 @@ function horizontalDrag(
110
120
  }
111
121
 
112
122
  describe("computeDragPreview — plain horizontal drag never arms a phantom insert (BUG 1)", () => {
123
+ it("opens the physical seam below a zero-padding ruler and keeps it armed inside the new lane", () => {
124
+ const geometry = createTimelineRowGeometry([0, 1, 2], [104, 48, 48], { top: 0 });
125
+ const context = { ...ctx(geometry.rowHeights), rowGeometry: geometry };
126
+ const { drag, clientX } = horizontalDrag(moodboard, 0.5, 2);
127
+ const top = computeDragPreview(drag, clientX, 24, context);
128
+ expect(top.insertRow).toBe(0);
129
+ const between = computeDragPreview(drag, clientX, 128, context);
130
+ expect(between.insertRow).toBe(1);
131
+ const inside = computeDragPreview(between, clientX, 150, context);
132
+ expect(inside.insertRow).toBe(1);
133
+ const below = computeDragPreview(inside, clientX, 202, context);
134
+ expect(below.insertRow).toBeNull();
135
+ });
136
+
113
137
  it("dragging v-moodboard +2s while grabbing its clip body keeps it a pure time move", () => {
114
138
  const { drag, clientX, clientY } = horizontalDrag(moodboard, 0.5, 2);
115
139
  const next = computeDragPreview(drag, clientX, clientY, ctx());
@@ -470,11 +494,10 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
470
494
  expect(committedStart(ghost, tag, rows)).toBe(10);
471
495
  });
472
496
 
473
- it("the edge of a row is still that row, not a new track", () => {
497
+ it("a clip aimed at a seam opens a new track at that boundary", () => {
474
498
  for (const edge of [0.02, 0.98, 1.02]) {
475
499
  const ghost = preview(tag, rows, 2, edge);
476
- expect(ghost.insertRow).toBeNull();
477
- expect(ghost.previewTrack).toBe(Math.floor(edge));
500
+ expect(ghost.insertRow).toBe(Math.round(edge));
478
501
  }
479
502
  });
480
503
  });
@@ -550,6 +573,23 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
550
573
  });
551
574
 
552
575
  describe("computeDragPreview — a group move keeps its shape", () => {
576
+ function commitPreview(
577
+ ghost: DraggedClipState,
578
+ elements: TimelineElement[],
579
+ selectedKeys: ReadonlySet<string>,
580
+ ) {
581
+ const onMoveElements = vi.fn<(edits: TimelineMoveEdit[]) => void>();
582
+ commitDraggedClipMove(ghost, {
583
+ elements,
584
+ trackOrder: [0, 1, 2],
585
+ updateElement: vi.fn(),
586
+ onMoveElement: vi.fn(),
587
+ onMoveElements,
588
+ selectedKeys,
589
+ });
590
+ return onMoveElements.mock.calls[0][0];
591
+ }
592
+
553
593
  it("never bumps the grabbed clip further left than the group's 0 limit", () => {
554
594
  const a = clip("a", 1, 0.5, 1, 1);
555
595
  const b = clip("b", 2, 7, 2, 1);
@@ -563,19 +603,7 @@ describe("computeDragPreview — a group move keeps its shape", () => {
563
603
  selectedKeys,
564
604
  });
565
605
  expect(ghost).toMatchObject({ previewTrack: 0, insertRow: null, previewStart: 10 });
566
- const onMoveElements = vi.fn();
567
- commitDraggedClipMove(ghost, {
568
- elements,
569
- trackOrder: [0, 1, 2],
570
- updateElement: vi.fn(),
571
- onMoveElement: vi.fn(),
572
- onMoveElements,
573
- selectedKeys,
574
- });
575
- const edits = onMoveElements.mock.calls[0][0] as Array<{
576
- element: TimelineElement;
577
- updates: { start: number };
578
- }>;
606
+ const edits = commitPreview(ghost, elements, selectedKeys);
579
607
  const moved = Object.fromEntries(
580
608
  edits.map((e) => [e.element.id, e.updates.start - e.element.start]),
581
609
  );
@@ -595,19 +623,7 @@ describe("computeDragPreview — a group move keeps its shape", () => {
595
623
  ...ctx(undefined, elements),
596
624
  selectedKeys,
597
625
  });
598
- const onMoveElements = vi.fn();
599
- commitDraggedClipMove(ghost, {
600
- elements,
601
- trackOrder: [0, 1, 2],
602
- updateElement: vi.fn(),
603
- onMoveElement: vi.fn(),
604
- onMoveElements,
605
- selectedKeys,
606
- });
607
- const edits = onMoveElements.mock.calls[0][0] as Array<{
608
- element: TimelineElement;
609
- updates: { start: number; track: number };
610
- }>;
626
+ const edits = commitPreview(ghost, elements, selectedKeys);
611
627
  return Object.fromEntries(edits.map((e) => [e.element.id, e.updates]));
612
628
  }
613
629