@hyperframes/studio 0.7.38 → 0.7.40

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 (102) hide show
  1. package/dist/assets/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
  2. package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
  3. package/dist/assets/index-BpM6cnfP.css +1 -0
  4. package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
  5. package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/chunk-BA66NM4L.js.map +1 -0
  7. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +47 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +4432 -3045
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +9 -7
  14. package/src/components/StudioPreviewArea.tsx +14 -0
  15. package/src/components/StudioRightPanel.tsx +9 -0
  16. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  17. package/src/components/editor/DomEditOverlay.tsx +140 -151
  18. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  19. package/src/components/editor/PropertyPanel.test.ts +73 -0
  20. package/src/components/editor/PropertyPanel.tsx +42 -37
  21. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  22. package/src/components/editor/SnapToolbar.tsx +20 -1
  23. package/src/components/editor/clipPathHelpers.ts +113 -0
  24. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  25. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  27. package/src/components/editor/domEditOverlayShape.ts +39 -0
  28. package/src/components/editor/domEditing.test.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +8 -1
  30. package/src/components/editor/domEditingTypes.ts +3 -0
  31. package/src/components/editor/manualEditsDom.ts +1 -18
  32. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  33. package/src/components/editor/marqueeCommit.ts +3 -2
  34. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  35. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  36. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  37. package/src/components/editor/propertyPanelHelpers.ts +21 -34
  38. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  39. package/src/components/editor/propertyPanelTypes.ts +3 -0
  40. package/src/components/editor/snapTargetCollection.ts +2 -3
  41. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  43. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  44. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  45. package/src/components/sidebar/AssetsTab.tsx +113 -15
  46. package/src/components/sidebar/BlocksTab.tsx +2 -1
  47. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  48. package/src/contexts/TimelineEditContext.tsx +1 -0
  49. package/src/hooks/domSelectionTestHarness.ts +1 -0
  50. package/src/hooks/gsapDragCommit.test.ts +56 -0
  51. package/src/hooks/gsapDragCommit.ts +41 -109
  52. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  53. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  54. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  55. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  56. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  58. package/src/hooks/timelineEditingHelpers.ts +17 -5
  59. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  60. package/src/hooks/timelineTrackVisibility.ts +371 -0
  61. package/src/hooks/useCropMode.ts +91 -0
  62. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  63. package/src/hooks/useRenderClipContent.test.ts +68 -2
  64. package/src/hooks/useRenderClipContent.ts +5 -6
  65. package/src/hooks/useTimelineEditing.ts +35 -30
  66. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  67. package/src/icons/SystemIcons.tsx +12 -4
  68. package/src/player/components/AudioWaveform.tsx +11 -8
  69. package/src/player/components/CompositionThumbnail.tsx +14 -11
  70. package/src/player/components/PlayheadIndicator.tsx +21 -11
  71. package/src/player/components/ShortcutsPanel.tsx +9 -0
  72. package/src/player/components/Timeline.test.ts +105 -5
  73. package/src/player/components/Timeline.tsx +37 -47
  74. package/src/player/components/TimelineCanvas.tsx +275 -256
  75. package/src/player/components/TimelineClip.test.tsx +105 -0
  76. package/src/player/components/TimelineClip.tsx +44 -57
  77. package/src/player/components/TimelineRuler.tsx +17 -23
  78. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  79. package/src/player/components/TimelineTypes.ts +18 -0
  80. package/src/player/components/VideoThumbnail.tsx +16 -13
  81. package/src/player/components/timelineCallbacks.ts +1 -0
  82. package/src/player/components/timelineIcons.tsx +2 -44
  83. package/src/player/components/timelineLayout.ts +18 -22
  84. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  85. package/src/player/components/timelineTheme.test.ts +21 -4
  86. package/src/player/components/timelineTheme.ts +15 -15
  87. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  88. package/src/player/components/useTimelineActiveClips.ts +125 -0
  89. package/src/player/components/useTimelinePlayhead.ts +6 -0
  90. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  91. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  92. package/src/player/lib/time.test.ts +1 -0
  93. package/src/player/lib/timelineDOM.test.ts +48 -1
  94. package/src/player/lib/timelineDOM.ts +7 -0
  95. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  96. package/src/player/store/playerStore.ts +19 -1
  97. package/src/styles/studio.css +108 -0
  98. package/src/utils/sourcePatcher.test.ts +11 -0
  99. package/src/utils/sourcePatcher.ts +6 -4
  100. package/dist/assets/index-DJaiR8T2.css +0 -1
  101. package/dist/chunk-JND3XUJL.js.map +0 -1
  102. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
