diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index f1dd04a..c397dd1 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -40,10 +40,32 @@ 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, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips, updateClipTrim, undo, redo } = - useTimelineStore(); + const { + tracks, + addTrack, + addClipToTrack, + removeTrack, + toggleTrackMute, + removeClipFromTrack, + getTotalDuration, + selectedClips, + clearSelectedClips, + setSelectedClips, + updateClipTrim, + undo, + redo, + } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); - const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore(); + const { + currentTime, + duration, + seek, + setDuration, + isPlaying, + toggle, + setSpeed, + speed, + } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [zoomLevel, setZoomLevel] = useState(1); @@ -53,7 +75,7 @@ export function Timeline() { // Unified context menu state const [contextMenu, setContextMenu] = useState<{ - type: 'track' | 'clip'; + type: "track" | "clip"; trackId: string; clipId?: string; x: number; @@ -92,7 +114,10 @@ export function Timeline() { // Keyboard event for deleting selected clips useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.key === "Delete" || e.key === "Backspace") && selectedClips.length > 0) { + if ( + (e.key === "Delete" || e.key === "Backspace") && + selectedClips.length > 0 + ) { selectedClips.forEach(({ trackId, clipId }) => { removeClipFromTrack(trackId, clipId); }); @@ -148,10 +173,15 @@ export function Timeline() { useEffect(() => { if (!marquee || !marquee.active) return; const handleMouseMove = (e: MouseEvent) => { - setMarquee((prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY }); + setMarquee( + (prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY } + ); }; const handleMouseUp = (e: MouseEvent) => { - setMarquee((prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false }); + setMarquee( + (prev) => + prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false } + ); }; window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); @@ -177,7 +207,8 @@ export function Timeline() { return; } // Clamp to timeline bounds - const clamp = (val: number, min: number, max: number) => Math.max(min, Math.min(max, val)); + const clamp = (val: number, min: number, max: number) => + Math.max(min, Math.min(max, val)); const bx1 = clamp(x1, 0, rect.width); const bx2 = clamp(x2, 0, rect.width); const by1 = clamp(y1, 0, rect.height); @@ -203,10 +234,14 @@ export function Timeline() { }); if (newSelection.length > 0) { if (marquee.additive) { - const selectedSet = new Set(selectedClips.map((c) => c.trackId + ':' + c.clipId)); + const selectedSet = new Set( + selectedClips.map((c) => c.trackId + ":" + c.clipId) + ); newSelection = [ ...selectedClips, - ...newSelection.filter((c) => !selectedSet.has(c.trackId + ':' + c.clipId)), + ...newSelection.filter( + (c) => !selectedSet.has(c.trackId + ":" + c.clipId) + ), ]; } setSelectedClips(newSelection); @@ -214,7 +249,14 @@ export function Timeline() { clearSelectedClips(); } setMarquee(null); - }, [marquee, tracks, zoomLevel, selectedClips, setSelectedClips, clearSelectedClips]); + }, [ + marquee, + tracks, + zoomLevel, + selectedClips, + setSelectedClips, + clearSelectedClips, + ]); const handleDragEnter = (e: React.DragEvent) => { // When something is dragged over the timeline, show overlay @@ -254,7 +296,9 @@ export function Timeline() { dragCounterRef.current = 0; // Ignore timeline clip drags - they're handled by track-specific handlers - const hasTimelineClip = e.dataTransfer.types.includes("application/x-timeline-clip"); + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); if (hasTimelineClip) { return; } @@ -327,14 +371,14 @@ export function Timeline() { const clickX = e.clientX - rect.left; const clickedTime = clickX / (50 * zoomLevel); const clampedTime = Math.max(0, Math.min(duration, clickedTime)); - + seek(clampedTime); }; const handleTimelineAreaClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { clearSelectedClips(); - + // Calculate the clicked time position and seek to it handleSeekToPosition(e); } @@ -351,21 +395,27 @@ export function Timeline() { }; // --- Playhead Scrubbing Handlers --- - const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setIsScrubbing(true); - handleScrub(e); - }, [duration, zoomLevel]); + const handlePlayheadMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsScrubbing(true); + handleScrub(e); + }, + [duration, zoomLevel] + ); - const handleScrub = useCallback((e: MouseEvent | React.MouseEvent) => { - const timeline = timelineRef.current; - if (!timeline) return; - const rect = timeline.getBoundingClientRect(); - const x = e.clientX - rect.left; - const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); - setScrubTime(time); - seek(time); // update video preview in real time - }, [duration, zoomLevel, seek]); + const handleScrub = useCallback( + (e: MouseEvent | React.MouseEvent) => { + const timeline = timelineRef.current; + if (!timeline) return; + const rect = timeline.getBoundingClientRect(); + const x = e.clientX - rect.left; + const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); + setScrubTime(time); + seek(time); // update video preview in real time + }, + [duration, zoomLevel, seek] + ); useEffect(() => { if (!isScrubbing) return; @@ -383,7 +433,8 @@ export function Timeline() { }; }, [isScrubbing, scrubTime, seek, handleScrub]); - const playheadPosition = isScrubbing && scrubTime !== null ? scrubTime : currentTime; + const playheadPosition = + isScrubbing && scrubTime !== null ? scrubTime : currentTime; const dragProps = { onDragEnter: handleDragEnter, @@ -399,14 +450,20 @@ export function Timeline() { return; } selectedClips.forEach(({ trackId, clipId }) => { - const track = tracks.find(t => t.id === trackId); - const clip = track?.clips.find(c => c.id === clipId); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); if (clip && track) { const splitTime = currentTime; const effectiveStart = clip.startTime; - const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); if (splitTime > effectiveStart && splitTime < effectiveEnd) { - updateClipTrim(track.id, clip.id, clip.trimStart, clip.trimEnd + (effectiveEnd - splitTime)); + updateClipTrim( + track.id, + clip.id, + clip.trimStart, + clip.trimEnd + (effectiveEnd - splitTime) + ); addClipToTrack(track.id, { mediaId: clip.mediaId, name: clip.name + " (split)", @@ -427,14 +484,17 @@ export function Timeline() { return; } selectedClips.forEach(({ trackId, clipId }) => { - const track = tracks.find(t => t.id === trackId); - const clip = track?.clips.find(c => c.id === clipId); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); if (clip && track) { addClipToTrack(track.id, { mediaId: clip.mediaId, name: clip.name + " (copy)", duration: clip.duration, - startTime: clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd) + 0.1, + startTime: + clip.startTime + + (clip.duration - clip.trimStart - clip.trimEnd) + + 0.1, trimStart: clip.trimStart, trimEnd: clip.trimEnd, }); @@ -449,8 +509,8 @@ export function Timeline() { return; } selectedClips.forEach(({ trackId, clipId }) => { - const track = tracks.find(t => t.id === trackId); - const clip = track?.clips.find(c => c.id === clipId); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); if (clip && track) { // Add a new freeze frame clip at the playhead addClipToTrack(track.id, { @@ -478,20 +538,23 @@ export function Timeline() { toast.success("Deleted selected clip(s)"); }; - // Prevent explorer zooming in/out when in timeline useEffect(() => { const preventZoom = (e: WheelEvent) => { // if (isInTimeline && (e.ctrlKey || e.metaKey)) { - if (isInTimeline && (e.ctrlKey || e.metaKey) && timelineRef.current?.contains(e.target as Node)) { + if ( + isInTimeline && + (e.ctrlKey || e.metaKey) && + timelineRef.current?.contains(e.target as Node) + ) { e.preventDefault(); } }; - document.addEventListener('wheel', preventZoom, { passive: false }); - + document.addEventListener("wheel", preventZoom, { passive: false }); + return () => { - document.removeEventListener('wheel', preventZoom); + document.removeEventListener("wheel", preventZoom); }; }, [isInTimeline]); @@ -820,15 +883,15 @@ export function Timeline() { {/* Timeline Tracks Content */}