@hyperframes/studio 0.4.16 → 0.4.18
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-D0VntLIQ.js +115 -0
- package/dist/assets/index-kT65pCwW.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +387 -67
- package/src/components/nle/NLELayout.tsx +19 -0
- package/src/components/nle/TimelineEditorNotice.tsx +156 -0
- package/src/components/sidebar/AssetsTab.tsx +7 -0
- package/src/components/sidebar/CompositionsTab.test.ts +37 -0
- package/src/components/sidebar/CompositionsTab.tsx +45 -2
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +288 -29
- package/src/player/components/TimelineClip.tsx +1 -1
- package/src/player/components/timelineEditing.test.ts +149 -0
- package/src/player/components/timelineEditing.ts +45 -6
- package/src/player/hooks/useTimelinePlayer.ts +5 -1
- package/src/utils/timelineAssetDrop.test.ts +80 -0
- package/src/utils/timelineAssetDrop.ts +87 -0
- package/dist/assets/index-CVm-zeM9.css +0 -1
- package/dist/assets/index-RzXlAX2g.js +0 -93
|
@@ -10,10 +10,14 @@ import { formatTime } from "../lib/time";
|
|
|
10
10
|
import { TimelineClip } from "./TimelineClip";
|
|
11
11
|
import { EditPopover } from "./EditModal";
|
|
12
12
|
import {
|
|
13
|
+
buildClipRangeSelection,
|
|
13
14
|
getTimelineEditCapabilities,
|
|
15
|
+
resolveBlockedTimelineEditIntent,
|
|
14
16
|
resolveTimelineAutoScroll,
|
|
15
17
|
resolveTimelineMove,
|
|
16
18
|
resolveTimelineResize,
|
|
19
|
+
type BlockedTimelineEditIntent,
|
|
20
|
+
type TimelineRangeSelection,
|
|
17
21
|
} from "./timelineEditing";
|
|
18
22
|
import {
|
|
19
23
|
defaultTimelineTheme,
|
|
@@ -23,12 +27,15 @@ import {
|
|
|
23
27
|
type TimelineTheme,
|
|
24
28
|
} from "./timelineTheme";
|
|
25
29
|
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
30
|
+
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
26
31
|
|
|
27
32
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
|
28
33
|
const GUTTER = 32;
|
|
29
34
|
const TRACK_H = 72;
|
|
30
35
|
const RULER_H = 24;
|
|
31
36
|
const CLIP_Y = 3; // vertical inset inside track
|
|
37
|
+
const CLIP_HANDLE_W = 18;
|
|
38
|
+
const TIMELINE_SCROLL_BUFFER = 24;
|
|
32
39
|
|
|
33
40
|
interface TrackVisualStyle extends TimelineTrackStyle {
|
|
34
41
|
icon: ReactNode;
|
|
@@ -130,6 +137,74 @@ export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number):
|
|
|
130
137
|
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
|
|
131
138
|
}
|
|
132
139
|
|
|
140
|
+
export function getTimelineCanvasHeight(trackCount: number): number {
|
|
141
|
+
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function shouldHandleTimelineDeleteKey(input: {
|
|
145
|
+
key: string;
|
|
146
|
+
metaKey?: boolean;
|
|
147
|
+
ctrlKey?: boolean;
|
|
148
|
+
altKey?: boolean;
|
|
149
|
+
target?: EventTarget | null;
|
|
150
|
+
}): boolean {
|
|
151
|
+
if (input.key !== "Delete" && input.key !== "Backspace") return false;
|
|
152
|
+
if (input.metaKey || input.ctrlKey || input.altKey) return false;
|
|
153
|
+
const target =
|
|
154
|
+
input.target && typeof input.target === "object"
|
|
155
|
+
? (input.target as {
|
|
156
|
+
tagName?: string;
|
|
157
|
+
isContentEditable?: boolean;
|
|
158
|
+
closest?: (selector: string) => Element | null;
|
|
159
|
+
})
|
|
160
|
+
: null;
|
|
161
|
+
if (target) {
|
|
162
|
+
const tag = target.tagName?.toLowerCase() ?? "";
|
|
163
|
+
if (target.isContentEditable) return false;
|
|
164
|
+
if (["input", "textarea", "select"].includes(tag)) return false;
|
|
165
|
+
if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
|
|
173
|
+
if (trackOrder.length === 0) return 0;
|
|
174
|
+
if (rowIndex == null || rowIndex < 0) return trackOrder[0];
|
|
175
|
+
if (rowIndex >= trackOrder.length) {
|
|
176
|
+
return Math.max(...trackOrder) + 1;
|
|
177
|
+
}
|
|
178
|
+
return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function resolveTimelineAssetDrop(
|
|
182
|
+
input: {
|
|
183
|
+
rectLeft: number;
|
|
184
|
+
rectTop: number;
|
|
185
|
+
scrollLeft: number;
|
|
186
|
+
scrollTop: number;
|
|
187
|
+
pixelsPerSecond: number;
|
|
188
|
+
duration: number;
|
|
189
|
+
trackHeight: number;
|
|
190
|
+
trackOrder: number[];
|
|
191
|
+
},
|
|
192
|
+
clientX: number,
|
|
193
|
+
clientY: number,
|
|
194
|
+
): { start: number; track: number } {
|
|
195
|
+
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
|
|
196
|
+
const y = clientY - input.rectTop + input.scrollTop - RULER_H;
|
|
197
|
+
const start = Math.max(
|
|
198
|
+
0,
|
|
199
|
+
Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
|
|
200
|
+
);
|
|
201
|
+
const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1));
|
|
202
|
+
return {
|
|
203
|
+
start,
|
|
204
|
+
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
133
208
|
/* ── Component ──────────────────────────────────────────────────── */
|
|
134
209
|
interface TimelineProps {
|
|
135
210
|
/** Called when user seeks via ruler/track click or playhead drag */
|
|
@@ -144,8 +219,19 @@ interface TimelineProps {
|
|
|
144
219
|
/** Optional overlay renderer for clips (e.g. badges, cursors) */
|
|
145
220
|
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
|
|
146
221
|
/** Called when files are dropped onto the empty timeline */
|
|
147
|
-
onFileDrop?: (
|
|
222
|
+
onFileDrop?: (
|
|
223
|
+
files: File[],
|
|
224
|
+
placement?: { start: number; track: number },
|
|
225
|
+
) => Promise<void> | void;
|
|
226
|
+
/** Called when an existing asset is dropped from the Assets tab */
|
|
227
|
+
onAssetDrop?: (
|
|
228
|
+
assetPath: string,
|
|
229
|
+
placement: { start: number; track: number },
|
|
230
|
+
) => Promise<void> | void;
|
|
148
231
|
/** Persist a clip move back into source HTML */
|
|
232
|
+
onDeleteElement?: (
|
|
233
|
+
element: import("../store/playerStore").TimelineElement,
|
|
234
|
+
) => Promise<void> | void;
|
|
149
235
|
onMoveElement?: (
|
|
150
236
|
element: import("../store/playerStore").TimelineElement,
|
|
151
237
|
updates: Pick<import("../store/playerStore").TimelineElement, "start" | "track">,
|
|
@@ -157,6 +243,10 @@ interface TimelineProps {
|
|
|
157
243
|
"start" | "duration" | "playbackStart"
|
|
158
244
|
>,
|
|
159
245
|
) => Promise<void> | void;
|
|
246
|
+
onBlockedEditAttempt?: (
|
|
247
|
+
element: import("../store/playerStore").TimelineElement,
|
|
248
|
+
intent: BlockedTimelineEditIntent,
|
|
249
|
+
) => void;
|
|
160
250
|
theme?: Partial<TimelineTheme>;
|
|
161
251
|
}
|
|
162
252
|
|
|
@@ -185,14 +275,25 @@ interface ResizingClipState {
|
|
|
185
275
|
started: boolean;
|
|
186
276
|
}
|
|
187
277
|
|
|
278
|
+
interface BlockedClipState {
|
|
279
|
+
element: TimelineElement;
|
|
280
|
+
intent: BlockedTimelineEditIntent;
|
|
281
|
+
originClientX: number;
|
|
282
|
+
originClientY: number;
|
|
283
|
+
started: boolean;
|
|
284
|
+
}
|
|
285
|
+
|
|
188
286
|
export const Timeline = memo(function Timeline({
|
|
189
287
|
onSeek,
|
|
190
288
|
onDrillDown,
|
|
191
289
|
renderClipContent,
|
|
192
290
|
renderClipOverlay,
|
|
193
291
|
onFileDrop,
|
|
292
|
+
onAssetDrop,
|
|
293
|
+
onDeleteElement,
|
|
194
294
|
onMoveElement,
|
|
195
295
|
onResizeElement,
|
|
296
|
+
onBlockedEditAttempt,
|
|
196
297
|
theme: themeOverrides,
|
|
197
298
|
}: TimelineProps = {}) {
|
|
198
299
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
@@ -210,6 +311,11 @@ export const Timeline = memo(function Timeline({
|
|
|
210
311
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
211
312
|
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
|
212
313
|
const isDragging = useRef(false);
|
|
314
|
+
const shiftClickClipRef = useRef<{
|
|
315
|
+
element: TimelineElement;
|
|
316
|
+
anchorX: number;
|
|
317
|
+
anchorY: number;
|
|
318
|
+
} | null>(null);
|
|
213
319
|
// Range selection (Shift+drag)
|
|
214
320
|
const [shiftHeld, setShiftHeld] = useState(false);
|
|
215
321
|
useMountEffect(() => {
|
|
@@ -227,22 +333,21 @@ export const Timeline = memo(function Timeline({
|
|
|
227
333
|
});
|
|
228
334
|
const isRangeSelecting = useRef(false);
|
|
229
335
|
const rangeAnchorTime = useRef(0);
|
|
230
|
-
const [rangeSelection, setRangeSelection] = useState<
|
|
231
|
-
start: number;
|
|
232
|
-
end: number;
|
|
233
|
-
anchorX: number;
|
|
234
|
-
anchorY: number;
|
|
235
|
-
} | null>(null);
|
|
336
|
+
const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | null>(null);
|
|
236
337
|
const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
|
|
237
338
|
const draggedClipRef = useRef<DraggedClipState | null>(null);
|
|
238
339
|
draggedClipRef.current = draggedClip;
|
|
239
340
|
const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
|
|
240
341
|
const resizingClipRef = useRef<ResizingClipState | null>(null);
|
|
241
342
|
resizingClipRef.current = resizingClip;
|
|
343
|
+
const blockedClipRef = useRef<BlockedClipState | null>(null);
|
|
344
|
+
const deleteInFlightRef = useRef(false);
|
|
242
345
|
const onMoveElementRef = useRef(onMoveElement);
|
|
243
346
|
onMoveElementRef.current = onMoveElement;
|
|
244
347
|
const onResizeElementRef = useRef(onResizeElement);
|
|
245
348
|
onResizeElementRef.current = onResizeElement;
|
|
349
|
+
const onDeleteElementRef = useRef(onDeleteElement);
|
|
350
|
+
onDeleteElementRef.current = onDeleteElement;
|
|
246
351
|
const suppressClickRef = useRef(false);
|
|
247
352
|
const [showPopover, setShowPopover] = useState(false);
|
|
248
353
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
@@ -313,6 +418,12 @@ export const Timeline = memo(function Timeline({
|
|
|
313
418
|
}
|
|
314
419
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
315
420
|
}, [draggedClip, trackOrder]);
|
|
421
|
+
const selectedElement = useMemo(
|
|
422
|
+
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
423
|
+
[elements, selectedElementId],
|
|
424
|
+
);
|
|
425
|
+
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
|
|
426
|
+
selectedElementRef.current = selectedElement;
|
|
316
427
|
|
|
317
428
|
// Calculate effective pixels per second
|
|
318
429
|
// In fit mode, use clientWidth (excludes scrollbar) with a small padding
|
|
@@ -546,6 +657,7 @@ export const Timeline = memo(function Timeline({
|
|
|
546
657
|
const handleWindowPointerMove = (e: PointerEvent) => {
|
|
547
658
|
const drag = draggedClipRef.current;
|
|
548
659
|
const resize = resizingClipRef.current;
|
|
660
|
+
const blocked = blockedClipRef.current;
|
|
549
661
|
if (resize) {
|
|
550
662
|
const distance = Math.abs(e.clientX - resize.originClientX);
|
|
551
663
|
if (!resize.started && distance < 2) return;
|
|
@@ -561,6 +673,8 @@ export const Timeline = memo(function Timeline({
|
|
|
561
673
|
Math.max(resize.element.playbackRate ?? 1, 0.1),
|
|
562
674
|
)
|
|
563
675
|
: Number.POSITIVE_INFINITY;
|
|
676
|
+
const normalizedTag = resize.element.tag.toLowerCase();
|
|
677
|
+
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
|
|
564
678
|
const nextResize = resolveTimelineResize(
|
|
565
679
|
{
|
|
566
680
|
start: resize.element.start,
|
|
@@ -569,7 +683,10 @@ export const Timeline = memo(function Timeline({
|
|
|
569
683
|
pixelsPerSecond: ppsRef.current,
|
|
570
684
|
minStart: 0,
|
|
571
685
|
maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
|
|
572
|
-
playbackStart:
|
|
686
|
+
playbackStart:
|
|
687
|
+
resize.edge === "start" && canSeedPlaybackStart
|
|
688
|
+
? (resize.element.playbackStart ?? 0)
|
|
689
|
+
: resize.element.playbackStart,
|
|
573
690
|
playbackRate: resize.element.playbackRate,
|
|
574
691
|
},
|
|
575
692
|
resize.edge,
|
|
@@ -589,6 +706,23 @@ export const Timeline = memo(function Timeline({
|
|
|
589
706
|
);
|
|
590
707
|
return;
|
|
591
708
|
}
|
|
709
|
+
if (blocked) {
|
|
710
|
+
const distance = Math.hypot(
|
|
711
|
+
e.clientX - blocked.originClientX,
|
|
712
|
+
e.clientY - blocked.originClientY,
|
|
713
|
+
);
|
|
714
|
+
const threshold = blocked.intent === "move" ? 4 : 2;
|
|
715
|
+
if (!blocked.started && distance < threshold) return;
|
|
716
|
+
if (!blocked.started) {
|
|
717
|
+
blocked.started = true;
|
|
718
|
+
blockedClipRef.current = blocked;
|
|
719
|
+
suppressClickRef.current = true;
|
|
720
|
+
setShowPopover(false);
|
|
721
|
+
setRangeSelection(null);
|
|
722
|
+
onBlockedEditAttempt?.(blocked.element, blocked.intent);
|
|
723
|
+
}
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
592
726
|
if (!drag) return;
|
|
593
727
|
|
|
594
728
|
const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
|
|
@@ -644,6 +778,14 @@ export const Timeline = memo(function Timeline({
|
|
|
644
778
|
return;
|
|
645
779
|
}
|
|
646
780
|
|
|
781
|
+
const blocked = blockedClipRef.current;
|
|
782
|
+
if (blocked) {
|
|
783
|
+
blockedClipRef.current = null;
|
|
784
|
+
if (!blocked.started) return;
|
|
785
|
+
clearSuppressedClick();
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
647
789
|
const drag = draggedClipRef.current;
|
|
648
790
|
if (!drag) return;
|
|
649
791
|
draggedClipRef.current = null;
|
|
@@ -688,6 +830,28 @@ export const Timeline = memo(function Timeline({
|
|
|
688
830
|
};
|
|
689
831
|
});
|
|
690
832
|
|
|
833
|
+
useMountEffect(() => {
|
|
834
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
835
|
+
if (!shouldHandleTimelineDeleteKey(event)) return;
|
|
836
|
+
const selected = selectedElementRef.current;
|
|
837
|
+
const onDelete = onDeleteElementRef.current;
|
|
838
|
+
if (!selected || !onDelete || deleteInFlightRef.current) return;
|
|
839
|
+
event.preventDefault();
|
|
840
|
+
deleteInFlightRef.current = true;
|
|
841
|
+
suppressClickRef.current = true;
|
|
842
|
+
setShowPopover(false);
|
|
843
|
+
setRangeSelection(null);
|
|
844
|
+
Promise.resolve(onDelete(selected)).finally(() => {
|
|
845
|
+
deleteInFlightRef.current = false;
|
|
846
|
+
requestAnimationFrame(() => {
|
|
847
|
+
suppressClickRef.current = false;
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
};
|
|
851
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
852
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
853
|
+
});
|
|
854
|
+
|
|
691
855
|
const handlePointerDown = useCallback(
|
|
692
856
|
(e: React.PointerEvent) => {
|
|
693
857
|
if (e.button !== 0) return;
|
|
@@ -707,6 +871,7 @@ export const Timeline = memo(function Timeline({
|
|
|
707
871
|
return;
|
|
708
872
|
}
|
|
709
873
|
|
|
874
|
+
shiftClickClipRef.current = null;
|
|
710
875
|
// Normal click on a clip — let the clip handle it
|
|
711
876
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
712
877
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
@@ -740,8 +905,14 @@ export const Timeline = memo(function Timeline({
|
|
|
740
905
|
const handlePointerUp = useCallback(() => {
|
|
741
906
|
if (isRangeSelecting.current) {
|
|
742
907
|
isRangeSelecting.current = false;
|
|
743
|
-
|
|
908
|
+
const pendingShiftClick = shiftClickClipRef.current;
|
|
909
|
+
shiftClickClipRef.current = null;
|
|
744
910
|
setRangeSelection((prev) => {
|
|
911
|
+
if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
|
|
912
|
+
setShowPopover(true);
|
|
913
|
+
return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
|
|
914
|
+
}
|
|
915
|
+
// Show popover if range is meaningful (> 0.2s)
|
|
745
916
|
if (prev && Math.abs(prev.end - prev.start) > 0.2) {
|
|
746
917
|
setShowPopover(true);
|
|
747
918
|
return prev;
|
|
@@ -771,6 +942,74 @@ export const Timeline = memo(function Timeline({
|
|
|
771
942
|
);
|
|
772
943
|
|
|
773
944
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
945
|
+
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
|
|
946
|
+
const hasFiles = e.dataTransfer.files.length > 0;
|
|
947
|
+
const hasAsset = Array.from(e.dataTransfer.types).includes(TIMELINE_ASSET_MIME);
|
|
948
|
+
if (!hasFiles && !hasAsset) return;
|
|
949
|
+
e.preventDefault();
|
|
950
|
+
if (hasAsset) {
|
|
951
|
+
e.dataTransfer.dropEffect = "copy";
|
|
952
|
+
}
|
|
953
|
+
setIsDragOver(true);
|
|
954
|
+
}, []);
|
|
955
|
+
|
|
956
|
+
const handleAssetDrop = useCallback(
|
|
957
|
+
(e: React.DragEvent) => {
|
|
958
|
+
e.preventDefault();
|
|
959
|
+
setIsDragOver(false);
|
|
960
|
+
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
961
|
+
const scroll = scrollRef.current;
|
|
962
|
+
const rect = scroll?.getBoundingClientRect();
|
|
963
|
+
const placement =
|
|
964
|
+
scroll && rect
|
|
965
|
+
? resolveTimelineAssetDrop(
|
|
966
|
+
{
|
|
967
|
+
rectLeft: rect.left,
|
|
968
|
+
rectTop: rect.top,
|
|
969
|
+
scrollLeft: scroll.scrollLeft,
|
|
970
|
+
scrollTop: scroll.scrollTop,
|
|
971
|
+
pixelsPerSecond: ppsRef.current,
|
|
972
|
+
duration: durationRef.current,
|
|
973
|
+
trackHeight: TRACK_H,
|
|
974
|
+
trackOrder: trackOrderRef.current,
|
|
975
|
+
},
|
|
976
|
+
e.clientX,
|
|
977
|
+
e.clientY,
|
|
978
|
+
)
|
|
979
|
+
: undefined;
|
|
980
|
+
void onFileDrop(Array.from(e.dataTransfer.files), placement);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
|
985
|
+
if (!assetPayload || !onAssetDrop) return;
|
|
986
|
+
try {
|
|
987
|
+
const parsed = JSON.parse(assetPayload) as { path?: string };
|
|
988
|
+
if (!parsed.path) return;
|
|
989
|
+
const scroll = scrollRef.current;
|
|
990
|
+
const rect = scroll?.getBoundingClientRect();
|
|
991
|
+
if (!scroll || !rect) return;
|
|
992
|
+
const placement = resolveTimelineAssetDrop(
|
|
993
|
+
{
|
|
994
|
+
rectLeft: rect.left,
|
|
995
|
+
rectTop: rect.top,
|
|
996
|
+
scrollLeft: scroll.scrollLeft,
|
|
997
|
+
scrollTop: scroll.scrollTop,
|
|
998
|
+
pixelsPerSecond: ppsRef.current,
|
|
999
|
+
duration: durationRef.current,
|
|
1000
|
+
trackHeight: TRACK_H,
|
|
1001
|
+
trackOrder: trackOrderRef.current,
|
|
1002
|
+
},
|
|
1003
|
+
e.clientX,
|
|
1004
|
+
e.clientY,
|
|
1005
|
+
);
|
|
1006
|
+
void onAssetDrop(parsed.path, placement);
|
|
1007
|
+
} catch {
|
|
1008
|
+
// ignore malformed drag payloads
|
|
1009
|
+
}
|
|
1010
|
+
},
|
|
1011
|
+
[onAssetDrop, onFileDrop],
|
|
1012
|
+
);
|
|
774
1013
|
|
|
775
1014
|
if (!timelineReady || elements.length === 0) {
|
|
776
1015
|
return (
|
|
@@ -778,18 +1017,9 @@ export const Timeline = memo(function Timeline({
|
|
|
778
1017
|
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
|
|
779
1018
|
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
|
|
780
1019
|
}`}
|
|
781
|
-
onDragOver={
|
|
782
|
-
e.preventDefault();
|
|
783
|
-
setIsDragOver(true);
|
|
784
|
-
}}
|
|
1020
|
+
onDragOver={handleAssetDragOver}
|
|
785
1021
|
onDragLeave={() => setIsDragOver(false)}
|
|
786
|
-
onDrop={
|
|
787
|
-
e.preventDefault();
|
|
788
|
-
setIsDragOver(false);
|
|
789
|
-
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
790
|
-
onFileDrop(Array.from(e.dataTransfer.files));
|
|
791
|
-
}
|
|
792
|
-
}}
|
|
1022
|
+
onDrop={handleAssetDrop}
|
|
793
1023
|
>
|
|
794
1024
|
{/* Ruler */}
|
|
795
1025
|
<div
|
|
@@ -869,7 +1099,7 @@ export const Timeline = memo(function Timeline({
|
|
|
869
1099
|
);
|
|
870
1100
|
}
|
|
871
1101
|
|
|
872
|
-
const totalH =
|
|
1102
|
+
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
873
1103
|
const draggedElement = draggedClip?.element ?? null;
|
|
874
1104
|
const activeDraggedElement =
|
|
875
1105
|
draggedClip?.started === true && draggedElement
|
|
@@ -953,6 +1183,9 @@ export const Timeline = memo(function Timeline({
|
|
|
953
1183
|
<div
|
|
954
1184
|
ref={scrollRef}
|
|
955
1185
|
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full`}
|
|
1186
|
+
onDragOver={handleAssetDragOver}
|
|
1187
|
+
onDragLeave={() => setIsDragOver(false)}
|
|
1188
|
+
onDrop={handleAssetDrop}
|
|
956
1189
|
onPointerDown={handlePointerDown}
|
|
957
1190
|
onPointerMove={handlePointerMove}
|
|
958
1191
|
onPointerUp={handlePointerUp}
|
|
@@ -990,7 +1223,7 @@ export const Timeline = memo(function Timeline({
|
|
|
990
1223
|
{shiftHeld && !rangeSelection && (
|
|
991
1224
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
|
992
1225
|
<span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
|
|
993
|
-
Drag to
|
|
1226
|
+
Drag or click a clip to edit range
|
|
994
1227
|
</span>
|
|
995
1228
|
</div>
|
|
996
1229
|
)}
|
|
@@ -1108,6 +1341,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1108
1341
|
if (edge === "start" && !capabilities.canTrimStart) return;
|
|
1109
1342
|
if (edge === "end" && !capabilities.canTrimEnd) return;
|
|
1110
1343
|
e.stopPropagation();
|
|
1344
|
+
blockedClipRef.current = null;
|
|
1111
1345
|
setShowPopover(false);
|
|
1112
1346
|
setRangeSelection(null);
|
|
1113
1347
|
setResizingClip({
|
|
@@ -1121,16 +1355,41 @@ export const Timeline = memo(function Timeline({
|
|
|
1121
1355
|
});
|
|
1122
1356
|
}}
|
|
1123
1357
|
onPointerDown={(e) => {
|
|
1358
|
+
if (e.button !== 0) return;
|
|
1359
|
+
if (e.shiftKey) {
|
|
1360
|
+
shiftClickClipRef.current = {
|
|
1361
|
+
element: el,
|
|
1362
|
+
anchorX: e.clientX,
|
|
1363
|
+
anchorY: e.clientY,
|
|
1364
|
+
};
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const target = e.currentTarget as HTMLElement;
|
|
1368
|
+
const rect = target.getBoundingClientRect();
|
|
1369
|
+
const blockedIntent = resolveBlockedTimelineEditIntent({
|
|
1370
|
+
width: rect.width,
|
|
1371
|
+
offsetX: e.clientX - rect.left,
|
|
1372
|
+
handleWidth: CLIP_HANDLE_W,
|
|
1373
|
+
capabilities,
|
|
1374
|
+
});
|
|
1124
1375
|
if (
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1376
|
+
blockedIntent &&
|
|
1377
|
+
((blockedIntent === "move" && onMoveElement) ||
|
|
1378
|
+
(blockedIntent !== "move" && onResizeElement))
|
|
1379
|
+
) {
|
|
1380
|
+
blockedClipRef.current = {
|
|
1381
|
+
element: el,
|
|
1382
|
+
intent: blockedIntent,
|
|
1383
|
+
originClientX: e.clientX,
|
|
1384
|
+
originClientY: e.clientY,
|
|
1385
|
+
started: false,
|
|
1386
|
+
};
|
|
1130
1387
|
return;
|
|
1388
|
+
}
|
|
1389
|
+
if (!onMoveElement || !capabilities.canMove) return;
|
|
1390
|
+
blockedClipRef.current = null;
|
|
1131
1391
|
setShowPopover(false);
|
|
1132
1392
|
setRangeSelection(null);
|
|
1133
|
-
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
1134
1393
|
setDraggedClip({
|
|
1135
1394
|
element: el,
|
|
1136
1395
|
originClientX: e.clientX,
|
|
@@ -1270,7 +1529,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1270
1529
|
Shift
|
|
1271
1530
|
</kbd>
|
|
1272
1531
|
<span className="text-[9px]" style={{ color: theme.textSecondary }}>
|
|
1273
|
-
+ drag to edit range
|
|
1532
|
+
+ drag/click to edit range
|
|
1274
1533
|
</span>
|
|
1275
1534
|
</div>
|
|
1276
1535
|
</div>
|
|
@@ -147,7 +147,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
147
147
|
top: 0,
|
|
148
148
|
bottom: 0,
|
|
149
149
|
width: 18,
|
|
150
|
-
opacity: showHandles ? 1 : 0,
|
|
150
|
+
opacity: showHandles && capabilities.canTrimEnd ? 1 : 0,
|
|
151
151
|
pointerEvents: onResizeStart && capabilities.canTrimEnd ? "auto" : "none",
|
|
152
152
|
zIndex: 4,
|
|
153
153
|
transition: "opacity 120ms ease-out",
|