From b63aba99b0d8ea98f9ad3a7c5123ff75df85d786 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:06:40 +1000 Subject: [PATCH] Support drag-and-drop placement across audio rows --- src/components/video-editor/VideoEditor.tsx | 47 ++++++++++++----- .../video-editor/timeline/TimelineEditor.tsx | 51 ++++++++++++++++--- .../video-editor/timeline/TimelineWrapper.tsx | 36 ++++++++----- 3 files changed, 100 insertions(+), 34 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index d90e4cb6..a436aa25 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -3142,7 +3142,12 @@ export default function VideoEditor() { setSelectedSpeedId(null); }, []); - const handleAudioSpanChange = useCallback((id: string, span: Span) => { + const handleAudioSpanChange = useCallback((id: string, span: Span, trackIndex?: number) => { + const normalizedTrackIndex = + typeof trackIndex === "number" && Number.isFinite(trackIndex) + ? Math.max(0, Math.floor(trackIndex)) + : undefined; + setAudioRegions((prev) => prev.map((region) => region.id === id @@ -3150,6 +3155,9 @@ export default function VideoEditor() { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end), + ...(normalizedTrackIndex === undefined + ? {} + : { trackIndex: normalizedTrackIndex }), } : region, ), @@ -3199,19 +3207,30 @@ export default function VideoEditor() { setSelectedTrimId(null); }, []); - const handleAnnotationSpanChange = useCallback((id: string, span: Span) => { - setAnnotationRegions((prev) => - prev.map((region) => - region.id === id - ? { - ...region, - startMs: Math.round(span.start), - endMs: Math.round(span.end), - } - : region, - ), - ); - }, []); + const handleAnnotationSpanChange = useCallback( + (id: string, span: Span, trackIndex?: number) => { + const normalizedTrackIndex = + typeof trackIndex === "number" && Number.isFinite(trackIndex) + ? Math.max(0, Math.floor(trackIndex)) + : undefined; + + setAnnotationRegions((prev) => + prev.map((region) => + region.id === id + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + ...(normalizedTrackIndex === undefined + ? {} + : { trackIndex: normalizedTrackIndex }), + } + : region, + ), + ); + }, + [], + ); const handleAnnotationDelete = useCallback( (id: string) => { diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 720a6586..159e98fe 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -141,7 +141,7 @@ interface TimelineEditorProps { onSelectClip?: (id: string | null) => void; annotationRegions?: AnnotationRegion[]; onAnnotationAdded?: (span: Span, trackIndex?: number) => void; - onAnnotationSpanChange?: (id: string, span: Span) => void; + onAnnotationSpanChange?: (id: string, span: Span, trackIndex?: number) => void; onAnnotationDelete?: (id: string) => void; selectedAnnotationId?: string | null; onSelectAnnotation?: (id: string | null) => void; @@ -153,7 +153,7 @@ interface TimelineEditorProps { onSelectSpeed?: (id: string | null) => void; audioRegions?: AudioRegion[]; onAudioAdded?: (span: Span, audioPath: string, trackIndex?: number) => void; - onAudioSpanChange?: (id: string, span: Span) => void; + onAudioSpanChange?: (id: string, span: Span, trackIndex?: number) => void; onAudioDelete?: (id: string) => void; selectedAudioId?: string | null; onSelectAudio?: (id: string | null) => void; @@ -1236,7 +1236,7 @@ const TimelineEditor = forwardRef( ]); const hasOverlap = useCallback( - (newSpan: Span, excludeId?: string): boolean => { + (newSpan: Span, excludeId?: string, rowId?: string): boolean => { // Determine which row the item belongs to const isZoomItem = zoomRegions.some((r) => r.id === excludeId); const isTrimItem = trimRegions.some((r) => r.id === excludeId); @@ -1283,7 +1283,10 @@ const TimelineEditor = forwardRef( const activeAudioRegion = audioRegions.find( (region) => region.id === excludeId, ); - const activeTrackIndex = activeAudioRegion?.trackIndex ?? 0; + const activeTrackIndex = + rowId && isAudioTrackRowId(rowId) + ? getAudioTrackIndex(rowId) + : (activeAudioRegion?.trackIndex ?? 0); return checkOverlap( audioRegions.filter( (region) => (region.trackIndex ?? 0) === activeTrackIndex, @@ -1904,8 +1907,32 @@ const TimelineEditor = forwardRef( return [...zooms, ...clips, ...audios]; }, [zoomRegions, clipRegions, audioRegions]); + const getResolvedDropRowId = useCallback( + (id: string, proposedRowId: string) => { + const currentRowId = timelineItems.find((item) => item.id === id)?.rowId; + if (!currentRowId) { + return proposedRowId; + } + + if (isAnnotationTrackRowId(currentRowId)) { + return isAnnotationTrackRowId(proposedRowId) + ? getAnnotationTrackRowId(getAnnotationTrackIndex(proposedRowId)) + : currentRowId; + } + + if (isAudioTrackRowId(currentRowId)) { + return isAudioTrackRowId(proposedRowId) + ? getAudioTrackRowId(getAudioTrackIndex(proposedRowId)) + : currentRowId; + } + + return currentRowId; + }, + [timelineItems], + ); + const handleItemSpanChange = useCallback( - (id: string, span: Span) => { + (id: string, span: Span, rowId?: string) => { // Check if it's a zoom, trim, clip, speed, or annotation item if (zoomRegions.some((r) => r.id === id)) { onZoomSpanChange(id, span); @@ -1914,11 +1941,20 @@ const TimelineEditor = forwardRef( } else if (clipRegions.some((r) => r.id === id)) { onClipSpanChange?.(id, span); } else if (annotationRegions.some((r) => r.id === id)) { - onAnnotationSpanChange?.(id, span); + const nextTrackIndex = + rowId && isAnnotationTrackRowId(rowId) + ? getAnnotationTrackIndex(rowId) + : (annotationRegions.find((region) => region.id === id)?.trackIndex ?? + 0); + onAnnotationSpanChange?.(id, span, nextTrackIndex); } else if (speedRegions.some((r) => r.id === id)) { onSpeedSpanChange?.(id, span); } else if (audioRegions.some((r) => r.id === id)) { - onAudioSpanChange?.(id, span); + const nextTrackIndex = + rowId && isAudioTrackRowId(rowId) + ? getAudioTrackIndex(rowId) + : (audioRegions.find((region) => region.id === id)?.trackIndex ?? 0); + onAudioSpanChange?.(id, span, nextTrackIndex); } }, [ @@ -2202,6 +2238,7 @@ const TimelineEditor = forwardRef( minItemDurationMs={timelineScale.minItemDurationMs} minVisibleRangeMs={timelineScale.minVisibleRangeMs} onItemSpanChange={handleItemSpanChange} + resolveTargetRowId={getResolvedDropRowId} allRegionSpans={allRegionSpans} > boolean; + hasOverlap: (newSpan: Span, excludeId?: string, rowId?: string) => boolean; onRangeChange: Dispatch>; minItemDurationMs: number; minVisibleRangeMs: number; gridSizeMs?: number; - onItemSpanChange: (id: string, span: Span) => void; + onItemSpanChange: (id: string, span: Span, rowId?: string) => void; + resolveTargetRowId?: (id: string, proposedRowId: string) => string; allRegionSpans?: { id: string; start: number; end: number; rowId: string }[]; } @@ -34,6 +35,7 @@ export default function TimelineWrapper({ minVisibleRangeMs, gridSizeMs: _gridSizeMs, onItemSpanChange, + resolveTargetRowId, allRegionSpans = [], }: TimelineWrapperProps) { const totalMs = Math.max(0, Math.round(videoDuration * 1000)); @@ -95,14 +97,15 @@ export default function TimelineWrapper({ ); const getSiblingSpans = useCallback( - (activeItemId: string) => { + (activeItemId: string, rowId?: string) => { const activeItem = allRegionSpans.find((region) => region.id === activeItemId); - if (!activeItem) { + const resolvedRowId = rowId ?? activeItem?.rowId; + if (!resolvedRowId) { return []; } return allRegionSpans - .filter((region) => region.id !== activeItemId && region.rowId === activeItem.rowId) + .filter((region) => region.id !== activeItemId && region.rowId === resolvedRowId) .sort((left, right) => left.start - right.start); }, [allRegionSpans], @@ -146,13 +149,13 @@ export default function TimelineWrapper({ // When a drag overlaps neighbours, keep duration fixed and stop at the nearest gap boundary. const clampDraggedSpanToNeighbours = useCallback( - (span: Span, activeItemId: string): Span => { + (span: Span, activeItemId: string, rowId?: string): Span => { const activeItem = allRegionSpans.find((region) => region.id === activeItemId); if (!activeItem) { return clampSpanToBounds(span); } - const siblings = getSiblingSpans(activeItemId); + const siblings = getSiblingSpans(activeItemId, rowId); const duration = Math.max( activeItem.end - activeItem.start, Math.min(minItemDurationMs, totalMs || minItemDurationMs), @@ -216,11 +219,13 @@ export default function TimelineWrapper({ const onDragEnd = useCallback( (event: DragEndEvent) => { - const activeRowId = event.over?.id as string; + const proposedRowId = event.over?.id as string; const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event); - if (!updatedSpan || !activeRowId) return; + if (!updatedSpan || !proposedRowId) return; const activeItemId = event.active.id as string; + const resolvedRowId = + resolveTargetRowId?.(activeItemId, proposedRowId) ?? proposedRowId; // Drags are pure translations — always preserve the original duration. // The span from getSpanFromDragEvent can drift due to pixel-to-ms @@ -237,14 +242,18 @@ export default function TimelineWrapper({ let clampedSpan = clampSpanToBounds(dragSpan); // Clamp to neighbour boundaries instead of rejecting - if (hasOverlap(clampedSpan, activeItemId)) { - clampedSpan = clampDraggedSpanToNeighbours(clampedSpan, activeItemId); - if (hasOverlap(clampedSpan, activeItemId)) { + if (hasOverlap(clampedSpan, activeItemId, resolvedRowId)) { + clampedSpan = clampDraggedSpanToNeighbours( + clampedSpan, + activeItemId, + resolvedRowId, + ); + if (hasOverlap(clampedSpan, activeItemId, resolvedRowId)) { return; } } - onItemSpanChange(activeItemId, clampedSpan); + onItemSpanChange(activeItemId, clampedSpan, resolvedRowId); }, [ allRegionSpans, @@ -252,6 +261,7 @@ export default function TimelineWrapper({ clampSpanToBounds, hasOverlap, onItemSpanChange, + resolveTargetRowId, ], );