@hyperframes/studio 0.7.45 → 0.7.47

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 (110) hide show
  1. package/dist/assets/index-Dq7FEg0K.css +1 -0
  2. package/dist/assets/{index-CZkdm5YL.js → index-DsckwbdW.js} +1 -1
  3. package/dist/assets/index-VsAbY3rl.js +423 -0
  4. package/dist/assets/{index-CbQKp0Ek.js → index-uvB30_yI.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-CZUbpYhQ.js +0 -416
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -5,69 +5,29 @@ import {
5
5
  resolveTimelineResize,
6
6
  resolveTimelineAutoScroll,
7
7
  type BlockedTimelineEditIntent,
8
+ type TimelineStackingReorderIntent,
8
9
  } from "./timelineEditing";
9
10
  import { usePlayerStore } from "../store/playerStore";
10
11
  import type { TimelineElement } from "../store/playerStore";
11
12
  import { TRACK_H } from "./timelineLayout";
12
13
  import { isMusicTrack } from "../../utils/timelineInspector";
13
14
  import { mergeUserBeats } from "../../utils/beatEditing";
15
+ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
16
+ import type {
17
+ TimelineGroupMoveChange,
18
+ TimelineGroupResizeChange,
19
+ } from "../../hooks/useTimelineGroupEditing";
20
+ import {
21
+ buildTimelineSnapTargets,
22
+ snapEdgesToTargets,
23
+ snapResizeEdgeToTargets,
24
+ type TimelineSnapKind,
25
+ } from "./timelineSnapTargets";
26
+ import { resolveDragPreviewPlacement } from "./timelineClipDragPreview";
27
+ import { useTimelineClipGroupDrag } from "./useTimelineClipGroupDrag";
14
28
 
15
- const BEAT_SNAP_PX = 8;
16
29
  const EMPTY_BEAT_TIMES: number[] = [];
17
30
 
18
- function snapToNearestBeat(time: number, beatTimes: number[], thresholdSecs: number): number {
19
- let best = time;
20
- let bestDist = thresholdSecs;
21
- for (const bt of beatTimes) {
22
- const d = Math.abs(bt - time);
23
- if (d < bestDist) {
24
- bestDist = d;
25
- best = bt;
26
- }
27
- }
28
- return best;
29
- }
30
-
31
- /**
32
- * Snap a moved clip so whichever edge (start or end) is nearest a beat lands on
33
- * it, keeping the duration fixed. Returns the (clamped) start plus the beat time
34
- * it snapped to (for the grid-line highlight), or `beat: null` when no edge is
35
- * within threshold.
36
- */
37
- function snapMoveStartToBeat(
38
- start: number,
39
- duration: number,
40
- beatTimes: number[],
41
- pixelsPerSecond: number,
42
- timelineDuration: number,
43
- ): { start: number; beat: number | null } {
44
- if (beatTimes.length === 0) return { start, beat: null };
45
- const snapSecs = BEAT_SNAP_PX / Math.max(pixelsPerSecond, 1);
46
- const snappedStart = snapToNearestBeat(start, beatTimes, snapSecs);
47
- const snappedEnd = snapToNearestBeat(start + duration, beatTimes, snapSecs);
48
- const startMoved = snappedStart !== start;
49
- const endMoved = snappedEnd !== start + duration;
50
-
51
- let candidate = start;
52
- let beat: number | null = null;
53
- if (
54
- startMoved &&
55
- (!endMoved || Math.abs(snappedStart - start) <= Math.abs(snappedEnd - (start + duration)))
56
- ) {
57
- candidate = snappedStart;
58
- beat = snappedStart;
59
- } else if (endMoved) {
60
- candidate = snappedEnd - duration;
61
- beat = snappedEnd;
62
- }
63
-
64
- const maxStart = Math.max(0, timelineDuration - duration);
65
- const clamped = Math.max(0, Math.min(maxStart, Math.round(candidate * 1000) / 1000));
66
- // If clamping pulled the clip off the snap target, drop the highlight.
67
- if (beat != null && Math.abs(clamped - candidate) > 1e-6) beat = null;
68
- return { start: clamped, beat };
69
- }
70
-
71
31
  /* ── Shared state types ─────────────────────────────────────────── */
72
32
  export interface DraggedClipState {
73
33
  element: TimelineElement;
@@ -81,8 +41,14 @@ export interface DraggedClipState {
81
41
  pointerOffsetY: number;
82
42
  previewStart: number;
83
43
  previewTrack: number;
44
+ previewLayerId: TimelineLayerId;
45
+ previewLayerIndex: number;
84
46
  /** Beat time the clip will snap to on drop, for the grid-line highlight. */
85
47
  snapBeatTime: number | null;
48
+ snapGuideTime: number | null;
49
+ snapGuideKind: TimelineSnapKind | null;
50
+ /** Sibling-scoped z-index reorder intent resolved from the vertical drag. */
51
+ previewStackingReorder: TimelineStackingReorderIntent | null;
86
52
  started: boolean;
87
53
  }
88
54
 
@@ -93,6 +59,8 @@ export interface ResizingClipState {
93
59
  previewStart: number;
94
60
  previewDuration: number;
95
61
  previewPlaybackStart?: number;
62
+ snapGuideTime: number | null;
63
+ snapGuideKind: TimelineSnapKind | null;
96
64
  started: boolean;
97
65
  }
98
66
 
@@ -108,29 +76,41 @@ export interface BlockedClipState {
108
76
  interface UseTimelineClipDragInput {
109
77
  scrollRef: React.RefObject<HTMLDivElement | null>;
110
78
  ppsRef: React.RefObject<number>;
111
- durationRef: React.RefObject<number>;
112
- trackOrderRef: React.RefObject<number[]>;
79
+ trackOrderRef: React.RefObject<TimelineLayerId[]>;
80
+ timelineLayersRef: React.RefObject<StackingTimelineLayer[]>;
81
+ timelineElementsRef: React.RefObject<TimelineElement[]>;
113
82
  onMoveElement?: (
114
83
  element: TimelineElement,
115
- updates: Pick<TimelineElement, "start" | "track">,
84
+ updates: Pick<TimelineElement, "start" | "track"> & {
85
+ stackingReorder?: TimelineStackingReorderIntent | null;
86
+ },
116
87
  ) => Promise<void> | void;
117
88
  onResizeElement?: (
118
89
  element: TimelineElement,
119
90
  updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
120
91
  ) => Promise<void> | void;
92
+ onMoveElements?: (changes: TimelineGroupMoveChange[]) => Promise<void> | void;
93
+ onResizeElements?: (changes: TimelineGroupResizeChange[]) => Promise<void> | void;
94
+ onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
95
+ onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void;
121
96
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
122
97
  setShowPopover: (show: boolean) => void;
123
- /** Stable ref to the range selection setter wired after mount to break circular dependency. */
98
+ /** Stable ref to the range selection setter, wired after mount to break circular dependency. */
124
99
  setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
125
100
  }
126
101
 
127
102
  export function useTimelineClipDrag({
128
103
  scrollRef,
129
104
  ppsRef,
130
- durationRef,
131
105
  trackOrderRef,
106
+ timelineLayersRef,
107
+ timelineElementsRef,
132
108
  onMoveElement,
133
109
  onResizeElement,
110
+ onMoveElements,
111
+ onResizeElements,
112
+ onPreviewMoveElements,
113
+ onPreviewResizeElements,
134
114
  onBlockedEditAttempt,
135
115
  setShowPopover,
136
116
  setRangeSelectionRef,
@@ -139,6 +119,8 @@ export function useTimelineClipDrag({
139
119
  const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
140
120
  const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
141
121
  const beatEdits = usePlayerStore((s) => s.beatEdits);
122
+ const playhead = usePlayerStore((s) => s.currentTime);
123
+ const compositionDuration = usePlayerStore((s) => s.duration);
142
124
  const musicStart = usePlayerStore((s) => s.elements.find(isMusicTrack)?.start ?? 0);
143
125
  const musicPlaybackStart = usePlayerStore(
144
126
  (s) => s.elements.find(isMusicTrack)?.playbackStart ?? 0,
@@ -166,6 +148,29 @@ export function useTimelineClipDrag({
166
148
 
167
149
  const beatTimesRef = useRef<number[]>([]);
168
150
  beatTimesRef.current = adjustedBeatTimes;
151
+ const playheadRef = useRef(0);
152
+ playheadRef.current = playhead;
153
+ const compositionDurationRef = useRef(0);
154
+ compositionDurationRef.current = compositionDuration;
155
+
156
+ const buildSnapTargets = useCallback(
157
+ (element: TimelineElement) => {
158
+ const draggedKey = element.key ?? element.id;
159
+ const selected = selectedElementIdsRef.current;
160
+ // In a group drag every selected clip moves together, so none of them may act
161
+ // as a snap target for the others; exclude the whole set, not just the grabbed clip.
162
+ const excludedKeys =
163
+ selected.size > 1 && selected.has(draggedKey) ? selected : new Set([draggedKey]);
164
+ return buildTimelineSnapTargets({
165
+ elements: timelineElementsRef.current,
166
+ excludedKeys,
167
+ playhead: playheadRef.current,
168
+ compDuration: compositionDurationRef.current,
169
+ beats: isMusicTrack(element) ? EMPTY_BEAT_TIMES : beatTimesRef.current,
170
+ });
171
+ },
172
+ [timelineElementsRef],
173
+ );
169
174
 
170
175
  const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
171
176
  const draggedClipRef = useRef<DraggedClipState | null>(null);
@@ -182,10 +187,35 @@ export function useTimelineClipDrag({
182
187
  onMoveElementRef.current = onMoveElement;
183
188
  const onResizeElementRef = useRef(onResizeElement);
184
189
  onResizeElementRef.current = onResizeElement;
190
+ const onMoveElementsRef = useRef(onMoveElements);
191
+ onMoveElementsRef.current = onMoveElements;
192
+ const onResizeElementsRef = useRef(onResizeElements);
193
+ onResizeElementsRef.current = onResizeElements;
194
+ const onPreviewMoveElementsRef = useRef(onPreviewMoveElements);
195
+ onPreviewMoveElementsRef.current = onPreviewMoveElements;
196
+ const onPreviewResizeElementsRef = useRef(onPreviewResizeElements);
197
+ onPreviewResizeElementsRef.current = onPreviewResizeElements;
198
+ const selectedElementIdsRef = useRef(usePlayerStore.getState().selectedElementIds);
199
+ selectedElementIdsRef.current = usePlayerStore((s) => s.selectedElementIds);
185
200
 
186
201
  const clipDragScrollRaf = useRef(0);
187
202
  const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
203
+ const {
204
+ previewGroupMove,
205
+ previewGroupResize,
206
+ commitGroupMove,
207
+ commitGroupResize,
208
+ clearGroupDragSessions,
209
+ } = useTimelineClipGroupDrag({
210
+ timelineElementsRef,
211
+ updateElement,
212
+ onMoveElementsRef,
213
+ onResizeElementsRef,
214
+ onPreviewMoveElementsRef,
215
+ onPreviewResizeElementsRef,
216
+ });
188
217
 
218
+ // fallow-ignore-next-line complexity
189
219
  const updateDraggedClipPreview = useCallback(
190
220
  (drag: DraggedClipState, clientX: number, clientY: number): DraggedClipState => {
191
221
  const scroll = scrollRef.current;
@@ -202,33 +232,45 @@ export function useTimelineClipDrag({
202
232
  currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
203
233
  pixelsPerSecond: ppsRef.current,
204
234
  trackHeight: TRACK_H,
205
- maxStart: Math.max(0, durationRef.current - drag.element.duration),
206
- trackOrder: trackOrderRef.current,
235
+ maxStart: Number.POSITIVE_INFINITY,
236
+ trackOrder: timelineLayersRef.current.map((layer) => layer.placementTrack),
237
+ layerOrder: trackOrderRef.current,
238
+ timelineLayers: timelineLayersRef.current,
239
+ stackingElement: drag.element,
240
+ stackingElements: timelineElementsRef.current,
207
241
  },
208
242
  clientX,
209
243
  clientY,
210
244
  );
211
- // The music track defines the beats, so it must not snap to itself.
212
- const snap = isMusicTrack(drag.element)
213
- ? { start: nextMove.start, beat: null }
214
- : snapMoveStartToBeat(
215
- nextMove.start,
216
- drag.element.duration,
217
- beatTimesRef.current,
218
- ppsRef.current,
219
- durationRef.current,
220
- );
245
+ const snap = snapEdgesToTargets(
246
+ nextMove.start,
247
+ drag.element.duration,
248
+ buildSnapTargets(drag.element),
249
+ ppsRef.current,
250
+ { maxStart: Number.POSITIVE_INFINITY },
251
+ );
252
+ const groupMove = previewGroupMove(drag.element, selectedElementIdsRef.current, snap.start);
253
+ const placement = resolveDragPreviewPlacement(drag, nextMove, groupMove);
221
254
  return {
222
255
  ...drag,
223
256
  started: true,
224
257
  pointerClientX: clientX,
225
258
  pointerClientY: clientY,
226
- previewStart: snap.start,
227
- previewTrack: nextMove.track,
228
- snapBeatTime: snap.beat,
259
+ ...placement,
260
+ snapBeatTime: snap.snapKind === "beat" ? snap.snapTime : null,
261
+ snapGuideTime: snap.snapTime,
262
+ snapGuideKind: snap.snapKind,
229
263
  };
230
264
  },
231
- [scrollRef, ppsRef, durationRef, trackOrderRef],
265
+ [
266
+ scrollRef,
267
+ ppsRef,
268
+ trackOrderRef,
269
+ timelineLayersRef,
270
+ timelineElementsRef,
271
+ buildSnapTargets,
272
+ previewGroupMove,
273
+ ],
232
274
  );
233
275
 
234
276
  const stopClipDragAutoScroll = useCallback(() => {
@@ -287,6 +329,12 @@ export function useTimelineClipDrag({
287
329
 
288
330
  const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview);
289
331
  updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
332
+ const commitGroupMoveRef = useRef(commitGroupMove);
333
+ commitGroupMoveRef.current = commitGroupMove;
334
+ const commitGroupResizeRef = useRef(commitGroupResize);
335
+ commitGroupResizeRef.current = commitGroupResize;
336
+ const clearGroupDragSessionsRef = useRef(clearGroupDragSessions);
337
+ clearGroupDragSessionsRef.current = clearGroupDragSessions;
290
338
  const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
291
339
  syncClipDragAutoScrollRef.current = syncClipDragAutoScroll;
292
340
  const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
@@ -299,6 +347,7 @@ export function useTimelineClipDrag({
299
347
  });
300
348
  };
301
349
 
350
+ // fallow-ignore-next-line complexity
302
351
  const handleWindowPointerMove = (e: PointerEvent) => {
303
352
  const drag = draggedClipRef.current;
304
353
  const resize = resizingClipRef.current;
@@ -322,7 +371,7 @@ export function useTimelineClipDrag({
322
371
  const normalizedTag = resize.element.tag.toLowerCase();
323
372
  const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
324
373
  const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
325
- const maxEnd = Math.min(durationRef.current, resize.element.start + sourceRemaining);
374
+ const maxEnd = resize.element.start + sourceRemaining;
326
375
  let nextResize = resolveTimelineResize(
327
376
  {
328
377
  start: resize.element.start,
@@ -341,54 +390,43 @@ export function useTimelineClipDrag({
341
390
  e.clientX,
342
391
  );
343
392
 
344
- // Snap edge to beat grid when beat analysis is available. The snap must
345
- // stay inside the same limits resolveTimelineResize enforces, or it would
346
- // push the edge past the available source media / composition end.
347
- // The music track defines the beats, so it must not snap to itself.
348
- const beatTimes = beatTimesRef.current;
349
- if (beatTimes.length > 0 && !isMusicTrack(resize.element)) {
350
- const snapSecs = BEAT_SNAP_PX / Math.max(ppsRef.current, 1);
351
- if (resize.edge === "end") {
352
- const edgeTime = nextResize.start + nextResize.duration;
353
- const snapped = snapToNearestBeat(edgeTime, beatTimes, snapSecs);
354
- // Stay within [start+minDuration, maxEnd] so the snap can't create a
355
- // degenerate clip or run past the source/composition limit.
356
- const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
357
- if (snapped !== edgeTime && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
358
- nextResize = { ...nextResize, duration: snappedDuration };
359
- }
360
- } else {
361
- const snapped = snapToNearestBeat(nextResize.start, beatTimes, snapSecs);
362
- const delta = nextResize.start - snapped; // >0 when snapping left
363
- // Leftward snap reveals more source; cap so playbackStart can't go < 0.
364
- const maxLeftDelta =
365
- nextResize.playbackStart != null
393
+ const snap = snapResizeEdgeToTargets(
394
+ resize.edge,
395
+ nextResize.start,
396
+ nextResize.duration,
397
+ buildSnapTargets(resize.element),
398
+ ppsRef.current,
399
+ {
400
+ minDuration: 0.05,
401
+ maxEnd,
402
+ maxLeftDelta:
403
+ resize.edge === "start" && nextResize.playbackStart != null
366
404
  ? nextResize.playbackStart / playbackRate
367
- : Number.POSITIVE_INFINITY;
368
- // Also require the resulting duration to stay >= minDuration so a
369
- // rightward snap (delta < 0) can't collapse the clip to zero/negative.
370
- const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
371
- if (
372
- snapped !== nextResize.start &&
373
- snapped >= 0 &&
374
- delta <= maxLeftDelta + 1e-6 &&
375
- snappedDuration >= 0.05
376
- ) {
377
- nextResize = {
378
- ...nextResize,
379
- start: snapped,
380
- duration: snappedDuration,
381
- playbackStart:
382
- nextResize.playbackStart != null
383
- ? Math.round(
384
- Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000,
385
- ) / 1000
386
- : undefined,
387
- };
388
- }
405
+ : Number.POSITIVE_INFINITY,
406
+ },
407
+ );
408
+ if (snap.snapTime != null) {
409
+ const unsnappedStart = nextResize.start;
410
+ nextResize = { ...nextResize, start: snap.start, duration: snap.duration };
411
+ if (resize.edge === "start" && nextResize.playbackStart != null) {
412
+ const delta = unsnappedStart - snap.start;
413
+ nextResize = {
414
+ ...nextResize,
415
+ playbackStart:
416
+ Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
417
+ 1000,
418
+ };
389
419
  }
390
420
  }
391
-
421
+ const groupResize = previewGroupResize(
422
+ resize.element,
423
+ selectedElementIdsRef.current,
424
+ resize.edge,
425
+ nextResize,
426
+ );
427
+ if (groupResize.active) {
428
+ nextResize = groupResize.updates;
429
+ }
392
430
  setResizingClip((prev) =>
393
431
  prev
394
432
  ? {
@@ -397,6 +435,8 @@ export function useTimelineClipDrag({
397
435
  previewStart: nextResize.start,
398
436
  previewDuration: nextResize.duration,
399
437
  previewPlaybackStart: nextResize.playbackStart,
438
+ snapGuideTime: snap.snapTime,
439
+ snapGuideKind: snap.snapKind,
400
440
  }
401
441
  : prev,
402
442
  );
@@ -434,6 +474,7 @@ export function useTimelineClipDrag({
434
474
  syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
435
475
  };
436
476
 
477
+ // fallow-ignore-next-line complexity
437
478
  const handleWindowPointerUp = () => {
438
479
  stopClipDragAutoScrollRef.current();
439
480
 
@@ -446,6 +487,8 @@ export function useTimelineClipDrag({
446
487
  suppressClickRef.current = true;
447
488
  clearSuppressedClick();
448
489
 
490
+ if (commitGroupResizeRef.current(resize.element)) return;
491
+
449
492
  const hasChanged =
450
493
  resize.previewStart !== resize.element.start ||
451
494
  resize.previewDuration !== resize.element.duration ||
@@ -492,24 +535,26 @@ export function useTimelineClipDrag({
492
535
  suppressClickRef.current = true;
493
536
  clearSuppressedClick();
494
537
 
495
- const hasChanged =
496
- drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track;
538
+ if (commitGroupMoveRef.current(drag.element)) return;
539
+
540
+ const hasStackingReorder =
541
+ drag.previewStackingReorder != null && drag.previewStackingReorder.zIndexChanges.length > 0;
542
+ const hasChanged = drag.previewStart !== drag.element.start || hasStackingReorder;
497
543
  if (!hasChanged) return;
498
544
 
499
545
  updateElement(drag.element.key ?? drag.element.id, {
500
546
  start: drag.previewStart,
501
- track: drag.previewTrack,
502
547
  });
503
548
 
504
549
  Promise.resolve(
505
550
  onMoveElementRef.current?.(drag.element, {
506
551
  start: drag.previewStart,
507
- track: drag.previewTrack,
552
+ track: drag.element.track,
553
+ stackingReorder: drag.previewStackingReorder,
508
554
  }),
509
555
  ).catch((error) => {
510
556
  updateElement(drag.element.key ?? drag.element.id, {
511
557
  start: drag.element.start,
512
- track: drag.element.track,
513
558
  });
514
559
  console.error("[Timeline] Failed to persist clip move", error);
515
560
  });
@@ -519,6 +564,7 @@ export function useTimelineClipDrag({
519
564
  window.addEventListener("pointerup", handleWindowPointerUp);
520
565
  window.addEventListener("pointercancel", handleWindowPointerUp);
521
566
  return () => {
567
+ clearGroupDragSessionsRef.current();
522
568
  stopClipDragAutoScrollRef.current();
523
569
  window.removeEventListener("pointermove", handleWindowPointerMove);
524
570
  window.removeEventListener("pointerup", handleWindowPointerUp);