fix: clips duplicating when moving them around

This commit is contained in:
Hyteq
2025-06-23 14:05:14 +03:00
parent c55b7796a6
commit b7ccce954e

View File

@ -33,7 +33,7 @@ export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips. // Timeline shows all tracks (video, audio, effects) and their clips.
// You can drag media here to add it to your project. // You can drag media here to add it to your project.
// Clips can be trimmed, deleted, and moved. // Clips can be trimmed, deleted, and moved.
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute } = const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack } =
useTimelineStore(); useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore(); const { mediaItems, addMediaItem } = useMediaStore();
const { currentTime, duration, seek } = usePlaybackStore(); const { currentTime, duration, seek } = usePlaybackStore();
@ -42,11 +42,12 @@ export function Timeline() {
const [zoomLevel, setZoomLevel] = useState(1); const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
// Track menu state
const [trackMenuOpen, setTrackMenuOpen] = useState<string | null>(null); // Unified context menu state
// Context menu state for tracks
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
type: 'track' | 'clip';
trackId: string; trackId: string;
clipId?: string;
x: number; x: number;
y: number; y: number;
} | null>(null); } | null>(null);
@ -318,19 +319,17 @@ export function Timeline() {
return ( return (
<div <div
key={i} key={i}
className={`absolute top-0 bottom-0 ${ className={`absolute top-0 bottom-0 ${isMainMarker
isMainMarker ? "border-l border-muted-foreground/40"
? "border-l border-muted-foreground/40" : "border-l border-muted-foreground/20"
: "border-l border-muted-foreground/20" }`}
}`}
style={{ left: `${time * 50 * zoomLevel}px` }} style={{ left: `${time * 50 * zoomLevel}px` }}
> >
<span <span
className={`absolute top-1 left-1 text-xs ${ className={`absolute top-1 left-1 text-xs ${isMainMarker
isMainMarker ? "text-muted-foreground font-medium"
? "text-muted-foreground font-medium" : "text-muted-foreground/70"
: "text-muted-foreground/70" }`}
}`}
> >
{(() => { {(() => {
const formatTime = (seconds: number) => { const formatTime = (seconds: number) => {
@ -391,13 +390,12 @@ export function Timeline() {
> >
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
<div <div
className={`w-3 h-3 rounded-full flex-shrink-0 ${ className={`w-3 h-3 rounded-full flex-shrink-0 ${track.type === "video"
track.type === "video" ? "bg-blue-500"
? "bg-blue-500" : track.type === "audio"
: track.type === "audio" ? "bg-green-500"
? "bg-green-500" : "bg-purple-500"
: "bg-purple-500" }`}
}`}
/> />
<span className="text-sm font-medium truncate"> <span className="text-sm font-medium truncate">
{track.name} {track.name}
@ -453,6 +451,7 @@ export function Timeline() {
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
setContextMenu({ setContextMenu({
type: 'track',
trackId: track.id, trackId: track.id,
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
@ -462,64 +461,9 @@ export function Timeline() {
<TimelineTrackContent <TimelineTrackContent
track={track} track={track}
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
setContextMenu={setContextMenu}
/> />
{/* Render context menu if open for this track */}
{contextMenu && contextMenu.trackId === track.id && (
<TrackContextMenu
x={contextMenu.x}
y={contextMenu.y}
track={track}
onClose={() => 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);
}}
/>
)}
</div> </div>
))} ))}
@ -538,6 +482,136 @@ export function Timeline() {
</div> </div>
</div> </div>
</div> </div>
{/* Clean Unified Context Menu */}
{contextMenu && (
<div
className="fixed z-50 min-w-[160px] bg-popover border border-border rounded-md shadow-md py-1 text-sm"
style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={(e) => e.preventDefault()}
>
{contextMenu.type === 'track' ? (
// Track context menu
<>
<button
className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
onClick={() => {
const track = tracks.find(t => t.id === contextMenu.trackId);
if (track) toggleTrackMute(track.id);
setContextMenu(null);
}}
>
{(() => {
const track = tracks.find(t => t.id === contextMenu.trackId);
return track?.muted ? (
<>
<Volume2 className="h-4 w-4 mr-2" />
Unmute Track
</>
) : (
<>
<VolumeX className="h-4 w-4 mr-2" />
Mute Track
</>
);
})()}
</button>
<div className="h-px bg-border mx-1 my-1" />
<button
className="flex items-center w-full px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors text-left"
onClick={() => {
removeTrack(contextMenu.trackId);
setContextMenu(null);
toast.success("Track deleted");
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Track
</button>
</>
) : (
// Clip context menu
<>
<button
className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
onClick={() => {
if (contextMenu.clipId) {
const track = tracks.find(t => t.id === contextMenu.trackId);
const clip = track?.clips.find(c => c.id === contextMenu.clipId);
if (clip && track) {
const splitTime = currentTime;
const effectiveStart = clip.startTime;
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
useTimelineStore.getState().updateClipTrim(
track.id,
clip.id,
clip.trimStart,
clip.trimEnd + (effectiveEnd - splitTime)
);
useTimelineStore.getState().addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (split)",
duration: clip.duration,
startTime: splitTime,
trimStart: clip.trimStart + (splitTime - effectiveStart),
trimEnd: clip.trimEnd,
});
toast.success("Clip split successfully");
} else {
toast.error("Playhead must be within clip to split");
}
}
}
setContextMenu(null);
}}
>
<Scissors className="h-4 w-4 mr-2" />
Split at Playhead
</button>
<button
className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left"
onClick={() => {
if (contextMenu.clipId) {
const track = tracks.find(t => t.id === contextMenu.trackId);
const clip = track?.clips.find(c => c.id === contextMenu.clipId);
if (clip && track) {
useTimelineStore.getState().addClipToTrack(track.id, {
mediaId: clip.mediaId,
name: clip.name + " (copy)",
duration: clip.duration,
startTime: clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd) + 0.1,
trimStart: clip.trimStart,
trimEnd: clip.trimEnd,
});
toast.success("Clip duplicated");
}
}
setContextMenu(null);
}}
>
<Copy className="h-4 w-4 mr-2" />
Duplicate Clip
</button>
<div className="h-px bg-border mx-1 my-1" />
<button
className="flex items-center w-full px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors text-left"
onClick={() => {
if (contextMenu.clipId) {
removeClipFromTrack(contextMenu.trackId, contextMenu.clipId);
toast.success("Clip deleted");
}
setContextMenu(null);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Clip
</button>
</>
)}
</div>
)}
</div> </div>
); );
} }
@ -545,9 +619,11 @@ export function Timeline() {
function TimelineTrackContent({ function TimelineTrackContent({
track, track,
zoomLevel, zoomLevel,
setContextMenu,
}: { }: {
track: TimelineTrack; track: TimelineTrack;
zoomLevel: number; zoomLevel: number;
setContextMenu: (menu: { type: 'track' | 'clip'; trackId: string; clipId?: string; x: number; y: number; } | null) => void;
}) { }) {
const { mediaItems } = useMediaStore(); const { mediaItems } = useMediaStore();
const { const {
@ -573,89 +649,10 @@ function TimelineTrackContent({
} | null>(null); } | null>(null);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const [clipMenuOpen, setClipMenuOpen] = useState<string | null>(null); const [clipMenuOpen, setClipMenuOpen] = useState<string | null>(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<NodeJS.Timeout>();
// 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 // Handle clip deletion
const handleDeleteClip = (clipId: string) => { 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); removeClipFromTrack(track.id, clipId);
setClipContextMenu(null);
}; };
const handleResizeStart = ( const handleResizeStart = (
@ -768,7 +765,7 @@ function TimelineTrackContent({
return; return;
} }
} }
} catch (error) {} } catch (error) { }
} }
// Calculate drop position for overlap checking // Calculate drop position for overlap checking
@ -984,8 +981,11 @@ function TimelineTrackContent({
} }
if (fromTrackId === track.id) { if (fromTrackId === track.id) {
// Moving within same track
updateClipStartTime(track.id, clipId, snappedTime); updateClipStartTime(track.id, clipId, snappedTime);
} else { } else {
// Moving to different track
console.log('Moving clip from', fromTrackId, 'to', track.id);
moveClipToTrack(fromTrackId, track.id, clipId); moveClipToTrack(fromTrackId, track.id, clipId);
requestAnimationFrame(() => { requestAnimationFrame(() => {
updateClipStartTime(track.id, clipId, snappedTime); updateClipStartTime(track.id, clipId, snappedTime);
@ -1143,14 +1143,24 @@ function TimelineTrackContent({
return ( return (
<div <div
className={`w-full h-full transition-all duration-150 ease-out ${ className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver
isDraggedOver ? wouldOverlap
? wouldOverlap ? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg" : "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
: "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg" : "hover:bg-muted/20"
: "hover:bg-muted/20" }`}
}`} onContextMenu={(e) => {
onContextMenu={handleTrackContextMenu} 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} onDragOver={handleTrackDragOver}
onDragEnter={handleTrackDragEnter} onDragEnter={handleTrackDragEnter}
onDragLeave={handleTrackDragLeave} onDragLeave={handleTrackDragLeave}
@ -1162,13 +1172,12 @@ function TimelineTrackContent({
<div className="h-full relative track-clips-container min-w-full"> <div className="h-full relative track-clips-container min-w-full">
{track.clips.length === 0 ? ( {track.clips.length === 0 ? (
<div <div
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${ className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping
isDropping ? wouldOverlap
? wouldOverlap ? "border-red-500 bg-red-500/10 text-red-600"
? "border-red-500 bg-red-500/10 text-red-600" : "border-blue-500 bg-blue-500/10 text-blue-600"
: "border-blue-500 bg-blue-500/10 text-blue-600" : "border-muted/30"
: "border-muted/30" }`}
}`}
> >
{isDropping {isDropping
? wouldOverlap ? wouldOverlap
@ -1192,7 +1201,17 @@ function TimelineTrackContent({
key={clip.id} 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`} 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` }} 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 */} {/* Left trim handle */}
<div <div
@ -1250,28 +1269,24 @@ function TimelineTrackContent({
{/* Drop position indicator */} {/* Drop position indicator */}
{isDraggedOver && dropPosition !== null && ( {isDraggedOver && dropPosition !== null && (
<div <div
className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${ className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
wouldOverlap ? "bg-red-500" : "bg-blue-500" }`}
}`}
style={{ style={{
left: `${dropPosition * 50 * zoomLevel}px`, left: `${dropPosition * 50 * zoomLevel}px`,
transform: "translateX(-50%)", transform: "translateX(-50%)",
}} }}
> >
<div <div
className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${ className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
wouldOverlap ? "bg-red-500" : "bg-blue-500" }`}
}`}
/> />
<div <div
className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${ className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
wouldOverlap ? "bg-red-500" : "bg-blue-500" }`}
}`}
/> />
<div <div
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${ className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
wouldOverlap ? "bg-red-500" : "bg-blue-500" }`}
}`}
> >
{wouldOverlap ? "⚠️" : ""} {wouldOverlap ? "⚠️" : ""}
{dropPosition.toFixed(1)}s {dropPosition.toFixed(1)}s
@ -1281,75 +1296,7 @@ function TimelineTrackContent({
</> </>
)} )}
{/* Track Context Menu (for empty areas) */}
{trackContextMenu && (
<div
className="track-context-menu fixed z-50 min-w-[140px] bg-white border border-muted rounded-lg shadow-lg py-1 text-sm animate-fade-in text-black"
style={{
left: trackContextMenu.x + 4,
top: trackContextMenu.y + 4,
}}
onContextMenu={(e) => e.preventDefault()}
>
{/* Mute/Unmute option */}
<button
className="flex items-center w-full px-3 py-2 hover:bg-muted/20 transition-colors"
onClick={() => {
toggleTrackMute(track.id);
setTrackContextMenu(null);
}}
>
<span className="mr-2">
{track.muted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</span>
{track.muted ? "Unmute Track" : "Mute Track"}
</button>
</div>
)}
{/* Clip Context Menu (for specific clips) */}
{clipContextMenu &&
track.clips.some((c) => c.id === clipContextMenu.clipId) && (
<div
className="clip-context-menu fixed z-50 min-w-[140px] bg-white border border-muted rounded-lg shadow-lg py-1 text-sm animate-fade-in text-black"
style={{
left: clipContextMenu.x + 4,
top: clipContextMenu.y + 4,
}}
onContextMenu={(e) => e.preventDefault()}
>
{/* Split option */}
<button
className="flex items-center w-full px-3 py-2 hover:bg-muted/20 transition-colors"
onClick={() => {
const clip = track.clips.find(
(c) => c.id === clipContextMenu.clipId
);
if (clip) handleSplitClip(clip);
setClipContextMenu(null);
}}
>
<span className="mr-2">
<Scissors className="h-4 w-4" />
</span>
Split at Playhead
</button>
{/* Delete clip option */}
<button
className="flex items-center w-full px-3 py-2 text-red-600 hover:bg-red-50 transition-colors"
onClick={() => handleDeleteClip(clipContextMenu.clipId)}
>
<span className="mr-2">
<Trash2 className="h-4 w-4" />
</span>
Delete This Clip
</button>
</div>
)}
</div> </div>
</div> </div>
); );