diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index af06107..b073786 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -65,14 +65,14 @@ export default function Item({ onPointerDownCapture={() => onSelect?.()} className="group" > -
+
{ event.stopPropagation(); onSelect?.(); diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index 803269b..d89cc0e 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -83,7 +83,7 @@ width: 4px; pointer-events: none; z-index: 2; - opacity: 0; + opacity: 0.45; transition: opacity 0.2s, width 0.2s; } diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 635e1c3..c1d1500 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -597,12 +597,20 @@ export default function TimelineEditor({ setRange(createInitialRange(totalMs)); }, [totalMs]); + // Normalize regions only when timeline bounds change (not on every region edit). + // Using refs to read current regions avoids a dependency-loop that re-fires + // this effect on every drag/resize and races with dnd-timeline's internal state. + const zoomRegionsRef = useRef(zoomRegions); + const trimRegionsRef = useRef(trimRegions); + zoomRegionsRef.current = zoomRegions; + trimRegionsRef.current = trimRegions; + useEffect(() => { if (totalMs === 0 || safeMinDurationMs <= 0) { return; } - zoomRegions.forEach((region) => { + zoomRegionsRef.current.forEach((region) => { const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); const minEnd = clampedStart + safeMinDurationMs; const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); @@ -614,7 +622,7 @@ export default function TimelineEditor({ } }); - trimRegions.forEach((region) => { + trimRegionsRef.current.forEach((region) => { const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); const minEnd = clampedStart + safeMinDurationMs; const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); @@ -625,7 +633,8 @@ export default function TimelineEditor({ onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); } }); - }, [zoomRegions, trimRegions, annotationRegions, totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]); + // Only re-run when the timeline scale changes, not on every region edit + }, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]); const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => { // Determine which row the item belongs to @@ -641,12 +650,8 @@ export default function TimelineEditor({ const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => { return regions.some((region) => { if (region.id === excludeId) return false; - const gapBefore = newSpan.start - region.endMs; - const gapAfter = region.startMs - newSpan.end; - // Snap if gap is 2ms or less - if (gapBefore > 0 && gapBefore <= 2) return true; - if (gapAfter > 0 && gapAfter <= 2) return true; - return !(newSpan.end <= region.startMs || newSpan.start >= region.endMs); + // True overlap: regions actually intersect (not just adjacent) + return newSpan.end > region.startMs && newSpan.start < region.endMs; }); }; @@ -661,12 +666,19 @@ export default function TimelineEditor({ return false; }, [zoomRegions, trimRegions, annotationRegions]); + // At least 5% of the timeline or 1000ms, whichever is larger, so the region + // is always wide enough to grab and resize comfortably. + const defaultRegionDurationMs = useMemo( + () => Math.max(1000, Math.round(totalMs * 0.05)), + [totalMs], + ); + const handleAddZoom = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0) { return; } - const defaultDuration = Math.min(1000, totalMs); + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); if (defaultDuration <= 0) { return; } @@ -687,16 +699,16 @@ export default function TimelineEditor({ return; } - const actualDuration = Math.min(1000, gapToNext); + const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); onZoomAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded]); + }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]); const handleAddTrim = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) { return; } - const defaultDuration = Math.min(1000, totalMs); + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); if (defaultDuration <= 0) { return; } @@ -717,16 +729,16 @@ export default function TimelineEditor({ return; } - const actualDuration = Math.min(1000, gapToNext); + const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); onTrimAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded]); + }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]); const handleAddAnnotation = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) { return; } - const defaultDuration = Math.min(1000, totalMs); + const defaultDuration = Math.min(defaultRegionDurationMs, totalMs); if (defaultDuration <= 0) { return; } @@ -736,7 +748,7 @@ export default function TimelineEditor({ const endPos = Math.min(startPos + defaultDuration, totalMs); onAnnotationAdded({ start: startPos, end: endPos }); - }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded]); + }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -850,6 +862,13 @@ export default function TimelineEditor({ return [...zooms, ...trims, ...annotations]; }, [zoomRegions, trimRegions, annotationRegions]); + // Flat list of all non-annotation region spans for neighbour-clamping during drag/resize + const allRegionSpans = useMemo(() => { + const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); + const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs })); + return [...zooms, ...trims]; + }, [zoomRegions, trimRegions]); + const handleItemSpanChange = useCallback((id: string, span: Span) => { // Check if it's a zoom or trim item if (zoomRegions.some(r => r.id === id)) { @@ -961,6 +980,7 @@ export default function TimelineEditor({ minVisibleRangeMs={timelineScale.minVisibleRangeMs} gridSizeMs={timelineScale.gridMs} onItemSpanChange={handleItemSpanChange} + allRegionSpans={allRegionSpans} > void; + allRegionSpans?: { id: string; start: number; end: number }[]; } export default function TimelineWrapper({ @@ -25,6 +26,7 @@ export default function TimelineWrapper({ minVisibleRangeMs, gridSizeMs: _gridSizeMs, onItemSpanChange, + allRegionSpans = [], }: TimelineWrapperProps) { const totalMs = Math.max(0, Math.round(videoDuration * 1000)); @@ -84,25 +86,63 @@ export default function TimelineWrapper({ [minVisibleRangeMs, totalMs], ); + // When a span overlaps neighbours, clamp it to the nearest boundary + const clampToNeighbours = useCallback( + (span: Span, activeItemId: string): Span => { + const siblings = allRegionSpans.filter((r) => r.id !== activeItemId); + let { start, end } = span; + + for (const r of siblings) { + // Span's right edge crossed into a region to the right + if (end > r.start && start < r.start) { + end = r.start; + } + // Span's left edge crossed into a region to the left + if (start < r.end && end > r.end) { + start = r.end; + } + } + + // Ensure minimum duration after clamping + const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs); + if (end - start < minDur) { + // Try extending in the direction that has room + if (end + minDur - (end - start) <= totalMs) { + end = start + minDur; + } else { + start = end - minDur; + } + } + + return { start: Math.max(0, start), end: Math.min(end, totalMs || end) }; + }, + [allRegionSpans, minItemDurationMs, totalMs], + ); + const onResizeEnd = useCallback( (event: ResizeEndEvent) => { const updatedSpan = event.active.data.current.getSpanFromResizeEvent?.(event); if (!updatedSpan) return; - + const activeItemId = event.active.id as string; - const clampedSpan = clampSpanToBounds(updatedSpan); + let clampedSpan = clampSpanToBounds(updatedSpan); if (clampedSpan.end - clampedSpan.start < Math.min(minItemDurationMs, totalMs || minItemDurationMs)) { return; } - + + // Clamp to neighbour boundaries instead of rejecting if (hasOverlap(clampedSpan, activeItemId)) { - return; + clampedSpan = clampToNeighbours(clampedSpan, activeItemId); + // If still overlapping after clamping, fall back to original position + if (hasOverlap(clampedSpan, activeItemId)) { + return; + } } onItemSpanChange(activeItemId, clampedSpan); }, - [clampSpanToBounds, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs] + [clampSpanToBounds, clampToNeighbours, hasOverlap, minItemDurationMs, onItemSpanChange, totalMs] ); const onDragEnd = useCallback( @@ -110,17 +150,21 @@ export default function TimelineWrapper({ const activeRowId = event.over?.id as string; const updatedSpan = event.active.data.current.getSpanFromDragEvent?.(event); if (!updatedSpan || !activeRowId) return; - + const activeItemId = event.active.id as string; - const clampedSpan = clampSpanToBounds(updatedSpan); - + let clampedSpan = clampSpanToBounds(updatedSpan); + + // Clamp to neighbour boundaries instead of rejecting if (hasOverlap(clampedSpan, activeItemId)) { - return; + clampedSpan = clampToNeighbours(clampedSpan, activeItemId); + if (hasOverlap(clampedSpan, activeItemId)) { + return; + } } onItemSpanChange(activeItemId, clampedSpan); }, - [clampSpanToBounds, hasOverlap, onItemSpanChange] + [clampSpanToBounds, clampToNeighbours, hasOverlap, onItemSpanChange] ); const handleRangeChange = useCallback(