From ba9870a9080ac4bdcc6b291a8908d1790ab99b98 Mon Sep 17 00:00:00 2001 From: yassinehaimouch Date: Wed, 25 Jun 2025 15:00:35 +0100 Subject: update: landing-page --- apps/web/src/components/header.tsx | 11 ++++++++--- apps/web/src/components/landing/hero.tsx | 27 +++++++++++++++++++-------- 2 files changed, 27 insertions(+), 11 deletions(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 27503cd0..d99a4fc5 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -29,8 +29,7 @@ export function Header() { const leftContent = ( - OpenCut Logo - OpenCut + OpenCut Logo ); @@ -59,5 +58,11 @@ export function Header() { ); - return ; + return ( + + ); } diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 7b4588c8..fadc043a 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -8,6 +8,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { useToast } from "@/hooks/use-toast"; import { getStars } from "@/lib/fetchGhStars"; +import Image from "next/image"; interface HeroProps { signupCount: number; @@ -82,7 +83,7 @@ export function Hero({ signupCount }: HeroProps) { }; return ( -
+
-

- The open source -

-

- video editor -

+

The First Open Source

+
+ Online +
+ frame + + Video Editor + +
+
Date: Wed, 25 Jun 2025 21:41:10 +0530 Subject: feat: enhance media processing with progress tracking --- apps/web/src/components/editor/media-panel.tsx | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx index f2b3f657..cdd67a86 100644 --- a/apps/web/src/components/editor/media-panel.tsx +++ b/apps/web/src/components/editor/media-panel.tsx @@ -17,27 +17,28 @@ export function MediaPanel() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const fileInputRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); const [searchQuery, setSearchQuery] = useState(""); const [mediaFilter, setMediaFilter] = useState("all"); const processFiles = async (files: FileList | File[]) => { - // If no files, do nothing - if (!files?.length) return; - + if (!files || files.length === 0) return; setIsProcessing(true); + setProgress(0); try { // Process files (extract metadata, generate thumbnails, etc.) - const items = await processMediaFiles(files); + const processedItems = await processMediaFiles(files, (p) => + setProgress(p) + ); // Add each processed media item to the store - items.forEach((item) => { - addMediaItem(item); - }); + processedItems.forEach((item) => addMediaItem(item)); } catch (error) { - // Show error if processing fails - console.error("File processing failed:", error); + // Show error toast if processing fails + console.error("Error processing files:", error); toast.error("Failed to process files"); } finally { setIsProcessing(false); + setProgress(0); } }; @@ -241,15 +242,12 @@ export function MediaPanel() { {isProcessing ? ( <> - Processing... + {progress}% ) : ( <> - + Add -- cgit v1.3.1 From f9d0be20d0a9b4c5ba5916838b05426fbb652fa7 Mon Sep 17 00:00:00 2001 From: DevloperAmanSingh Date: Wed, 25 Jun 2025 21:41:16 +0530 Subject: feat: implement progress tracking for media file processing in timeline --- apps/web/src/components/editor/timeline.tsx | 53 ++++++++++++++++------------- 1 file changed, 29 insertions(+), 24 deletions(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index c1b8f3e8..1ecbe545 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -69,6 +69,7 @@ export function Timeline() { } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); const [zoomLevel, setZoomLevel] = useState(1); const dragCounterRef = useRef(0); const timelineRef = useRef(null); @@ -334,8 +335,12 @@ export function Timeline() { } else if (e.dataTransfer.files?.length > 0) { // Handle file drops by creating new tracks setIsProcessing(true); + setProgress(0); try { - const processedItems = await processMediaFiles(e.dataTransfer.files); + const processedItems = await processMediaFiles( + e.dataTransfer.files, + (p) => setProgress(p) + ); for (const processedItem of processedItems) { addMediaItem(processedItem); const currentMediaItems = useMediaStore.getState().mediaItems; @@ -363,6 +368,7 @@ export function Timeline() { toast.error("Failed to process dropped files"); } finally { setIsProcessing(false); + setProgress(0); } } }; @@ -594,10 +600,11 @@ export function Timeline() {
{/* Time Display */} -
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s +
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
{/* Test Clip Button - for debugging */} @@ -946,14 +953,12 @@ export function Timeline() { )} {isDragOver && ( -
-
Drop media here to add a new track
+
+
+ {isProcessing + ? `Processing ${progress}%` + : "Drop media here to add to timeline"} +
)}
@@ -1630,18 +1635,18 @@ function TimelineTrackContent({ } if (mediaItem.type === "audio") { - return ( -
-
- + return ( +
+
+ +
-
- ); - } + ); + } // Fallback for videos without thumbnails return ( -- cgit v1.3.1 From de3d3e210e3e9d8b19eb15cdc61fdfe030ca2432 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 19:23:01 +0200 Subject: fix: update heading in hero for clarity about first oss video editor --- apps/web/src/components/landing/hero.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index fadc043a..a4341493 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -96,10 +96,9 @@ export function Hero({ signupCount }: HeroProps) { transition={{ delay: 0.2, duration: 0.8 }} className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]" > -

The First Open Source

+

The open source

- Online -
+
frame - + Video Editor
-- cgit v1.3.1 From bf64ec11334c39fde04c09b44460a489bcab1f2d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 19:24:05 +0200 Subject: style: make join waitlist responsive --- apps/web/src/components/landing/hero.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index a4341493..af33a3dd 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -129,7 +129,7 @@ export function Hero({ signupCount }: HeroProps) { animate={{ opacity: 1 }} transition={{ delay: 0.6, duration: 0.8 }} > -
+ Date: Wed, 25 Jun 2025 19:51:50 +0200 Subject: feat: improve clips dragging experience --- apps/web/src/components/editor/timeline.tsx | 70 +++++++++++++---------------- 1 file changed, 32 insertions(+), 38 deletions(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 1ecbe545..584a45f7 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1241,7 +1241,18 @@ function TimelineTrackContent({ }; const handleClipDragStart = (e: React.DragEvent, clip: any) => { - const dragData = { clipId: clip.id, trackId: track.id, name: clip.name }; + // Calculate the offset from the left edge of the clip to where the user clicked + const clipElement = e.currentTarget.parentElement as HTMLElement; + const clipRect = clipElement.getBoundingClientRect(); + const clickOffsetX = e.clientX - clipRect.left; + const clickOffsetTime = clickOffsetX / (50 * zoomLevel); + + const dragData = { + clipId: clip.id, + trackId: track.id, + name: clip.name, + clickOffsetTime: clickOffsetTime, + }; e.dataTransfer.setData( "application/x-timeline-clip", @@ -1467,7 +1478,11 @@ function TimelineTrackContent({ ); if (!timelineClipData) return; - const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData); + const { + clipId, + trackId: fromTrackId, + clickOffsetTime = 0, + } = JSON.parse(timelineClipData); // Find the clip being moved const sourceTrack = tracks.find( @@ -1480,10 +1495,17 @@ function TimelineTrackContent({ return; } + // Adjust position based on where user clicked on the clip + const adjustedStartTime = snappedTime - clickOffsetTime; + const finalStartTime = Math.max( + 0, + Math.round(adjustedStartTime * 10) / 10 + ); + // Check for overlaps with existing clips (excluding the moving clip itself) const movingClipDuration = movingClip.duration - movingClip.trimStart - movingClip.trimEnd; - const movingClipEnd = snappedTime + movingClipDuration; + const movingClipEnd = finalStartTime + movingClipDuration; const hasOverlap = track.clips.some((existingClip) => { // Skip the clip being moved if it's on the same track @@ -1498,7 +1520,7 @@ function TimelineTrackContent({ existingClip.trimEnd); // Check if clips overlap - return snappedTime < existingEnd && movingClipEnd > existingStart; + return finalStartTime < existingEnd && movingClipEnd > existingStart; }); if (hasOverlap) { @@ -1510,12 +1532,12 @@ function TimelineTrackContent({ if (fromTrackId === track.id) { // Moving within same track - updateClipStartTime(track.id, clipId, snappedTime); + updateClipStartTime(track.id, clipId, finalStartTime); } else { // Moving to different track moveClipToTrack(fromTrackId, track.id, clipId); requestAnimationFrame(() => { - updateClipStartTime(track.id, clipId, snappedTime); + updateClipStartTime(track.id, clipId, finalStartTime); }); } } else if (hasMediaItem) { @@ -1612,6 +1634,7 @@ function TimelineTrackContent({ src={mediaItem.url} alt={mediaItem.name} className="w-full h-full object-cover" + draggable={false} />
); @@ -1625,6 +1648,7 @@ function TimelineTrackContent({ src={mediaItem.thumbnailUrl} alt={mediaItem.name} className="w-full h-full object-cover rounded-sm" + draggable={false} />
@@ -1684,13 +1708,7 @@ function TimelineTrackContent({ return (
{ e.preventDefault(); // Only show track menu if we didn't click on a clip @@ -1744,7 +1762,7 @@ function TimelineTrackContent({ return (
{ e.stopPropagation(); @@ -1835,30 +1853,6 @@ function TimelineTrackContent({
); })} - - {/* Drop position indicator */} - {isDraggedOver && dropPosition !== null && ( -
-
-
-
- {wouldOverlap ? "⚠️" : ""} - {dropPosition.toFixed(1)}s -
-
- )} )}
-- cgit v1.3.1 From ebcb898e4e70805b556a25ac0a293d67a800fc59 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 21:02:34 +0200 Subject: feat: use mouse-based dragging for clips fixes an issue where dragging a clip would create a duplicate instead of dragging clip directly, and gives us more control moved the drag to a hook for cleaner code --- apps/web/src/components/editor/timeline.tsx | 75 +++++----- apps/web/src/hooks/use-drag-clip.ts | 213 ++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/hooks/use-drag-clip.ts (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 584a45f7..31e0dd50 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -25,6 +25,7 @@ import { import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; +import { useDragClip } from "@/hooks/use-drag-clip"; import { processMediaFiles } from "@/lib/media-processing"; import { toast } from "sonner"; import { useState, useRef, useEffect, useCallback } from "react"; @@ -1163,9 +1164,19 @@ function TimelineTrackContent({ deselectClip, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); + + // Mouse-based drag hook + const { + isDragging, + draggedClipId, + startDrag, + endDrag, + getDraggedClipPosition, + isValidDropTarget, + timelineRef, + } = useDragClip(zoomLevel); const [isDropping, setIsDropping] = useState(false); const [dropPosition, setDropPosition] = useState(null); - const [isDraggedOver, setIsDraggedOver] = useState(false); const [wouldOverlap, setWouldOverlap] = useState(false); const [resizing, setResizing] = useState<{ clipId: string; @@ -1240,37 +1251,14 @@ function TimelineTrackContent({ setResizing(null); }; - const handleClipDragStart = (e: React.DragEvent, clip: any) => { + const handleClipMouseDown = (e: React.MouseEvent, clip: any) => { // Calculate the offset from the left edge of the clip to where the user clicked const clipElement = e.currentTarget.parentElement as HTMLElement; const clipRect = clipElement.getBoundingClientRect(); const clickOffsetX = e.clientX - clipRect.left; const clickOffsetTime = clickOffsetX / (50 * zoomLevel); - const dragData = { - clipId: clip.id, - trackId: track.id, - name: clip.name, - clickOffsetTime: clickOffsetTime, - }; - - e.dataTransfer.setData( - "application/x-timeline-clip", - JSON.stringify(dragData) - ); - e.dataTransfer.effectAllowed = "move"; - - // Add visual feedback to the dragged element - const target = e.currentTarget.parentElement as HTMLElement; - target.style.opacity = "0.5"; - target.style.transform = "scale(0.95)"; - }; - - const handleClipDragEnd = (e: React.DragEvent) => { - // Reset visual feedback - const target = e.currentTarget.parentElement as HTMLElement; - target.style.opacity = ""; - target.style.transform = ""; + startDrag(e, clip.id, track.id, clip.startTime, clickOffsetTime); }; const handleTrackDragOver = (e: React.DragEvent) => { @@ -1388,14 +1376,12 @@ function TimelineTrackContent({ if (wouldOverlap) { e.dataTransfer.dropEffect = "none"; - setIsDraggedOver(true); setWouldOverlap(true); setDropPosition(Math.round(dropTime * 10) / 10); return; } e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy"; - setIsDraggedOver(true); setWouldOverlap(false); setDropPosition(Math.round(dropTime * 10) / 10); }; @@ -1414,7 +1400,6 @@ function TimelineTrackContent({ dragCounterRef.current++; setIsDropping(true); - setIsDraggedOver(true); }; const handleTrackDragLeave = (e: React.DragEvent) => { @@ -1433,7 +1418,6 @@ function TimelineTrackContent({ if (dragCounterRef.current === 0) { setIsDropping(false); - setIsDraggedOver(false); setWouldOverlap(false); setDropPosition(null); } @@ -1446,7 +1430,6 @@ function TimelineTrackContent({ // Reset all drag states dragCounterRef.current = 0; setIsDropping(false); - setIsDraggedOver(false); setWouldOverlap(false); const currentDropPosition = dropPosition; setDropPosition(null); @@ -1726,10 +1709,18 @@ function TimelineTrackContent({ onDragLeave={handleTrackDragLeave} onDrop={handleTrackDrop} onMouseMove={handleResizeMove} - onMouseUp={handleResizeEnd} + onMouseUp={(e) => { + handleResizeEnd(); + if (isDragging) { + endDrag(track.id); + } + }} onMouseLeave={handleResizeEnd} > -
+
{track.clips.length === 0 ? (
c.trackId === track.id && c.clipId === clip.id ); + + const isBeingDragged = draggedClipId === clip.id; return (
{ e.stopPropagation(); @@ -1808,10 +1807,8 @@ function TimelineTrackContent({ /> {/* Clip content */}
handleClipDragStart(e, clip)} - onDragEnd={handleClipDragEnd} + className={`flex-1 relative ${isBeingDragged ? "cursor-grabbing" : "cursor-grab"}`} + onMouseDown={(e) => handleClipMouseDown(e, clip)} > {renderClipContent(clip)} {/* Clip options menu */} diff --git a/apps/web/src/hooks/use-drag-clip.ts b/apps/web/src/hooks/use-drag-clip.ts new file mode 100644 index 00000000..7eb24daa --- /dev/null +++ b/apps/web/src/hooks/use-drag-clip.ts @@ -0,0 +1,213 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTimelineStore } from "@/stores/timeline-store"; + +interface DragState { + isDragging: boolean; + clipId: string | null; + trackId: string | null; + startMouseX: number; + startClipTime: number; + clickOffsetTime: number; + currentTime: number; +} + +export function useDragClip(zoomLevel: number) { + const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore(); + + const [dragState, setDragState] = useState({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + + const timelineRef = useRef(null); + + const startDrag = useCallback( + ( + e: React.MouseEvent, + clipId: string, + trackId: string, + clipStartTime: number, + clickOffsetTime: number + ) => { + e.preventDefault(); + e.stopPropagation(); + + setDragState({ + isDragging: true, + clipId, + trackId, + startMouseX: e.clientX, + startClipTime: clipStartTime, + clickOffsetTime, + currentTime: clipStartTime, + }); + }, + [] + ); + + const updateDrag = useCallback( + (e: MouseEvent) => { + if (!dragState.isDragging || !timelineRef.current) return; + + const timelineRect = timelineRef.current.getBoundingClientRect(); + const mouseX = e.clientX - timelineRect.left; + const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const snappedTime = Math.round(adjustedTime * 10) / 10; + + setDragState((prev) => ({ + ...prev, + currentTime: snappedTime, + })); + }, + [dragState.isDragging, dragState.clickOffsetTime, zoomLevel] + ); + + const endDrag = useCallback( + (targetTrackId?: string) => { + if (!dragState.isDragging || !dragState.clipId || !dragState.trackId) + return; + + const finalTrackId = targetTrackId || dragState.trackId; + const finalTime = dragState.currentTime; + + // Check for overlaps + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const targetTrack = tracks.find((t) => t.id === finalTrackId); + const movingClip = sourceTrack?.clips.find( + (c) => c.id === dragState.clipId + ); + + if (!movingClip || !targetTrack) { + setDragState((prev) => ({ ...prev, isDragging: false })); + return; + } + + const movingClipDuration = + movingClip.duration - movingClip.trimStart - movingClip.trimEnd; + const movingClipEnd = finalTime + movingClipDuration; + + const hasOverlap = targetTrack.clips.some((existingClip) => { + // Skip the clip being moved if it's on the same track + if ( + dragState.trackId === finalTrackId && + existingClip.id === dragState.clipId + ) { + return false; + } + + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + + return finalTime < existingEnd && movingClipEnd > existingStart; + }); + + if (!hasOverlap) { + if (dragState.trackId === finalTrackId) { + // Moving within same track + updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); + } else { + // Moving to different track + moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!); + requestAnimationFrame(() => { + updateClipStartTime(finalTrackId, dragState.clipId!, finalTime); + }); + } + } + + setDragState({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + }, + [dragState, tracks, updateClipStartTime, moveClipToTrack] + ); + + const cancelDrag = useCallback(() => { + setDragState({ + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }); + }, []); + + // Global mouse events + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseMove = (e: MouseEvent) => updateDrag(e); + const handleMouseUp = () => endDrag(); + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") cancelDrag(); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("keydown", handleEscape); + }; + }, [dragState.isDragging, updateDrag, endDrag, cancelDrag]); + + const getDraggedClipPosition = useCallback( + (clipId: string) => { + if (dragState.isDragging && dragState.clipId === clipId) { + return dragState.currentTime; + } + return null; + }, + [dragState] + ); + + const isValidDropTarget = useCallback( + (trackId: string) => { + if (!dragState.isDragging) return false; + + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const targetTrack = tracks.find((t) => t.id === trackId); + + if (!sourceTrack || !targetTrack) return false; + + // For now, allow drops on same track type + return sourceTrack.type === targetTrack.type; + }, + [dragState.isDragging, dragState.trackId, tracks] + ); + + return { + // State + isDragging: dragState.isDragging, + draggedClipId: dragState.clipId, + + // Methods + startDrag, + endDrag, + cancelDrag, + getDraggedClipPosition, + isValidDropTarget, + + // Refs + timelineRef, + }; +} -- cgit v1.3.1 From d1e313450db64c95237ebcefe928dbde39043fee Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 21:06:34 +0200 Subject: fix: show clip trim handles on focus rather than hover --- apps/web/src/components/editor/timeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 31e0dd50..b9e78839 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1802,7 +1802,7 @@ function TimelineTrackContent({ > {/* Left trim handle */}
handleResizeStart(e, clip.id, "left")} /> {/* Clip content */} @@ -1844,7 +1844,7 @@ function TimelineTrackContent({
{/* Right trim handle */}
handleResizeStart(e, clip.id, "right")} />
-- cgit v1.3.1 From f5c546d416a84d577f45a1fdb79e5e67e5a9495c Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 21:08:52 +0200 Subject: fix: clip getting deselected after a drag --- apps/web/src/components/editor/timeline.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index b9e78839..7e2089a7 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1251,6 +1251,8 @@ function TimelineTrackContent({ setResizing(null); }; + const [justFinishedDrag, setJustFinishedDrag] = useState(false); + const handleClipMouseDown = (e: React.MouseEvent, clip: any) => { // Calculate the offset from the left edge of the clip to where the user clicked const clipElement = e.currentTarget.parentElement as HTMLElement; @@ -1261,6 +1263,16 @@ function TimelineTrackContent({ startDrag(e, clip.id, track.id, clip.startTime, clickOffsetTime); }; + // Reset drag flag when drag ends + useEffect(() => { + if (!isDragging && justFinishedDrag) { + const timer = setTimeout(() => setJustFinishedDrag(false), 50); + return () => clearTimeout(timer); + } else if (isDragging && !justFinishedDrag) { + setJustFinishedDrag(true); + } + }, [isDragging, justFinishedDrag]); + const handleTrackDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -1766,6 +1778,11 @@ function TimelineTrackContent({ onClick={(e) => { e.stopPropagation(); + // Don't handle click if we just finished dragging + if (justFinishedDrag) { + return; + } + // Close context menu if it's open if (contextMenu) { setContextMenu(null); -- cgit v1.3.1 From 2775ac427de8bd9c2bb6323a216f1875038f68d8 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 21:10:40 +0200 Subject: fix: remove hover effects on clip drag --- apps/web/src/components/editor/timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 7e2089a7..18e1338e 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1773,7 +1773,7 @@ function TimelineTrackContent({ return (
{ e.stopPropagation(); -- cgit v1.3.1 From 926aebe00423b5b0b2fd0d8cf0ee97c6e85ec1fd Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 21:22:08 +0200 Subject: fix: make whole clip draggable --- apps/web/src/components/editor/timeline.tsx | 53 ++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 17 deletions(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 18e1338e..210a943d 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1254,8 +1254,24 @@ function TimelineTrackContent({ const [justFinishedDrag, setJustFinishedDrag] = useState(false); const handleClipMouseDown = (e: React.MouseEvent, clip: any) => { + // Handle selection first + if (!justFinishedDrag) { + const isSelected = selectedClips.some( + (c) => c.trackId === track.id && c.clipId === clip.id + ); + + if (e.metaKey || e.ctrlKey || e.shiftKey) { + // Multi-selection mode: toggle the clip + selectClip(track.id, clip.id, true); + } else if (!isSelected) { + // If clip is not selected, select it (replacing other selections) + selectClip(track.id, clip.id, false); + } + // Note: Don't deselect if already selected, as user might want to drag + } + // Calculate the offset from the left edge of the clip to where the user clicked - const clipElement = e.currentTarget.parentElement as HTMLElement; + const clipElement = e.currentTarget as HTMLElement; const clipRect = clipElement.getBoundingClientRect(); const clickOffsetX = e.clientX - clipRect.left; const clickOffsetTime = clickOffsetX / (50 * zoomLevel); @@ -1773,8 +1789,9 @@ function TimelineTrackContent({ return (
handleClipMouseDown(e, clip)} onClick={(e) => { e.stopPropagation(); @@ -1789,19 +1806,14 @@ function TimelineTrackContent({ return; // Don't handle selection when closing context menu } + // Only handle deselection here (selection is handled in mouseDown) const isSelected = selectedClips.some( (c) => c.trackId === track.id && c.clipId === clip.id ); - if (e.metaKey || e.ctrlKey || e.shiftKey) { - // Multi-selection mode: toggle the clip - selectClip(track.id, clip.id, true); - } else if (isSelected) { - // If clip is already selected, deselect it + if (isSelected && !e.metaKey && !e.ctrlKey && !e.shiftKey) { + // If clip is already selected and no modifier keys, deselect it deselectClip(track.id, clip.id); - } else { - // If clip is not selected, select it (replacing other selections) - selectClip(track.id, clip.id, false); } }} tabIndex={0} @@ -1820,13 +1832,13 @@ function TimelineTrackContent({ {/* Left trim handle */}
handleResizeStart(e, clip.id, "left")} + onMouseDown={(e) => { + e.stopPropagation(); // Prevent triggering clip drag + handleResizeStart(e, clip.id, "left"); + }} /> {/* Clip content */} -
handleClipMouseDown(e, clip)} - > +
{renderClipContent(clip)} {/* Clip options menu */}
@@ -1835,11 +1847,15 @@ function TimelineTrackContent({ size="icon" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => setClipMenuOpen(clip.id)} + onMouseDown={(e) => e.stopPropagation()} > {clipMenuOpen === clip.id && ( -
+
e.stopPropagation()} + > - )} - - - - -
- - {/* Preview Area */} -
-
- {activeClips.length === 0 ? ( -
- {tracks.length === 0 - ? "Drop media to start editing" - : "No clips at current time"} -
- ) : ( - activeClips.map((clipData, index) => renderClip(clipData, index)) - )} -
-
- - {/* Debug Info Panel - Conditionally rendered */} - {showDebug && ( -
-
- Debug: Active Clips ({activeClips.length}) -
-
- {activeClips.map((clipData, index) => ( -
- - {index + 1} - - {clipData.clip.name} - - ({clipData.mediaItem?.type || "test"}) - -
- ))} - {activeClips.length === 0 && ( - No active clips - )} -
-
- )} -
- ); -} +"use client"; + +import { + useTimelineStore, + type TimelineClip, + type TimelineTrack, +} from "@/stores/timeline-store"; +import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { VideoPlayer } from "@/components/ui/video-player"; +import { Button } from "@/components/ui/button"; +import { Play, Pause, Volume2, VolumeX } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; + +// Debug flag - set to false to hide active clips info +const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development"; + +interface ActiveClip { + clip: TimelineClip; + track: TimelineTrack; + mediaItem: MediaItem | null; +} + +export function PreviewPanel() { + const { tracks } = useTimelineStore(); + const { mediaItems } = useMediaStore(); + const { isPlaying, toggle, currentTime, muted, toggleMute, volume } = + usePlaybackStore(); + const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 }); + const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO); + const previewRef = useRef(null); + const containerRef = useRef(null); + const [previewDimensions, setPreviewDimensions] = useState({ + width: 0, + height: 0, + }); + + // Calculate optimal preview size that fits in container while maintaining aspect ratio + useEffect(() => { + const updatePreviewSize = () => { + if (!containerRef.current) return; + + const container = containerRef.current.getBoundingClientRect(); + const targetRatio = canvasSize.width / canvasSize.height; + const containerRatio = container.width / container.height; + + let width, height; + + if (containerRatio > targetRatio) { + // Container is wider - constrain by height + height = container.height; + width = height * targetRatio; + } else { + // Container is taller - constrain by width + width = container.width; + height = width / targetRatio; + } + + setPreviewDimensions({ width, height }); + }; + + updatePreviewSize(); + + const resizeObserver = new ResizeObserver(updatePreviewSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => resizeObserver.disconnect(); + }, [canvasSize.width, canvasSize.height]); + + // Get active clips at current time + const getActiveClips = (): ActiveClip[] => { + const activeClips: ActiveClip[] = []; + + tracks.forEach((track) => { + track.clips.forEach((clip) => { + const clipStart = clip.startTime; + const clipEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime >= clipStart && currentTime < clipEnd) { + const mediaItem = + clip.mediaId === "test" + ? null // Test clips don't have a real media item + : mediaItems.find((item) => item.id === clip.mediaId) || null; + + activeClips.push({ clip, track, mediaItem }); + } + }); + }); + + return activeClips; + }; + + const activeClips = getActiveClips(); + + // Render a clip + const renderClip = (clipData: ActiveClip, index: number) => { + const { clip, mediaItem } = clipData; + + // Test clips + if (!mediaItem || clip.mediaId === "test") { + return ( +
+
+
🎬
+

{clip.name}

+
+
+ ); + } + + // Video clips + if (mediaItem.type === "video") { + return ( +
+ +
+ ); + } + + // Image clips + if (mediaItem.type === "image") { + return ( +
+ {mediaItem.name} +
+ ); + } + + // Audio clips (visual representation) + if (mediaItem.type === "audio") { + return ( +
+
+
🎵
+

{mediaItem.name}

+
+
+ ); + } + + return null; + }; + + // Canvas presets + const canvasPresets = [ + { name: "16:9 HD", width: 1920, height: 1080 }, + { name: "16:9 4K", width: 3840, height: 2160 }, + { name: "9:16 Mobile", width: 1080, height: 1920 }, + { name: "1:1 Square", width: 1080, height: 1080 }, + { name: "4:3 Standard", width: 1440, height: 1080 }, + ]; + + return ( +
+ {/* Controls */} +
+ Canvas: + + + {/* Debug Toggle - Only show in development */} + {SHOW_DEBUG_INFO && ( + + )} + + + + +
+ + {/* Preview Area */} +
+
+ {activeClips.length === 0 ? ( +
+ {tracks.length === 0 + ? "Drop media to start editing" + : "No clips at current time"} +
+ ) : ( + activeClips.map((clipData, index) => renderClip(clipData, index)) + )} +
+
+ + {/* Debug Info Panel - Conditionally rendered */} + {showDebug && ( +
+
+ Debug: Active Clips ({activeClips.length}) +
+
+ {activeClips.map((clipData, index) => ( +
+ + {index + 1} + + {clipData.clip.name} + + ({clipData.mediaItem?.type || "test"}) + +
+ ))} + {activeClips.length === 0 && ( + No active clips + )} +
+
+ )} +
+ ); +} -- cgit v1.3.1 From 3eac1bcb0b1f006fb6a7ae8a9aa76de4ad4edf1d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 23:05:14 +0200 Subject: refactor: move timeline clip into its separate component and type into /types --- apps/web/src/components/editor/timeline-clip.tsx | 277 ++++++++++++++++ apps/web/src/components/editor/timeline.tsx | 384 ++++------------------- apps/web/src/types/timeline.ts | 27 ++ 3 files changed, 361 insertions(+), 327 deletions(-) create mode 100644 apps/web/src/components/editor/timeline-clip.tsx create mode 100644 apps/web/src/types/timeline.ts (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx new file mode 100644 index 00000000..1bb2b77f --- /dev/null +++ b/apps/web/src/components/editor/timeline-clip.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "../ui/button"; +import { MoreVertical, Scissors, Trash2 } from "lucide-react"; +import { useMediaStore } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useDragClip } from "@/hooks/use-drag-clip"; +import AudioWaveform from "./audio-waveform"; +import { toast } from "sonner"; +import { TimelineClipProps, ResizeState } from "@/types/timeline"; + +export function TimelineClip({ + clip, + track, + zoomLevel, + isSelected, + onContextMenu, + onClipMouseDown, + onClipClick, +}: TimelineClipProps) { + const { mediaItems } = useMediaStore(); + const { updateClipTrim, addClipToTrack, removeClipFromTrack } = + useTimelineStore(); + const { currentTime } = usePlaybackStore(); + const { draggedClipId, getDraggedClipPosition } = + useDragClip(zoomLevel); + + const [resizing, setResizing] = useState(null); + const [clipMenuOpen, setClipMenuOpen] = useState(false); + + const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; + const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); + + // Use real-time position during drag, otherwise use stored position + const dragPosition = getDraggedClipPosition(clip.id); + const clipStartTime = dragPosition !== null ? dragPosition : clip.startTime; + const clipLeft = clipStartTime * 50 * zoomLevel; + + const isBeingDragged = draggedClipId === clip.id; + + const getTrackColor = (type: string) => { + switch (type) { + case "video": + return "bg-blue-500/20 border-blue-500/30"; + case "audio": + return "bg-green-500/20 border-green-500/30"; + case "effects": + return "bg-purple-500/20 border-purple-500/30"; + default: + return "bg-gray-500/20 border-gray-500/30"; + } + }; + + const handleResizeStart = ( + e: React.MouseEvent, + clipId: string, + side: "left" | "right" + ) => { + e.stopPropagation(); + e.preventDefault(); + + setResizing({ + clipId, + side, + startX: e.clientX, + initialTrimStart: clip.trimStart, + initialTrimEnd: clip.trimEnd, + }); + }; + + const updateTrimFromMouseMove = (e: { clientX: number }) => { + if (!resizing) return; + + const deltaX = e.clientX - resizing.startX; + const deltaTime = deltaX / (50 * zoomLevel); + + if (resizing.side === "left") { + const newTrimStart = Math.max( + 0, + Math.min( + clip.duration - clip.trimEnd - 0.1, + resizing.initialTrimStart + deltaTime + ) + ); + updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd); + } else { + const newTrimEnd = Math.max( + 0, + Math.min( + clip.duration - clip.trimStart - 0.1, + resizing.initialTrimEnd - deltaTime + ) + ); + updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd); + } + }; + + const handleResizeMove = (e: React.MouseEvent) => { + updateTrimFromMouseMove(e); + }; + + const handleResizeEnd = () => { + setResizing(null); + }; + + const handleDeleteClip = () => { + removeClipFromTrack(track.id, clip.id); + setClipMenuOpen(false); + }; + + const handleSplitClip = () => { + // Use current playback time as split point + const splitTime = currentTime; + // Only split if splitTime is within the clip's effective range + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) { + toast.error("Playhead must be within clip to split"); + return; + } + + const firstDuration = splitTime - effectiveStart; + const secondDuration = effectiveEnd - splitTime; + + // First part: adjust original clip + updateClipTrim( + track.id, + clip.id, + clip.trimStart, + clip.trimEnd + secondDuration + ); + + // Second part: add new clip after split + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (cut)", + duration: clip.duration, + startTime: splitTime, + trimStart: clip.trimStart + firstDuration, + trimEnd: clip.trimEnd, + }); + + setClipMenuOpen(false); + toast.success("Clip split successfully"); + }; + + const renderClipContent = () => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + + if (!mediaItem) { + return ( + {clip.name} + ); + } + + if (mediaItem.type === "image") { + return ( +
+ {mediaItem.name} +
+ ); + } + + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + return ( +
+
+ {mediaItem.name} +
+ + {clip.name} + +
+ ); + } + + if (mediaItem.type === "audio") { + return ( +
+
+ +
+
+ ); + } + + // Fallback for videos without thumbnails + return ( + {clip.name} + ); + }; + + return ( +
onClipMouseDown(e, clip)} + onClick={(e) => onClipClick(e, clip)} + onMouseMove={handleResizeMove} + onMouseUp={handleResizeEnd} + onMouseLeave={handleResizeEnd} + tabIndex={0} + onContextMenu={(e) => onContextMenu(e, clip.id)} + > + {/* Left trim handle */} +
handleResizeStart(e, clip.id, "left")} + /> + + {/* Clip content */} +
+ {renderClipContent()} + + {/* Clip options menu */} +
+ + + {clipMenuOpen && ( +
e.stopPropagation()} + > + + +
+ )} +
+
+ + {/* Right trim handle */} +
handleResizeStart(e, clip.id, "right")} + /> +
+ ); +} diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index de2740a6..ef45eb5d 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -10,7 +10,6 @@ import { Snowflake, Copy, SplitSquareHorizontal, - MoreVertical, Volume2, VolumeX, Pause, @@ -25,7 +24,7 @@ import { import { useTimelineStore, type TimelineTrack, - type TimelineClip, + type TimelineClip as TypeTimelineClip, } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; @@ -40,7 +39,8 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import AudioWaveform from "./audio-waveform"; +import { TimelineClip } from "./timeline-clip"; +import { ContextMenuState } from "@/types/timeline"; export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their clips. @@ -81,13 +81,7 @@ export function Timeline() { const [isInTimeline, setIsInTimeline] = useState(false); // Unified context menu state - const [contextMenu, setContextMenu] = useState<{ - type: "track" | "clip"; - trackId: string; - clipId?: string; - x: number; - y: number; - } | null>(null); + const [contextMenu, setContextMenu] = useState(null); // Marquee selection state const [marquee, setMarquee] = useState<{ @@ -1137,127 +1131,31 @@ function TimelineTrackContent({ }: { track: TimelineTrack; zoomLevel: number; - setContextMenu: ( - menu: { - type: "track" | "clip"; - trackId: string; - clipId?: string; - x: number; - y: number; - } | null - ) => void; - contextMenu: { - type: "track" | "clip"; - trackId: string; - clipId?: string; - x: number; - y: number; - } | null; + setContextMenu: (menu: ContextMenuState | null) => void; + contextMenu: ContextMenuState | null; }) { const { mediaItems } = useMediaStore(); const { tracks, moveClipToTrack, - updateClipTrim, updateClipStartTime, addClipToTrack, - removeClipFromTrack, - toggleTrackMute, selectedClips, selectClip, deselectClip, } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); // Mouse-based drag hook - const { - isDragging, - draggedClipId, - startDrag, - endDrag, - getDraggedClipPosition, - isValidDropTarget, - timelineRef, - } = useDragClip(zoomLevel); + const { isDragging, startDrag, endDrag, timelineRef } = + useDragClip(zoomLevel); const [isDropping, setIsDropping] = useState(false); const [dropPosition, setDropPosition] = useState(null); const [wouldOverlap, setWouldOverlap] = useState(false); - const [resizing, setResizing] = useState<{ - clipId: string; - side: "left" | "right"; - startX: number; - initialTrimStart: number; - initialTrimEnd: number; - } | null>(null); const dragCounterRef = useRef(0); - const [clipMenuOpen, setClipMenuOpen] = useState(null); - - // Handle clip deletion - const handleDeleteClip = (clipId: string) => { - removeClipFromTrack(track.id, clipId); - }; - - const handleResizeStart = ( - e: React.MouseEvent, - clipId: string, - side: "left" | "right" - ) => { - e.stopPropagation(); - e.preventDefault(); - - const clip = track.clips.find((c) => c.id === clipId); - if (!clip) return; - - setResizing({ - clipId, - side, - startX: e.clientX, - initialTrimStart: clip.trimStart, - initialTrimEnd: clip.trimEnd, - }); - }; - - const updateTrimFromMouseMove = (e: { clientX: number }) => { - if (!resizing) return; - - const clip = track.clips.find((c) => c.id === resizing.clipId); - if (!clip) return; - - const deltaX = e.clientX - resizing.startX; - const deltaTime = deltaX / (50 * zoomLevel); - - if (resizing.side === "left") { - const newTrimStart = Math.max( - 0, - Math.min( - clip.duration - clip.trimEnd - 0.1, - resizing.initialTrimStart + deltaTime - ) - ); - updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd); - } else { - const newTrimEnd = Math.max( - 0, - Math.min( - clip.duration - clip.trimStart - 0.1, - resizing.initialTrimEnd - deltaTime - ) - ); - updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd); - } - }; - - const handleResizeMove = (e: React.MouseEvent) => { - updateTrimFromMouseMove(e); - }; - - const handleResizeEnd = () => { - setResizing(null); - }; const [justFinishedDrag, setJustFinishedDrag] = useState(false); - const handleClipMouseDown = (e: React.MouseEvent, clip: TimelineClip) => { + const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => { // Handle selection first if (!justFinishedDrag) { const isSelected = selectedClips.some( @@ -1283,6 +1181,43 @@ function TimelineTrackContent({ startDrag(e, clip.id, track.id, clip.startTime, clickOffsetTime); }; + const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => { + e.stopPropagation(); + + // Don't handle click if we just finished dragging + if (justFinishedDrag) { + return; + } + + // Close context menu if it's open + if (contextMenu) { + setContextMenu(null); + return; // Don't handle selection when closing context menu + } + + // Only handle deselection here (selection is handled in mouseDown) + const isSelected = selectedClips.some( + (c) => c.trackId === track.id && c.clipId === clip.id + ); + + if (isSelected && !e.metaKey && !e.ctrlKey && !e.shiftKey) { + // If clip is already selected and no modifier keys, deselect it + deselectClip(track.id, clip.id); + } + }; + + const handleClipContextMenu = (e: React.MouseEvent, clipId: string) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ + type: "clip", + trackId: track.id, + clipId: clipId, + x: e.clientX, + y: e.clientY, + }); + }; + // Reset drag flag when drag ends useEffect(() => { if (!isDragging && justFinishedDrag) { @@ -1378,7 +1313,7 @@ function TimelineTrackContent({ (t: TimelineTrack) => t.id === fromTrackId ); const movingClip = sourceTrack?.clips.find( - (c: TimelineClip) => c.id === clipId + (c: TypeTimelineClip) => c.id === clipId ); if (movingClip) { @@ -1504,7 +1439,7 @@ function TimelineTrackContent({ (t: TimelineTrack) => t.id === fromTrackId ); const movingClip = sourceTrack?.clips.find( - (c: TimelineClip) => c.id === clipId + (c: TypeTimelineClip) => c.id === clipId ); if (!movingClip) { @@ -1622,107 +1557,6 @@ function TimelineTrackContent({ } }; - const getTrackColor = (type: string) => { - switch (type) { - case "video": - return "bg-blue-500/20 border-blue-500/30"; - case "audio": - return "bg-green-500/20 border-green-500/30"; - case "effects": - return "bg-purple-500/20 border-purple-500/30"; - default: - return "bg-gray-500/20 border-gray-500/30"; - } - }; - - const renderClipContent = (clip: TimelineClip) => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); - - if (!mediaItem) { - return ( - {clip.name} - ); - } - - if (mediaItem.type === "image") { - return ( -
- {mediaItem.name} -
- ); - } - - if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { - return ( -
-
- {mediaItem.name} -
- - {clip.name} - -
- ); - } - - if (mediaItem.type === "audio") { - return ( -
-
- -
-
- ); - } - - // Fallback for videos without thumbnails - return ( - {clip.name} - ); - }; - - const handleSplitClip = (clip: TimelineClip) => { - // Use current playback time as split point - const splitTime = currentTime; - // Only split if splitTime is within the clip's effective range - const effectiveStart = clip.startTime; - const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; - const firstDuration = splitTime - effectiveStart; - const secondDuration = effectiveEnd - splitTime; - // First part: adjust original clip - updateClipTrim( - track.id, - clip.id, - clip.trimStart, - clip.trimEnd + secondDuration - ); - // Second part: add new clip after split - addClipToTrack(track.id, { - mediaId: clip.mediaId, - name: clip.name + " (cut)", - duration: clip.duration, - startTime: splitTime, - trimStart: clip.trimStart + firstDuration, - trimEnd: clip.trimEnd, - }); - }; - return (
{ - handleResizeEnd(); if (isDragging) { endDrag(track.id); } }} - onMouseLeave={handleResizeEnd} >
{track.clips.map((clip) => { - const effectiveDuration = - clip.duration - clip.trimStart - clip.trimEnd; - const clipWidth = Math.max( - 80, - effectiveDuration * 50 * zoomLevel - ); - - // Use real-time position during drag, otherwise use stored position - const dragPosition = getDraggedClipPosition(clip.id); - const clipStartTime = - dragPosition !== null ? dragPosition : clip.startTime; - const clipLeft = clipStartTime * 50 * zoomLevel; - const isSelected = selectedClips.some( (c) => c.trackId === track.id && c.clipId === clip.id ); - const isBeingDragged = draggedClipId === clip.id; return ( -
handleClipMouseDown(e, clip)} - onClick={(e) => { - e.stopPropagation(); - - // Don't handle click if we just finished dragging - if (justFinishedDrag) { - return; - } - - // Close context menu if it's open - if (contextMenu) { - setContextMenu(null); - return; // Don't handle selection when closing context menu - } - - // Only handle deselection here (selection is handled in mouseDown) - const isSelected = selectedClips.some( - (c) => c.trackId === track.id && c.clipId === clip.id - ); - - if (isSelected && !e.metaKey && !e.ctrlKey && !e.shiftKey) { - // If clip is already selected and no modifier keys, deselect it - deselectClip(track.id, clip.id); - } - }} - tabIndex={0} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - setContextMenu({ - type: "clip", - trackId: track.id, - clipId: clip.id, - x: e.clientX, - y: e.clientY, - }); - }} - > - {/* Left trim handle */} -
{ - e.stopPropagation(); // Prevent triggering clip drag - handleResizeStart(e, clip.id, "left"); - }} - /> - {/* Clip content */} -
- {renderClipContent(clip)} - {/* Clip options menu */} -
- - {clipMenuOpen === clip.id && ( -
e.stopPropagation()} - > - - -
- )} -
-
- {/* Right trim handle */} -
{ - e.stopPropagation(); // Prevent triggering clip drag - handleResizeStart(e, clip.id, "right"); - }} - /> -
+ clip={clip} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onContextMenu={handleClipContextMenu} + onClipMouseDown={handleClipMouseDown} + onClipClick={handleClipClick} + /> ); })} diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts new file mode 100644 index 00000000..21be520e --- /dev/null +++ b/apps/web/src/types/timeline.ts @@ -0,0 +1,27 @@ +import { TimelineTrack, TimelineClip } from "@/stores/timeline-store"; + +export interface TimelineClipProps { + clip: TimelineClip; + track: TimelineTrack; + zoomLevel: number; + isSelected: boolean; + onContextMenu: (e: React.MouseEvent, clipId: string) => void; + onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void; + onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void; +} + +export interface ResizeState { + clipId: string; + side: "left" | "right"; + startX: number; + initialTrimStart: number; + initialTrimEnd: number; +} + +export interface ContextMenuState { + type: "track" | "clip"; + trackId: string; + clipId?: string; + x: number; + y: number; +} -- cgit v1.3.1 From 0c97bc8c3f83c9dca9e92181d9dc5bc718d0c4e9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 23:08:31 +0200 Subject: refactor: remov eunused things --- apps/web/src/components/editor-header.tsx | 2 -- 1 file changed, 2 deletions(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 6f9f0b2f..5131230f 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -3,13 +3,11 @@ import Link from "next/link"; import { Button } from "./ui/button"; import { ChevronLeft, Download } from "lucide-react"; -import { useProjectStore } from "@/stores/project-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { HeaderBase } from "./header-base"; import { ProjectNameEditor } from "./editor/project-name-editor"; export function EditorHeader() { - const { activeProject } = useProjectStore(); const { getTotalDuration } = useTimelineStore(); const handleExport = () => { -- cgit v1.3.1 From e5fc3f9bbbde4967dfb72374d74626de72c18811 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 23:09:40 +0200 Subject: style: adjust height in editor header --- apps/web/src/components/header-base.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'apps/web/src/components') diff --git a/apps/web/src/components/header-base.tsx b/apps/web/src/components/header-base.tsx index 5f7b2b8d..f27eb7fc 100644 --- a/apps/web/src/components/header-base.tsx +++ b/apps/web/src/components/header-base.tsx @@ -29,7 +29,7 @@ export function HeaderBase({ return (
{leftContent &&
{leftContent}
} {centerContent && ( -- cgit v1.3.1 From 2f3a148dd4808a69619758b8640110b1aa859217 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Jun 2025 23:22:59 +0200 Subject: refactor: move debug component to separate component and place absolutely --- apps/web/src/app/layout.tsx | 2 + apps/web/src/components/development-debug.tsx | 107 +++++++++++++++++++++++ apps/web/src/components/editor/preview-panel.tsx | 44 ---------- 3 files changed, 109 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/development-debug.tsx (limited to 'apps/web/src/components') diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index ee045647..8188d31b 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -5,6 +5,7 @@ import Script from "next/script"; import "./globals.css"; import { Toaster } from "../components/ui/sonner"; import { TooltipProvider } from "../components/ui/tooltip"; +import { DevelopmentDebug } from "../components/development-debug"; import { baseMetaData } from "./metadata"; const inter = Inter({ @@ -27,6 +28,7 @@ export default function RootLayout({ {children} +