mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 07:45:34 +00:00
Support drag-and-drop placement across audio rows
This commit is contained in:
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user