Support drag-and-drop placement across audio rows

This commit is contained in:
webadderall
2026-04-24 11:49:16 +10:00
parent c667f2efe0
commit b63aba99b0
3 changed files with 100 additions and 34 deletions
+33 -14
View File
@@ -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) => {
@@ -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<TimelineEditorHandle, TimelineEditorProps>(
]);
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<TimelineEditorHandle, TimelineEditorProps>(
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<TimelineEditorHandle, TimelineEditorProps>(
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<TimelineEditorHandle, TimelineEditorProps>(
} 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<TimelineEditorHandle, TimelineEditorProps>(
minItemDurationMs={timelineScale.minItemDurationMs}
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
onItemSpanChange={handleItemSpanChange}
resolveTargetRowId={getResolvedDropRowId}
allRegionSpans={allRegionSpans}
>
<KeyframeMarkers
@@ -15,12 +15,13 @@ interface TimelineWrapperProps {
children: ReactNode;
range: Range;
videoDuration: number;
hasOverlap: (newSpan: Span, excludeId?: string) => boolean;
hasOverlap: (newSpan: Span, excludeId?: string, rowId?: string) => boolean;
onRangeChange: Dispatch<SetStateAction<Range>>;
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,
],
);