From b7ccce954e82bb07891ebbb6d3bed557ea0a1a26 Mon Sep 17 00:00:00 2001 From: Hyteq Date: Mon, 23 Jun 2025 14:05:14 +0300 Subject: [PATCH] fix: clips duplicating when moving them around --- apps/web/src/components/editor/timeline.tsx | 455 +++++++++----------- 1 file changed, 201 insertions(+), 254 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 9b0def9..f6d9fdd 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -33,7 +33,7 @@ export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their clips. // You can drag media here to add it to your project. // Clips can be trimmed, deleted, and moved. - const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute } = + const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { currentTime, duration, seek } = usePlaybackStore(); @@ -42,11 +42,12 @@ export function Timeline() { const [zoomLevel, setZoomLevel] = useState(1); const dragCounterRef = useRef(0); const timelineRef = useRef(null); - // Track menu state - const [trackMenuOpen, setTrackMenuOpen] = useState(null); - // Context menu state for tracks + + // Unified context menu state const [contextMenu, setContextMenu] = useState<{ + type: 'track' | 'clip'; trackId: string; + clipId?: string; x: number; y: number; } | null>(null); @@ -318,19 +319,17 @@ export function Timeline() { return (
{(() => { const formatTime = (seconds: number) => { @@ -391,13 +390,12 @@ export function Timeline() { >
{track.name} @@ -453,6 +451,7 @@ export function Timeline() { onContextMenu={(e) => { e.preventDefault(); setContextMenu({ + type: 'track', trackId: track.id, x: e.clientX, y: e.clientY, @@ -462,64 +461,9 @@ export function Timeline() { - {/* Render context menu if open for this track */} - {contextMenu && contextMenu.trackId === track.id && ( - setContextMenu(null)} - onSplit={() => { - // Split all clips at playhead - track.clips.forEach((clip) => { - const splitTime = currentTime; - const effectiveStart = clip.startTime; - const effectiveEnd = - clip.startTime + - (clip.duration - - clip.trimStart - - clip.trimEnd); - if ( - splitTime > effectiveStart && - splitTime < effectiveEnd - ) { - // First part: adjust original clip - useTimelineStore - .getState() - .updateClipTrim( - track.id, - clip.id, - clip.trimStart, - clip.trimEnd + (effectiveEnd - splitTime) - ); - // Second part: add new clip after split - useTimelineStore - .getState() - .addClipToTrack(track.id, { - mediaId: clip.mediaId, - name: clip.name + " (cut)", - duration: clip.duration, - startTime: splitTime, - trimStart: - clip.trimStart + - (splitTime - effectiveStart), - trimEnd: clip.trimEnd, - }); - } - }); - setContextMenu(null); - }} - onMute={() => { - toggleTrackMute(track.id); - setContextMenu(null); - }} - onDelete={() => { - removeTrack(track.id); - setContextMenu(null); - }} - /> - )} +
))} @@ -538,6 +482,136 @@ export function Timeline() {
+ + {/* Clean Unified Context Menu */} + {contextMenu && ( +
e.preventDefault()} + > + {contextMenu.type === 'track' ? ( + // Track context menu + <> + +
+ + + ) : ( + // Clip context menu + <> + + +
+ + + )} +
+ )}
); } @@ -545,9 +619,11 @@ export function Timeline() { function TimelineTrackContent({ track, zoomLevel, + setContextMenu, }: { track: TimelineTrack; zoomLevel: number; + setContextMenu: (menu: { type: 'track' | 'clip'; trackId: string; clipId?: string; x: number; y: number; } | null) => void; }) { const { mediaItems } = useMediaStore(); const { @@ -573,89 +649,10 @@ function TimelineTrackContent({ } | null>(null); const dragCounterRef = useRef(0); const [clipMenuOpen, setClipMenuOpen] = useState(null); - // Track-level context menu state (for empty areas) - const [trackContextMenu, setTrackContextMenu] = useState<{ - x: number; - y: number; - } | null>(null); - - // Clip-level context menu state (for specific clips) - const [clipContextMenu, setClipContextMenu] = useState<{ - clipId: string; - x: number; - y: number; - } | null>(null); - - // Auto-dismiss timer ref - const dismissTimerRef = useRef(); - - // Handle right-click on empty track area - const handleTrackContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - // Only show track menu if we didn't click on a clip - if (!(e.target as HTMLElement).closest(".timeline-clip")) { - clearTimeout(dismissTimerRef.current); - setTrackContextMenu({ - x: e.clientX, - y: e.clientY, - }); - // Auto-dismiss after 3 seconds - dismissTimerRef.current = setTimeout(() => { - setTrackContextMenu(null); - }, 3000); - } - }; - - // Handle right-click on a clip - const handleClipContextMenu = (e: React.MouseEvent, clipId: string) => { - e.preventDefault(); - e.stopPropagation(); - clearTimeout(dismissTimerRef.current); - setClipContextMenu({ - clipId, - x: e.clientX, - y: e.clientY, - }); - // Auto-dismiss after 3 seconds - dismissTimerRef.current = setTimeout(() => { - setClipContextMenu(null); - }, 3000); - }; - - // Close context menus when clicking outside - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - // Check if click is outside both menus - const isOutsideTrackMenu = - trackContextMenu && - !(e.target as HTMLElement).closest(".track-context-menu"); - const isOutsideClipMenu = - clipContextMenu && - !(e.target as HTMLElement).closest(".clip-context-menu"); - - if (isOutsideTrackMenu) setTrackContextMenu(null); - if (isOutsideClipMenu) setClipContextMenu(null); - }; - - // Clean up any existing timer when component unmounts - const cleanup = () => clearTimeout(dismissTimerRef.current); - - window.addEventListener("click", handleClickOutside); - return () => { - window.removeEventListener("click", handleClickOutside); - cleanup(); - }; - }, [trackContextMenu, clipContextMenu]); // Handle clip deletion const handleDeleteClip = (clipId: string) => { - // Find the clip to be deleted - const clipToDelete = track.clips.find((c) => c.id === clipId); - if (!clipToDelete) return; - - // Remove only this specific clip from the track removeClipFromTrack(track.id, clipId); - setClipContextMenu(null); }; const handleResizeStart = ( @@ -768,7 +765,7 @@ function TimelineTrackContent({ return; } } - } catch (error) {} + } catch (error) { } } // Calculate drop position for overlap checking @@ -984,8 +981,11 @@ function TimelineTrackContent({ } if (fromTrackId === track.id) { + // Moving within same track updateClipStartTime(track.id, clipId, snappedTime); } else { + // Moving to different track + console.log('Moving clip from', fromTrackId, 'to', track.id); moveClipToTrack(fromTrackId, track.id, clipId); requestAnimationFrame(() => { updateClipStartTime(track.id, clipId, snappedTime); @@ -1143,14 +1143,24 @@ function TimelineTrackContent({ return (
{ + e.preventDefault(); + // Only show track menu if we didn't click on a clip + if (!(e.target as HTMLElement).closest(".timeline-clip")) { + setContextMenu({ + type: 'track', + trackId: track.id, + x: e.clientX, + y: e.clientY, + }); + } + }} onDragOver={handleTrackDragOver} onDragEnter={handleTrackDragEnter} onDragLeave={handleTrackDragLeave} @@ -1162,13 +1172,12 @@ function TimelineTrackContent({
{track.clips.length === 0 ? (
{isDropping ? wouldOverlap @@ -1192,7 +1201,17 @@ function TimelineTrackContent({ key={clip.id} className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg`} style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }} - onContextMenu={(e) => handleClipContextMenu(e, clip.id)} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ + type: 'clip', + trackId: track.id, + clipId: clip.id, + x: e.clientX, + y: e.clientY, + }); + }} > {/* Left trim handle */}
{wouldOverlap ? "⚠️" : ""} {dropPosition.toFixed(1)}s @@ -1281,75 +1296,7 @@ function TimelineTrackContent({ )} - {/* Track Context Menu (for empty areas) */} - {trackContextMenu && ( -
e.preventDefault()} - > - {/* Mute/Unmute option */} - -
- )} - {/* Clip Context Menu (for specific clips) */} - {clipContextMenu && - track.clips.some((c) => c.id === clipContextMenu.clipId) && ( -
e.preventDefault()} - > - {/* Split option */} - - {/* Delete clip option */} - -
- )}
);