@@ -1,4 +1,5 @@
1
1
  import { memo, type ReactNode } from "react";
2
+ import { Eye, EyeSlash } from "@phosphor-icons/react";
2
3
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
3
4
  import { TimelineClip } from "./TimelineClip";
4
5
  import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
@@ -24,21 +25,15 @@ import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
24
25
  import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
25
26
  import { isMusicTrack } from "../../utils/timelineInspector";
26
27
 
27
- function ClipLabel({ element, color }: { element: TimelineElement; color: string }) {
28
+ function ClipLintDot({ element }: { element: TimelineElement }) {
28
29
  const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
30
+ if (!lint || lint.count === 0) return null;
29
31
  return (
30
32
  <span
31
- className="flex items-center gap-1 truncate text-[10px] font-medium leading-none"
32
- style={{ color }}
33
- >
34
- {element.label || element.id || element.tag}
35
- {lint && lint.count > 0 && (
36
- <span
37
- className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-amber-400"
38
- title={lint.messages.join("\n")}
39
- />
40
- )}
41
- </span>
33
+ className="absolute w-1.5 h-1.5 rounded-full bg-amber-400"
34
+ style={{ top: 7, right: 7 }}
35
+ title={lint.messages.join("\n")}
36
+ />
42
37
  );
43
38
  }
44
39
 
@@ -50,7 +45,6 @@ interface TimelineCanvasProps {
50
45
  totalH: number;
51
46
  effectiveDuration: number;
52
47
  majorTickInterval: number;
53
- shiftHeld: boolean;
54
48
  rangeSelection: TimelineRangeSelection | null;
55
49
  theme: TimelineTheme;
56
50
  displayTrackOrder: number[];
@@ -109,7 +103,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
109
103
  totalH,
110
104
  effectiveDuration,
111
105
  majorTickInterval,
112
- shiftHeld,
113
106
  rangeSelection,
114
107
  theme,
115
108
  displayTrackOrder,
@@ -148,7 +141,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
148
141
  onContextMenuClip,
149
142
  beatAnalysis,
150
143
  }: TimelineCanvasProps) {
151
- const { onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll } =
144
+ const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
152
145
  useTimelineEditContextOptional();
153
146
  const beatDragging = usePlayerStore((s) => s.beatDragging);
154
147
  const draggedElement = draggedClip?.element ?? null;
@@ -180,17 +173,12 @@ export const TimelineCanvas = memo(function TimelineCanvas({
180
173
  const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => (
181
174
  <>
182
175
  {renderClipOverlay?.(element)}
183
- <div
184
- className={
185
- renderClipContent
186
- ? "absolute inset-0 overflow-hidden"
187
- : "flex items-center overflow-hidden flex-1 min-w-0 px-3 gap-2"
188
- }
189
- >
190
- {renderClipContent?.(element, clipStyle) ?? (
191
- <ClipLabel element={element} color={clipStyle.label} />
192
- )}
193
- </div>
176
+ {!renderClipContent && <ClipLintDot element={element} />}
177
+ {renderClipContent && (
178
+ <div className="absolute inset-0 overflow-hidden">
179
+ {renderClipContent(element, clipStyle)}
180
+ </div>
181
+ )}
194
182
  </>
195
183
  );
196
184
 
@@ -204,258 +192,290 @@ export const TimelineCanvas = memo(function TimelineCanvas({
204
192
  totalH={totalH}
205
193
  effectiveDuration={effectiveDuration}
206
194
  majorTickInterval={majorTickInterval}
207
- shiftHeld={shiftHeld}
208
- rangeSelection={rangeSelection}
209
195
  theme={theme}
210
196
  beatAnalysis={beatAnalysis}
211
197
  />
212
198
 
213
- {displayTrackOrder.map((trackNum) => {
214
- const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
215
- const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
216
- const isPendingTrack =
217
- draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
218
- // The beat-dot strip occupies the top of this track's lane (active track,
219
- // or the music track when nothing is selected). When shown, keyframe
220
- // diamonds shrink + drop to the bottom half so they don't collide with it.
221
- const beatStripOnTrack =
222
- (beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
223
- (selectedElementId
224
- ? els.some((e) => (e.key ?? e.id) === selectedElementId)
225
- : els.some(isMusicTrack));
226
- return (
227
- <div
228
- key={trackNum}
229
- className="relative flex"
230
- style={{
231
- height: TRACK_H,
232
- background: theme.rowBackground,
233
- borderBottom: `1px solid ${theme.rowBorder}`,
234
- }}
235
- >
199
+ {
200
+ // 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("");
204
+ const isPendingTrack =
205
+ draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
206
+ const rowBackground =
207
+ displayTrackOrder.indexOf(trackNum) % 2 === 0 ? theme.rowBackground : "#0D0E12";
208
+ // The beat-dot strip occupies the top of this track's lane (active track,
209
+ // or the music track when nothing is selected). When shown, keyframe
210
+ // diamonds shrink + drop to the bottom half so they don't collide with it.
211
+ const beatStripOnTrack =
212
+ (beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
213
+ (selectedElementId
214
+ ? els.some((e) => (e.key ?? e.id) === selectedElementId)
215
+ : els.some(isMusicTrack));
216
+ const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
217
+ return (
236
218
  <div
237
- className="flex-shrink-0 flex items-center justify-center"
219
+ key={trackNum}
220
+ className="relative flex"
238
221
  style={{
239
- width: GUTTER,
240
- background: theme.gutterBackground,
241
- borderRight: `1px solid ${theme.gutterBorder}`,
222
+ height: TRACK_H,
223
+ background: rowBackground,
224
+ borderBottom: `1px solid ${theme.rowBorder}`,
242
225
  }}
243
226
  >
244
227
  <div
245
- className="flex items-center justify-center"
228
+ className="sticky left-0 z-[12] flex-shrink-0 flex items-center justify-center"
246
229
  style={{
247
- width: 18,
248
- height: 18,
249
- borderRadius: 6,
250
- backgroundColor: ts.iconBackground,
251
- border: `1px solid ${theme.gutterBorder}`,
252
- color: "#fff",
230
+ width: GUTTER,
231
+ background: theme.gutterBackground,
232
+ borderRight: `1px solid ${theme.gutterBorder}`,
253
233
  }}
254
234
  >
255
- {ts.icon}
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();
246
+ }}
247
+ onClick={(e) => {
248
+ e.stopPropagation();
249
+ void onToggleTrackHidden?.(trackNum, !isTrackHidden);
250
+ }}
251
+ >
252
+ {isTrackHidden ? (
253
+ <EyeSlash size={14} weight="bold" aria-hidden="true" />
254
+ ) : (
255
+ <Eye size={14} weight="bold" aria-hidden="true" />
256
+ )}
257
+ </button>
256
258
  </div>
257
- </div>
258
- <div style={{ width: trackContentWidth }} className="relative">
259
- {/* Faint beat lines in every track's background (behind the clips);
260
- the active move-snap target is highlighted. */}
261
- <BeatBackgroundLines
262
- beatTimes={beatAnalysis?.beatTimes}
263
- beatStrengths={beatAnalysis?.beatStrengths}
264
- pps={pps}
265
- highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
266
- />
267
- {/* Beat dots on the active track (the one holding the selection),
268
- falling back to the music track when nothing is selected. */}
269
- {beatStripOnTrack && (
270
- <BeatStrip
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
271
270
  beatTimes={beatAnalysis?.beatTimes}
272
271
  beatStrengths={beatAnalysis?.beatStrengths}
273
272
  pps={pps}
273
+ highlightTime={draggedClip?.started ? draggedClip.snapBeatTime : null}
274
274
  />
275
- )}
276
- {isPendingTrack && (
277
- <div
278
- className="absolute inset-0 flex items-center"
279
- style={{
280
- paddingLeft: 16,
281
- color: ts.label,
282
- fontSize: 11,
283
- letterSpacing: "0.06em",
284
- textTransform: "uppercase",
285
- opacity: 0.5,
286
- }}
287
- >
288
- New track
289
- </div>
290
- )}
291
- {els.map((el, i) => {
292
- const clipStyle = getTrackStyle(el.tag);
293
- const elementKey = el.key ?? el.id;
294
- const capabilities = getTimelineEditCapabilities(el);
295
- const isSelected = selectedElementId === elementKey;
296
- const isComposition = !!el.compositionSrc;
297
- const clipKey = `${elementKey}-${i}`;
298
- const isDraggingClip =
299
- draggedClip?.started === true &&
300
- (draggedElement?.key ?? draggedElement?.id) === elementKey;
301
- if (isDraggingClip) return null;
302
- const previewElement = getPreviewElement(el);
303
- return (
304
- <TimelineClip
305
- key={clipKey}
306
- onContextMenu={(e: React.MouseEvent) => {
307
- e.preventDefault();
308
- onContextMenuClip?.(e, el);
309
- }}
310
- el={previewElement}
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
279
+ beatTimes={beatAnalysis?.beatTimes}
280
+ beatStrengths={beatAnalysis?.beatStrengths}
311
281
  pps={pps}
312
- clipY={CLIP_Y}
313
- isSelected={isSelected}
314
- isHovered={hoveredClip === clipKey}
315
- isDragging={false}
316
- hasCustomContent={!!renderClipContent}
317
- capabilities={capabilities}
318
- theme={theme}
319
- trackStyle={clipStyle}
320
- isComposition={isComposition}
321
- onHoverStart={() => setHoveredClip(clipKey)}
322
- onHoverEnd={() => setHoveredClip(null)}
323
- onResizeStart={(edge, e) => {
324
- if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
325
- if (edge === "start" && !capabilities.canTrimStart) return;
326
- if (edge === "end" && !capabilities.canTrimEnd) return;
327
- e.stopPropagation();
328
- blockedClipRef.current = null;
329
- setShowPopover(false);
330
- setRangeSelection(null);
331
- setResizingClip({
332
- element: el,
333
- edge,
334
- originClientX: e.clientX,
335
- previewStart: el.start,
336
- previewDuration: el.duration,
337
- previewPlaybackStart: el.playbackStart,
338
- started: false,
339
- });
340
- }}
341
- onPointerDown={(e) => {
342
- if (e.button !== 0) return;
343
- if (usePlayerStore.getState().activeTool === "razor") return;
344
- if (e.shiftKey) {
345
- shiftClickClipRef.current = {
346
- element: el,
347
- anchorX: e.clientX,
348
- anchorY: e.clientY,
349
- };
350
- return;
351
- }
352
- const target = e.currentTarget as HTMLElement;
353
- const rect = target.getBoundingClientRect();
354
- const blockedIntent = resolveBlockedTimelineEditIntent({
355
- width: rect.width,
356
- offsetX: e.clientX - rect.left,
357
- handleWidth: CLIP_HANDLE_W,
358
- capabilities,
359
- });
360
- if (
361
- blockedIntent &&
362
- ((blockedIntent === "move" && onMoveElement) ||
363
- (blockedIntent !== "move" && onResizeElement))
364
- ) {
365
- blockedClipRef.current = {
366
- element: el,
367
- intent: blockedIntent,
368
- originClientX: e.clientX,
369
- originClientY: e.clientY,
370
- started: false,
371
- };
372
- return;
373
- }
374
- if (!onMoveElement || !capabilities.canMove) return;
375
- blockedClipRef.current = null;
376
- setShowPopover(false);
377
- setRangeSelection(null);
378
- setDraggedClip({
379
- element: el,
380
- originClientX: e.clientX,
381
- originClientY: e.clientY,
382
- originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
383
- originScrollTop: scrollRef.current?.scrollTop ?? 0,
384
- pointerClientX: e.clientX,
385
- pointerClientY: e.clientY,
386
- pointerOffsetX: e.clientX - rect.left,
387
- pointerOffsetY: e.clientY - rect.top,
388
- previewStart: el.start,
389
- previewTrack: el.track,
390
- snapBeatTime: null,
391
- started: false,
392
- });
393
- syncClipDragAutoScroll(e.clientX, e.clientY);
394
- }}
395
- onClick={(e) => {
396
- e.stopPropagation();
397
- if (suppressClickRef.current) return;
398
- const { activeTool } = usePlayerStore.getState();
399
- if (activeTool === "razor" && onRazorSplit) {
400
- const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
401
- const clickOffsetX = e.clientX - clipRect.left;
402
- const splitTime = previewElement.start + clickOffsetX / pps;
403
- const clampedTime = Math.max(
404
- previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
405
- Math.min(
406
- previewElement.start +
407
- previewElement.duration -
408
- SPLIT_BOUNDARY_EPSILON_S,
409
- splitTime,
410
- ),
411
- );
412
- if (e.shiftKey && onRazorSplitAll) {
413
- onRazorSplitAll(clampedTime);
414
- } else {
415
- onRazorSplit(el, clampedTime);
416
- }
417
- return;
418
- }
419
- const nextElement = isSelected ? null : el;
420
- setSelectedElementId(nextElement ? elementKey : null);
421
- onSelectElement?.(nextElement);
422
- }}
423
- onDoubleClick={(e) => {
424
- e.stopPropagation();
425
- if (suppressClickRef.current) return;
426
- if (isComposition && onDrillDown) onDrillDown(el);
282
+ />
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,
427
294
  }}
428
295
  >
429
- {renderClipChildren(previewElement, clipStyle)}
430
- {STUDIO_KEYFRAMES_ENABLED && keyframeCache?.get(elementKey) && (
431
- <TimelineClipDiamonds
432
- keyframesData={keyframeCache.get(elementKey)!}
433
- clipWidthPx={Math.max(previewElement.duration * pps, 4)}
434
- clipHeightPx={TRACK_H - 2 * CLIP_Y}
435
- beatsActive={beatStripOnTrack}
436
- accentColor={clipStyle.accent}
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}
437
326
  isSelected={isSelected}
438
- currentPercentage={
439
- previewElement.duration > 0
440
- ? ((currentTime - previewElement.start) / previewElement.duration) * 100
441
- : 0
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;
389
+ blockedClipRef.current = null;
390
+ setShowPopover(false);
391
+ setRangeSelection(null);
392
+ setDraggedClip({
393
+ element: el,
394
+ 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
+ previewStart: el.start,
403
+ previewTrack: el.track,
404
+ snapBeatTime: null,
405
+ started: false,
406
+ });
407
+ syncClipDragAutoScroll(e.clientX, e.clientY);
408
+ }
442
409
  }
443
- elementId={elementKey}
444
- selectedKeyframes={selectedKeyframes}
445
- onClickKeyframe={(pct) => onClickKeyframe?.(previewElement, pct)}
446
- onShiftClickKeyframe={onShiftClickKeyframe}
447
- onContextMenuKeyframe={onContextMenuKeyframe}
448
- onMoveKeyframe={onMoveKeyframe}
449
- suppressClickRef={suppressClickRef}
450
- />
451
- )}
452
- </TimelineClip>
453
- );
454
- })}
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);
433
+ }
434
+ return;
435
+ }
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
460
+ }
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
+ }
474
+ </div>
455
475
  </div>
456
- </div>
457
- );
458
- })}
476
+ );
477
+ })
478
+ }
459
479
 
460
480
  {/* Drag ghost */}
461
481
  {activeDraggedElement && activeDraggedPosition && (
@@ -479,7 +499,6 @@ export const TimelineCanvas = memo(function TimelineCanvas({
479
499
  hasCustomContent={!!renderClipContent}
480
500
  capabilities={getTimelineEditCapabilities(activeDraggedElement)}
481
501
  theme={theme}
482
- trackStyle={getTrackStyle(activeDraggedElement.tag)}
483
502
  isComposition={!!activeDraggedElement.compositionSrc}
484
503
  onHoverStart={() => {}}
485
504
  onHoverEnd={() => {}}