@hyperframes/studio 0.7.39 → 0.7.41
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.
- package/dist/assets/{index-CQHiecE7.js → index-BCG-TnJO.js} +1 -1
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-B9YvRJz1.js → index-DRrNf6k_.js} +195 -195
- package/dist/assets/{index-312a3Ceu.js → index-rnqIbAs_.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js → chunk-BA66NM4L.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js.map → chunk-BA66NM4L.js.map} +1 -1
- package/dist/{domEditingLayers-S6YOLVRG.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +34 -29
- package/dist/index.html +2 -2
- package/dist/index.js +2749 -2075
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -1
- package/src/components/StudioPreviewArea.tsx +6 -0
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/editor/DomEditOverlay.tsx +33 -47
- package/src/components/editor/PropertyPanel.test.ts +25 -0
- package/src/components/editor/PropertyPanel.tsx +33 -7
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +11 -0
- package/src/components/editor/propertyPanelTypes.ts +1 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-0P10SwC_.css +0 -1
- /package/dist/{domEditingLayers-S6YOLVRG.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
|
|
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="
|
|
32
|
-
style={{
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
{
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
219
|
+
key={trackNum}
|
|
220
|
+
className="relative flex"
|
|
238
221
|
style={{
|
|
239
|
-
|
|
240
|
-
background:
|
|
241
|
-
|
|
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:
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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={() => {}}
|