@hyperframes/studio 0.7.46 → 0.7.48

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-DO2MAFSG.js → index-CWHON2kh.js} +1 -1
  2. package/dist/assets/index-DX02Q1Yl.js +423 -0
  3. package/dist/assets/index-Dq7FEg0K.css +1 -0
  4. package/dist/assets/{index-BGVoOXdg.js → index-RTqYPUN7.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-BsLQ2qGn.js +0 -420
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -1,5 +1,4 @@
1
- import { memo, type ReactNode } from "react";
2
- import { Eye, EyeSlash } from "@phosphor-icons/react";
1
+ import { Fragment, memo, type ReactNode } from "react";
3
2
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
4
3
  import { TimelineClip } from "./TimelineClip";
5
4
  import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
@@ -12,7 +11,7 @@ import {
12
11
  type TimelineRangeSelection,
13
12
  } from "./timelineEditing";
14
13
  import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme";
15
- import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
14
+ import { GUTTER, TRACK_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout";
16
15
  import {
17
16
  usePlayerStore,
18
17
  type TimelineElement,
@@ -20,10 +19,21 @@ import {
20
19
  } from "../store/playerStore";
21
20
  import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
22
21
  import type { TrackVisualStyle } from "./timelineIcons";
22
+ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
23
23
  import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
24
24
  import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
25
25
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
26
26
  import { isMusicTrack } from "../../utils/timelineInspector";
27
+ import { TimelineLayerGutter } from "./TimelineLayerGutter";
28
+ import {
29
+ shouldShowTimelineLayerGroupHeader,
30
+ TimelineLayerGroupHeader,
31
+ } from "./TimelineLayerGroupHeader";
32
+ import { resolveTimelineDropIndicator } from "./timelineDropIndicator";
33
+ import { TimelineDropInsertionLine } from "./TimelineDropInsertionLine";
34
+ import { TimelineDragGhost } from "./TimelineDragGhost";
35
+ import { TimelineSelectionOverlays } from "./TimelineSelectionOverlays";
36
+ import type { TimelineMarqueeOverlayRect } from "./useTimelineMarqueeSelection";
27
37
 
28
38
  function ClipLintDot({ element }: { element: TimelineElement }) {
29
39
  const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
@@ -46,11 +56,12 @@ interface TimelineCanvasProps {
46
56
  effectiveDuration: number;
47
57
  majorTickInterval: number;
48
58
  rangeSelection: TimelineRangeSelection | null;
59
+ marqueeRect: TimelineMarqueeOverlayRect | null;
49
60
  theme: TimelineTheme;
50
- displayTrackOrder: number[];
51
- trackOrder: number[];
52
- tracks: [number, TimelineElement[]][];
53
- trackStyles: Map<number, TrackVisualStyle>;
61
+ displayTrackOrder: TimelineLayerId[];
62
+ trackOrder: TimelineLayerId[];
63
+ tracks: StackingTimelineLayer[];
64
+ trackStyles: Map<TimelineLayerId, TrackVisualStyle>;
54
65
  selectedElementId: string | null;
55
66
  hoveredClip: string | null;
56
67
  draggedClip: DraggedClipState | null;
@@ -104,6 +115,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
104
115
  effectiveDuration,
105
116
  majorTickInterval,
106
117
  rangeSelection,
118
+ marqueeRect,
107
119
  theme,
108
120
  displayTrackOrder,
109
121
  trackOrder,
@@ -112,7 +124,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
112
124
  selectedElementId,
113
125
  hoveredClip,
114
126
  draggedClip,
115
- resizingClip: _resizingClip,
127
+ resizingClip,
116
128
  blockedClipRef,
117
129
  suppressClickRef,
118
130
  scrollRef,
@@ -141,9 +153,21 @@ export const TimelineCanvas = memo(function TimelineCanvas({
141
153
  onContextMenuClip,
142
154
  beatAnalysis,
143
155
  }: TimelineCanvasProps) {
144
- const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
145
- useTimelineEditContextOptional();
156
+ const {
157
+ onResizeElement,
158
+ onMoveElement,
159
+ onToggleTrackHidden,
160
+ onToggleElementHidden,
161
+ onRazorSplit,
162
+ onRazorSplitAll,
163
+ } = useTimelineEditContextOptional();
146
164
  const beatDragging = usePlayerStore((s) => s.beatDragging);
165
+ const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
166
+ const activeSnapGuideTime = draggedClip?.started
167
+ ? (draggedClip.snapBeatTime ?? draggedClip.snapGuideTime)
168
+ : resizingClip?.started
169
+ ? resizingClip.snapGuideTime
170
+ : null;
147
171
  const draggedElement = draggedClip?.element ?? null;
148
172
  const activeDraggedElement =
149
173
  draggedClip?.started === true && draggedElement
@@ -181,9 +205,16 @@ export const TimelineCanvas = memo(function TimelineCanvas({
181
205
  )}
182
206
  </>
183
207
  );
208
+ const activeDropPlacement =
209
+ draggedClip?.started === true ? (draggedClip.previewStackingReorder?.placement ?? null) : null;
184
210
 
185
211
  return (
186
- <div className="relative" style={{ height: totalH, width: GUTTER + trackContentWidth }}>
212
+ // minWidth:100% makes the lanes and ruler fill the panel when the composition
213
+ // is narrower than the viewport (zoomed out); content stays in time coords.
214
+ <div
215
+ className="relative"
216
+ style={{ height: totalH, width: GUTTER + trackContentWidth, minWidth: "100%" }}
217
+ >
187
218
  <TimelineRuler
188
219
  major={major}
189
220
  minor={minor}
@@ -198,13 +229,35 @@ export const TimelineCanvas = memo(function TimelineCanvas({
198
229
 
199
230
  {
200
231
  // fallow-ignore-next-line complexity
201
- displayTrackOrder.map((trackNum) => {
202
- const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
203
- const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
232
+ displayTrackOrder.map((layerId, rowIndex) => {
233
+ const layer = tracks.find((item) => item.id === layerId) ?? null;
234
+ const previousLayerId = displayTrackOrder[rowIndex - 1];
235
+ const previousLayer = previousLayerId
236
+ ? (tracks.find((item) => item.id === previousLayerId) ?? null)
237
+ : null;
238
+ const els = layer?.elements ?? [];
239
+ const ts = trackStyles.get(layerId) ?? getTrackStyle("");
240
+ const isAudioLayer = layer?.kind === "audio";
241
+ const isFirstAudioLayer = isAudioLayer && previousLayer?.kind !== "audio";
204
242
  const isPendingTrack =
205
- draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
243
+ draggedClip?.started === true && !trackOrder.includes(layerId) && els.length === 0;
244
+ const baseRowBackground = rowIndex % 2 === 0 ? theme.rowBackground : "#0D0E12";
245
+ const dropIndicator = resolveTimelineDropIndicator({
246
+ placement: activeDropPlacement,
247
+ layerId,
248
+ layerOrder: displayTrackOrder,
249
+ });
206
250
  const rowBackground =
207
- displayTrackOrder.indexOf(trackNum) % 2 === 0 ? theme.rowBackground : "#0D0E12";
251
+ dropIndicator?.kind === "onto"
252
+ ? theme.clipBackgroundActive
253
+ : isAudioLayer
254
+ ? `linear-gradient(90deg, ${theme.gutterBackground} 0, ${baseRowBackground} 74px)`
255
+ : baseRowBackground;
256
+ const showGroupHeader = shouldShowTimelineLayerGroupHeader(
257
+ layer?.contextKey ?? "",
258
+ previousLayer?.contextKey ?? "",
259
+ );
260
+ const rowTrack = layer?.placementTrack ?? els[0]?.track ?? 0;
208
261
  // The beat-dot strip occupies the top of this track's lane (active track,
209
262
  // or the music track when nothing is selected). When shown, keyframe
210
263
  // diamonds shrink + drop to the bottom half so they don't collide with it.
@@ -215,318 +268,309 @@ export const TimelineCanvas = memo(function TimelineCanvas({
215
268
  : els.some(isMusicTrack));
216
269
  const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
217
270
  return (
218
- <div
219
- key={trackNum}
220
- className="relative flex"
221
- style={{
222
- height: TRACK_H,
223
- background: rowBackground,
224
- borderBottom: `1px solid ${theme.rowBorder}`,
225
- }}
226
- >
271
+ <Fragment key={layerId}>
272
+ {showGroupHeader && layer && (
273
+ <TimelineLayerGroupHeader
274
+ contextKey={layer.contextKey}
275
+ trackContentWidth={trackContentWidth}
276
+ theme={theme}
277
+ accentColor={ts.accent}
278
+ />
279
+ )}
227
280
  <div
228
- className="sticky left-0 z-[12] flex-shrink-0 flex items-center justify-center"
281
+ className="relative flex"
229
282
  style={{
230
- width: GUTTER,
231
- background: theme.gutterBackground,
232
- borderRight: `1px solid ${theme.gutterBorder}`,
283
+ height: TRACK_H,
284
+ background: rowBackground,
285
+ borderTop: isFirstAudioLayer ? `2px solid ${theme.rulerBorder}` : undefined,
286
+ borderBottom: `1px solid ${theme.rowBorder}`,
287
+ boxShadow:
288
+ dropIndicator?.kind === "onto" ? `inset 0 0 0 1px ${ts.accent}` : undefined,
233
289
  }}
234
290
  >
235
- <button
236
- type="button"
237
- aria-label={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
238
- title={isTrackHidden ? `Show track ${trackNum}` : `Hide track ${trackNum}`}
239
- className={`flex h-6 w-6 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
240
- isTrackHidden
241
- ? "text-[#3CE6AC] hover:text-white"
242
- : "text-white/35 hover:text-white/75"
243
- }`}
244
- onPointerDown={(e) => {
245
- e.stopPropagation();
291
+ <TimelineLayerGutter
292
+ isAudio={isAudioLayer}
293
+ isTrackHidden={isTrackHidden}
294
+ rowTrack={rowTrack}
295
+ theme={theme}
296
+ onToggleHidden={() => {
297
+ if (onToggleElementHidden && els.length > 0) {
298
+ for (const element of els) {
299
+ void onToggleElementHidden(element.key ?? element.id, !isTrackHidden);
300
+ }
301
+ return;
302
+ }
303
+ void onToggleTrackHidden?.(rowTrack, !isTrackHidden);
246
304
  }}
247
- onClick={(e) => {
248
- e.stopPropagation();
249
- void onToggleTrackHidden?.(trackNum, !isTrackHidden);
305
+ />
306
+ <div
307
+ style={{
308
+ width: trackContentWidth,
309
+ opacity: isTrackHidden ? 0.35 : 1,
310
+ transition: "opacity 120ms ease",
250
311
  }}
312
+ className="relative"
251
313
  >
252
- {isTrackHidden ? (
253
- <EyeSlash size={14} weight="bold" aria-hidden="true" />
254
- ) : (
255
- <Eye size={14} weight="bold" aria-hidden="true" />
314
+ {layer?.contextKey && (
315
+ <span
316
+ className="absolute bottom-0 top-0 pointer-events-none"
317
+ style={{
318
+ left: 0,
319
+ width: 2,
320
+ background: ts.accent,
321
+ opacity: 0.45,
322
+ zIndex: 2,
323
+ }}
324
+ />
256
325
  )}
257
- </button>
258
- </div>
259
- <div
260
- style={{
261
- width: trackContentWidth,
262
- opacity: isTrackHidden ? 0.35 : 1,
263
- transition: "opacity 120ms ease",
264
- }}
265
- className="relative"
266
- >
267
- {/* Faint beat lines in every track's background (behind the clips);
268
- the active move-snap target is highlighted. */}
269
- <BeatBackgroundLines
270
- beatTimes={beatAnalysis?.beatTimes}
271
- beatStrengths={beatAnalysis?.beatStrengths}
272
- pps={pps}
273
- highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
274
- />
275
- {/* Beat dots on the active track (the one holding the selection),
276
- falling back to the music track when nothing is selected. */}
277
- {beatStripOnTrack && (
278
- <BeatStrip
326
+ {dropIndicator?.kind === "line" && (
327
+ <TimelineDropInsertionLine edge={dropIndicator.edge} accentColor={ts.accent} />
328
+ )}
329
+ {/* Faint beat lines in every track's background (behind the clips);
330
+ the active snap target is highlighted. */}
331
+ <BeatBackgroundLines
279
332
  beatTimes={beatAnalysis?.beatTimes}
280
333
  beatStrengths={beatAnalysis?.beatStrengths}
281
334
  pps={pps}
335
+ highlightTime={activeSnapGuideTime}
282
336
  />
283
- )}
284
- {isPendingTrack && (
285
- <div
286
- className="absolute inset-0 flex items-center"
287
- style={{
288
- paddingLeft: 16,
289
- color: ts.label,
290
- fontSize: 11,
291
- letterSpacing: "0.06em",
292
- textTransform: "uppercase",
293
- opacity: 0.5,
294
- }}
295
- >
296
- New track
297
- </div>
298
- )}
299
- {
300
- // fallow-ignore-next-line complexity
301
- els.map((el) => {
302
- const clipStyle = getTrackStyle(el.tag);
303
- const elementKey = el.key ?? el.id;
304
- const capabilities = getTimelineEditCapabilities(el);
305
- const isSelected = selectedElementId === elementKey;
306
- const isComposition = !!el.compositionSrc;
307
- // elementKey (el.key ?? el.id) is already unique per clip; do NOT
308
- // fold in the map index, or a splice/reorder remounts every clip
309
- // at/after the change (DOM flash, drag interruption).
310
- const clipKey = elementKey;
311
- const isDraggingClip =
312
- draggedClip?.started === true &&
313
- (draggedElement?.key ?? draggedElement?.id) === elementKey;
314
- if (isDraggingClip) return null;
315
- const previewElement = getPreviewElement(el);
316
- return (
317
- <TimelineClip
318
- key={clipKey}
319
- onContextMenu={(e: React.MouseEvent) => {
320
- e.preventDefault();
321
- onContextMenuClip?.(e, el);
322
- }}
323
- el={previewElement}
324
- pps={pps}
325
- clipY={CLIP_Y}
326
- isSelected={isSelected}
327
- isHovered={hoveredClip === clipKey}
328
- isDragging={false}
329
- hasCustomContent={!!renderClipContent}
330
- capabilities={capabilities}
331
- theme={theme}
332
- isComposition={isComposition}
333
- onHoverStart={() => setHoveredClip(clipKey)}
334
- onHoverEnd={() => setHoveredClip(null)}
335
- onResizeStart={(edge, e) => {
336
- if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
337
- if (edge === "start" && !capabilities.canTrimStart) return;
338
- if (edge === "end" && !capabilities.canTrimEnd) return;
339
- e.stopPropagation();
340
- blockedClipRef.current = null;
341
- setShowPopover(false);
342
- setRangeSelection(null);
343
- setResizingClip({
344
- element: el,
345
- edge,
346
- originClientX: e.clientX,
347
- previewStart: el.start,
348
- previewDuration: el.duration,
349
- previewPlaybackStart: el.playbackStart,
350
- started: false,
351
- });
352
- }}
353
- onPointerDown={
354
- // fallow-ignore-next-line complexity
355
- (e) => {
356
- if (e.button !== 0) return;
357
- if (usePlayerStore.getState().activeTool === "razor") return;
358
- if (e.shiftKey) {
359
- shiftClickClipRef.current = {
360
- element: el,
361
- anchorX: e.clientX,
362
- anchorY: e.clientY,
363
- };
364
- return;
365
- }
366
- const target = e.currentTarget as HTMLElement;
367
- const rect = target.getBoundingClientRect();
368
- const blockedIntent = resolveBlockedTimelineEditIntent({
369
- width: rect.width,
370
- offsetX: e.clientX - rect.left,
371
- handleWidth: CLIP_HANDLE_W,
372
- capabilities,
373
- });
374
- if (
375
- blockedIntent &&
376
- ((blockedIntent === "move" && onMoveElement) ||
377
- (blockedIntent !== "move" && onResizeElement))
378
- ) {
379
- blockedClipRef.current = {
380
- element: el,
381
- intent: blockedIntent,
382
- originClientX: e.clientX,
383
- originClientY: e.clientY,
384
- started: false,
385
- };
386
- return;
387
- }
388
- if (!onMoveElement || !capabilities.canMove) return;
337
+ {/* Beat dots on the active track (the one holding the selection),
338
+ falling back to the music track when nothing is selected. */}
339
+ {beatStripOnTrack && (
340
+ <BeatStrip
341
+ beatTimes={beatAnalysis?.beatTimes}
342
+ beatStrengths={beatAnalysis?.beatStrengths}
343
+ pps={pps}
344
+ />
345
+ )}
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,
354
+ textTransform: "uppercase",
355
+ opacity: 0.5,
356
+ }}
357
+ >
358
+ New track
359
+ </div>
360
+ )}
361
+ {
362
+ // fallow-ignore-next-line complexity
363
+ els.map((el) => {
364
+ const clipStyle = getTrackStyle(el.tag);
365
+ const elementKey = el.key ?? el.id;
366
+ const capabilities = getTimelineEditCapabilities(el);
367
+ const isSelected = selectedElementIds.has(elementKey);
368
+ const isComposition = !!el.compositionSrc;
369
+ // elementKey (el.key ?? el.id) is already unique per clip; do NOT
370
+ // fold in the map index, or a splice/reorder remounts every clip
371
+ // at/after the change (DOM flash, drag interruption).
372
+ const clipKey = elementKey;
373
+ const isDraggingClip =
374
+ draggedClip?.started === true &&
375
+ (draggedElement?.key ?? draggedElement?.id) === elementKey;
376
+ if (isDraggingClip) return null;
377
+ const previewElement = getPreviewElement(el);
378
+ return (
379
+ <TimelineClip
380
+ key={clipKey}
381
+ onContextMenu={(e: React.MouseEvent) => {
382
+ e.preventDefault();
383
+ onContextMenuClip?.(e, el);
384
+ }}
385
+ el={previewElement}
386
+ pps={pps}
387
+ clipY={CLIP_Y}
388
+ isSelected={isSelected}
389
+ isHovered={hoveredClip === clipKey}
390
+ isDragging={false}
391
+ hasCustomContent={!!renderClipContent}
392
+ capabilities={capabilities}
393
+ theme={theme}
394
+ isComposition={isComposition}
395
+ onHoverStart={() => setHoveredClip(clipKey)}
396
+ onHoverEnd={() => setHoveredClip(null)}
397
+ onResizeStart={(edge, e) => {
398
+ if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
399
+ if (edge === "start" && !capabilities.canTrimStart) return;
400
+ if (edge === "end" && !capabilities.canTrimEnd) return;
401
+ e.stopPropagation();
389
402
  blockedClipRef.current = null;
390
403
  setShowPopover(false);
391
404
  setRangeSelection(null);
392
- setDraggedClip({
405
+ setResizingClip({
393
406
  element: el,
407
+ edge,
394
408
  originClientX: e.clientX,
395
- originClientY: e.clientY,
396
- originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
397
- originScrollTop: scrollRef.current?.scrollTop ?? 0,
398
- pointerClientX: e.clientX,
399
- pointerClientY: e.clientY,
400
- pointerOffsetX: e.clientX - rect.left,
401
- pointerOffsetY: e.clientY - rect.top,
402
409
  previewStart: el.start,
403
- previewTrack: el.track,
404
- snapBeatTime: null,
410
+ previewDuration: el.duration,
411
+ previewPlaybackStart: el.playbackStart,
412
+ snapGuideTime: null,
413
+ snapGuideKind: null,
405
414
  started: false,
406
415
  });
407
- syncClipDragAutoScroll(e.clientX, e.clientY);
408
- }
409
- }
410
- onClick={(e) => {
411
- e.stopPropagation();
412
- if (suppressClickRef.current) return;
413
- const { activeTool } = usePlayerStore.getState();
414
- if (activeTool === "razor" && onRazorSplit) {
415
- const clipRect = (
416
- e.currentTarget as HTMLElement
417
- ).getBoundingClientRect();
418
- const clickOffsetX = e.clientX - clipRect.left;
419
- const splitTime = previewElement.start + clickOffsetX / pps;
420
- const clampedTime = Math.max(
421
- previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
422
- Math.min(
423
- previewElement.start +
424
- previewElement.duration -
425
- SPLIT_BOUNDARY_EPSILON_S,
426
- splitTime,
427
- ),
428
- );
429
- if (e.shiftKey && onRazorSplitAll) {
430
- onRazorSplitAll(clampedTime);
431
- } else {
432
- onRazorSplit(el, clampedTime);
416
+ }}
417
+ onPointerDown={
418
+ // fallow-ignore-next-line complexity
419
+ (e) => {
420
+ if (e.button !== 0) return;
421
+ if (usePlayerStore.getState().activeTool === "razor") return;
422
+ if (e.shiftKey) {
423
+ shiftClickClipRef.current = {
424
+ element: el,
425
+ anchorX: e.clientX,
426
+ anchorY: e.clientY,
427
+ };
428
+ return;
429
+ }
430
+ const target = e.currentTarget as HTMLElement;
431
+ const rect = target.getBoundingClientRect();
432
+ const blockedIntent = resolveBlockedTimelineEditIntent({
433
+ width: rect.width,
434
+ offsetX: e.clientX - rect.left,
435
+ handleWidth: CLIP_HANDLE_W,
436
+ capabilities,
437
+ });
438
+ if (
439
+ blockedIntent &&
440
+ ((blockedIntent === "move" && onMoveElement) ||
441
+ (blockedIntent !== "move" && onResizeElement))
442
+ ) {
443
+ blockedClipRef.current = {
444
+ element: el,
445
+ intent: blockedIntent,
446
+ originClientX: e.clientX,
447
+ originClientY: e.clientY,
448
+ started: false,
449
+ };
450
+ return;
451
+ }
452
+ if (!onMoveElement || !capabilities.canMove) return;
453
+ blockedClipRef.current = null;
454
+ setShowPopover(false);
455
+ setRangeSelection(null);
456
+ setDraggedClip({
457
+ element: el,
458
+ originClientX: e.clientX,
459
+ originClientY: e.clientY,
460
+ originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
461
+ originScrollTop: scrollRef.current?.scrollTop ?? 0,
462
+ pointerClientX: e.clientX,
463
+ pointerClientY: e.clientY,
464
+ pointerOffsetX: e.clientX - rect.left,
465
+ pointerOffsetY: e.clientY - rect.top,
466
+ previewStart: el.start,
467
+ previewTrack: el.track,
468
+ previewLayerId: layerId,
469
+ previewLayerIndex: rowIndex,
470
+ previewStackingReorder: null,
471
+ snapBeatTime: null,
472
+ snapGuideTime: null,
473
+ snapGuideKind: null,
474
+ started: false,
475
+ });
476
+ syncClipDragAutoScroll(e.clientX, e.clientY);
433
477
  }
434
- return;
435
478
  }
436
- const nextElement = isSelected ? null : el;
437
- setSelectedElementId(nextElement ? elementKey : null);
438
- onSelectElement?.(nextElement);
439
- }}
440
- onDoubleClick={(e) => {
441
- e.stopPropagation();
442
- if (suppressClickRef.current) return;
443
- if (isComposition && onDrillDown) onDrillDown(el);
444
- }}
445
- >
446
- {renderClipChildren(previewElement, clipStyle)}
447
- {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
448
- <TimelineClipDiamonds
449
- keyframesData={keyframeCache.get(elementKey)!}
450
- clipWidthPx={Math.max(previewElement.duration * pps, 4)}
451
- clipHeightPx={TRACK_H - 2 * CLIP_Y}
452
- beatsActive={beatStripOnTrack}
453
- accentColor={clipStyle.accent}
454
- isSelected={isSelected}
455
- currentPercentage={
456
- previewElement.duration > 0
457
- ? ((currentTime - previewElement.start) / previewElement.duration) *
458
- 100
459
- : 0
479
+ onClick={(e) => {
480
+ e.stopPropagation();
481
+ if (suppressClickRef.current) return;
482
+ const { activeTool } = usePlayerStore.getState();
483
+ if (activeTool === "razor" && onRazorSplit) {
484
+ const clipRect = (
485
+ e.currentTarget as HTMLElement
486
+ ).getBoundingClientRect();
487
+ const clickOffsetX = e.clientX - clipRect.left;
488
+ const splitTime = previewElement.start + clickOffsetX / pps;
489
+ const clampedTime = Math.max(
490
+ previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
491
+ Math.min(
492
+ previewElement.start +
493
+ previewElement.duration -
494
+ SPLIT_BOUNDARY_EPSILON_S,
495
+ splitTime,
496
+ ),
497
+ );
498
+ if (e.shiftKey && onRazorSplitAll) {
499
+ onRazorSplitAll(clampedTime);
500
+ } else {
501
+ onRazorSplit(el, clampedTime);
502
+ }
503
+ return;
460
504
  }
461
- elementId={elementKey}
462
- selectedKeyframes={selectedKeyframes}
463
- onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
464
- onShiftClickKeyframe={onShiftClickKeyframe}
465
- onContextMenuKeyframe={onContextMenuKeyframe}
466
- onMoveKeyframe={onMoveKeyframe}
467
- suppressClickRef={suppressClickRef}
468
- />
469
- )}
470
- </TimelineClip>
471
- );
472
- })
473
- }
505
+ const nextElement = isSelected ? null : el;
506
+ setSelectedElementId(nextElement ? elementKey : null);
507
+ onSelectElement?.(nextElement);
508
+ }}
509
+ onDoubleClick={(e) => {
510
+ e.stopPropagation();
511
+ if (suppressClickRef.current) return;
512
+ if (isComposition && onDrillDown) onDrillDown(el);
513
+ }}
514
+ >
515
+ {renderClipChildren(previewElement, clipStyle)}
516
+ {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
517
+ <TimelineClipDiamonds
518
+ keyframesData={keyframeCache.get(elementKey)!}
519
+ clipWidthPx={Math.max(previewElement.duration * pps, 4)}
520
+ clipHeightPx={TRACK_H - 2 * CLIP_Y}
521
+ beatsActive={beatStripOnTrack}
522
+ beatTimes={beatAnalysis?.beatTimes}
523
+ clipStart={previewElement.start}
524
+ clipDurationSeconds={previewElement.duration}
525
+ pixelsPerSecond={pps}
526
+ accentColor={clipStyle.accent}
527
+ isSelected={isSelected}
528
+ currentPercentage={
529
+ previewElement.duration > 0
530
+ ? ((currentTime - previewElement.start) /
531
+ previewElement.duration) *
532
+ 100
533
+ : 0
534
+ }
535
+ elementId={elementKey}
536
+ selectedKeyframes={selectedKeyframes}
537
+ onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
538
+ onShiftClickKeyframe={onShiftClickKeyframe}
539
+ onContextMenuKeyframe={onContextMenuKeyframe}
540
+ onMoveKeyframe={onMoveKeyframe}
541
+ suppressClickRef={suppressClickRef}
542
+ />
543
+ )}
544
+ </TimelineClip>
545
+ );
546
+ })
547
+ }
548
+ </div>
474
549
  </div>
475
- </div>
550
+ </Fragment>
476
551
  );
477
552
  })
478
553
  }
479
554
 
480
- {/* Drag ghost */}
481
555
  {activeDraggedElement && activeDraggedPosition && (
482
- <div
483
- className="absolute pointer-events-none"
484
- style={{
485
- top: activeDraggedPosition.top,
486
- left: activeDraggedPosition.left,
487
- width: Math.max(activeDraggedElement.duration * pps, 4),
488
- height: TRACK_H - CLIP_Y * 2,
489
- zIndex: 40,
490
- }}
556
+ <TimelineDragGhost
557
+ element={activeDraggedElement}
558
+ position={activeDraggedPosition}
559
+ pps={pps}
560
+ selectedElementId={selectedElementId}
561
+ hasCustomContent={!!renderClipContent}
562
+ theme={theme}
491
563
  >
492
- <TimelineClip
493
- el={{ ...activeDraggedElement, start: 0 }}
494
- pps={pps}
495
- clipY={0}
496
- isSelected={selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)}
497
- isHovered={false}
498
- isDragging={true}
499
- hasCustomContent={!!renderClipContent}
500
- capabilities={getTimelineEditCapabilities(activeDraggedElement)}
501
- theme={theme}
502
- isComposition={!!activeDraggedElement.compositionSrc}
503
- onHoverStart={() => {}}
504
- onHoverEnd={() => {}}
505
- onResizeStart={() => {}}
506
- onClick={() => {}}
507
- onDoubleClick={() => {}}
508
- >
509
- {renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))}
510
- </TimelineClip>
511
- </div>
564
+ {renderClipChildren(activeDraggedElement, getTrackStyle(activeDraggedElement.tag))}
565
+ </TimelineDragGhost>
512
566
  )}
513
567
 
514
- {/* Range highlight */}
515
- {rangeSelection && (
516
- <div
517
- className="absolute pointer-events-none"
518
- style={{
519
- left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
520
- width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
521
- top: RULER_H,
522
- bottom: 0,
523
- backgroundColor: "rgba(59, 130, 246, 0.12)",
524
- borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
525
- borderRight: "1px solid rgba(59, 130, 246, 0.4)",
526
- zIndex: 50,
527
- }}
528
- />
529
- )}
568
+ <TimelineSelectionOverlays
569
+ rangeSelection={rangeSelection}
570
+ marqueeRect={marqueeRect}
571
+ pps={pps}
572
+ accentColor={getTrackStyle("").accent}
573
+ />
530
574
 
531
575
  {/* Playhead — hidden while dragging a beat so its guideline doesn't
532
576
  track the scrub and clutter the beat being moved. */}