From d605cd762cc3c784d908d36a12605d7d3547f1f1 Mon Sep 17 00:00:00 2001 From: aashishparuvada Date: Mon, 23 Jun 2025 22:49:56 +0530 Subject: [PATCH 01/12] hotfix:fixed-cmd-for-deselection --- apps/web/src/components/editor/timeline.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 675b398..b95f983 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1409,9 +1409,18 @@ function TimelineTrackContent({ style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }} onClick={(e) => { e.stopPropagation(); + 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 + deselectClip(track.id, clip.id); } else { + // If clip is not selected, select it (replacing other selections) selectClip(track.id, clip.id, false); } }} From 4a9b102ce1d6824167cdb57f514b66d115bf016e Mon Sep 17 00:00:00 2001 From: aashishparuvada Date: Mon, 23 Jun 2025 23:26:41 +0530 Subject: [PATCH 02/12] feature:deselect-clips-on-click --- apps/web/src/components/editor/timeline.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index b95f983..d6a6d62 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -299,9 +299,13 @@ export function Timeline() { }; const handleWheel = (e: React.WheelEvent) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? -0.05 : 0.05; - setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); + // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true) + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.05 : 0.05; + setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); + } + // Otherwise, allow normal scrolling }; const dragProps = { @@ -620,8 +624,7 @@ export function Timeline() { className="relative cursor-pointer select-none" style={{ width: `${Math.max(1000, duration * 50 * zoomLevel)}px`, - minHeight: - tracks.length > 0 ? `${tracks.length * 60}px` : "200px", + minHeight: '600px', // Always at least 600px tall for easy empty area clicking }} onClick={handleTimelineAreaClick} onMouseDown={handleTimelineMouseDown} From f17d848b170361970d118074ded3553d6910f939 Mon Sep 17 00:00:00 2001 From: aashishparuvada Date: Mon, 23 Jun 2025 23:48:15 +0530 Subject: [PATCH 03/12] feat:assigned-actions-for-selected-clips --- apps/web/src/components/editor/timeline.tsx | 101 ++++++++++++++++++-- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index d6a6d62..967951e 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -40,7 +40,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, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips } = + const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips, updateClipTrim } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore(); @@ -315,6 +315,92 @@ export function Timeline() { onDrop: handleDrop, }; + // Action handlers for toolbar + const handleSplitSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, 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); + if (splitTime > effectiveStart && splitTime < effectiveEnd) { + updateClipTrim(track.id, clip.id, clip.trimStart, clip.trimEnd + (effectiveEnd - splitTime)); + 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("Split selected clip(s)"); + }; + + const handleDuplicateSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, 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, + trimStart: clip.trimStart, + trimEnd: clip.trimEnd, + }); + } + }); + toast.success("Duplicated selected clip(s)"); + }; + + const handleFreezeSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, 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, { + mediaId: clip.mediaId, + name: clip.name + " (freeze)", + duration: 1, // 1 second freeze frame + startTime: currentTime, + trimStart: 0, + trimEnd: clip.duration - 1, + }); + } + }); + toast.success("Freeze frame added for selected clip(s)"); + }; + + const handleDeleteSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + removeClipFromTrack(trackId, clipId); + }); + clearSelectedClips(); + toast.success("Deleted selected clip(s)"); + }; + return (
- @@ -423,7 +509,7 @@ export function Timeline() { - @@ -432,7 +518,7 @@ export function Timeline() { - @@ -441,7 +527,7 @@ export function Timeline() { - @@ -748,7 +834,7 @@ export function Timeline() { const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); if (splitTime > effectiveStart && splitTime < effectiveEnd) { - useTimelineStore.getState().updateClipTrim( + updateClipTrim( track.id, clip.id, clip.trimStart, @@ -1496,8 +1582,7 @@ function TimelineTrackContent({ {/* Drop position indicator */} {isDraggedOver && dropPosition !== null && (
Date: Mon, 23 Jun 2025 23:53:18 +0530 Subject: [PATCH 04/12] hotfix:disabled-vertical-scrolling-in-timeline --- apps/web/src/components/editor/timeline.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 967951e..fb94b37 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -703,14 +703,14 @@ export function Timeline() {
{/* Timeline Tracks Content */} -
- +
+
+ {/* Timeline grid and clips area (with left margin for sidebar) */}
-
))} @@ -768,7 +767,7 @@ export function Timeline() { )}
- +
From 2a1ac8fcf7075a4a25523c7601e98013313658c5 Mon Sep 17 00:00:00 2001 From: Andrew Kordampalos Date: Mon, 23 Jun 2025 22:17:36 +0300 Subject: [PATCH 05/12] Implement click-to-seek functionality in timeline --- .gitignore | 3 +++ apps/web/src/app/editor/editor.css | 4 ++++ apps/web/src/app/editor/page.tsx | 1 + apps/web/src/components/editor/timeline.tsx | 19 +++++++++++++++++-- 4 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/editor/editor.css diff --git a/.gitignore b/.gitignore index 24d66f8..3deed24 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ # typescript /apps/web/next-env.d.ts /apps/web/yarn.lock + +# asdf version management +.tool-versions diff --git a/apps/web/src/app/editor/editor.css b/apps/web/src/app/editor/editor.css new file mode 100644 index 0000000..f482c6d --- /dev/null +++ b/apps/web/src/app/editor/editor.css @@ -0,0 +1,4 @@ +/* Prevent scroll jumping on Mac devices when using the editor */ +body { + overflow: hidden; +} \ No newline at end of file diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/page.tsx index 64186a3..2efb59c 100644 --- a/apps/web/src/app/editor/page.tsx +++ b/apps/web/src/app/editor/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect } from "react"; +import "./editor.css"; import { ResizablePanelGroup, ResizablePanel, diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 675b398..24169b6 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -290,11 +290,18 @@ export function Timeline() { } }; - // Deselect all clips when clicking empty timeline area + // Deselect all clips when clicking empty timeline area and seek to clicked position const handleTimelineAreaClick = (e: React.MouseEvent) => { // Only clear selection if the click target is the timeline background (not a child/clip) if (e.target === e.currentTarget) { clearSelectedClips(); + + // Calculate the clicked time position and seek to it + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickedTime = clickX / (50 * zoomLevel); + const clampedTime = Math.max(0, Math.min(duration, clickedTime)); + seek(clampedTime); } }; @@ -487,10 +494,18 @@ export function Timeline() {
{ + // Calculate the clicked time position and seek to it + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickedTime = clickX / (50 * zoomLevel); + const clampedTime = Math.max(0, Math.min(duration, clickedTime)); + seek(clampedTime); + }} > {/* Time markers */} {(() => { From 267a590d04c89ad0a74b682d8b575197e1a742fc Mon Sep 17 00:00:00 2001 From: Andrew Kordampalos Date: Mon, 23 Jun 2025 22:32:17 +0300 Subject: [PATCH 06/12] De-duplicate code --- apps/web/src/components/editor/timeline.tsx | 22 ++++++++++----------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 24169b6..e2c212d 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -290,18 +290,20 @@ export function Timeline() { } }; - // Deselect all clips when clicking empty timeline area and seek to clicked position + const handleSeekToPosition = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + 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) => { - // Only clear selection if the click target is the timeline background (not a child/clip) if (e.target === e.currentTarget) { clearSelectedClips(); // Calculate the clicked time position and seek to it - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const clickedTime = clickX / (50 * zoomLevel); - const clampedTime = Math.max(0, Math.min(duration, clickedTime)); - seek(clampedTime); + handleSeekToPosition(e); } }; @@ -500,11 +502,7 @@ export function Timeline() { }} onClick={(e) => { // Calculate the clicked time position and seek to it - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const clickedTime = clickX / (50 * zoomLevel); - const clampedTime = Math.max(0, Math.min(duration, clickedTime)); - seek(clampedTime); + handleSeekToPosition(e); }} > {/* Time markers */} From c7f56d61d776f87607fc02e9f71bb4b8e1a49d98 Mon Sep 17 00:00:00 2001 From: Andrew Kordampalos Date: Mon, 23 Jun 2025 22:41:38 +0300 Subject: [PATCH 07/12] Format --- apps/web/src/components/editor/timeline.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index e2c212d..cf6b57e 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -295,6 +295,7 @@ 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); }; From bc4c064ad6a77d62ae19cfcb37258ddfdfd6420d Mon Sep 17 00:00:00 2001 From: yassinehaimouch Date: Mon, 23 Jun 2025 23:01:50 +0100 Subject: [PATCH 08/12] fix: hero landing page height --- apps/web/src/components/landing/hero.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index f2c70f5..6b6cc06 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -67,7 +67,7 @@ export function Hero({ signupCount }: HeroProps) { }; return ( -
+
Date: Tue, 24 Jun 2025 09:26:31 +0530 Subject: [PATCH 09/12] feat:implemented-undo-feature-for-timeline --- apps/web/src/components/editor/timeline.tsx | 14 +++- apps/web/src/stores/timeline-store.ts | 74 +++++++++++++++------ 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index fb94b37..484e1a6 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -40,7 +40,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, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips, updateClipTrim } = + const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips, updateClipTrim, undo } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore(); @@ -98,6 +98,18 @@ export function Timeline() { return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedClips, removeClipFromTrack, clearSelectedClips]); + // Keyboard event for undo (Cmd+Z) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) { + e.preventDefault(); + undo(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [undo]); + // Mouse down on timeline background to start marquee const handleTimelineMouseDown = (e: React.MouseEvent) => { if (e.target === e.currentTarget && e.button === 0) { diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 4b55a53..a369d57 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -20,6 +20,7 @@ export interface TimelineTrack { interface TimelineStore { tracks: TimelineTrack[]; + history: TimelineTrack[][]; // Multi-selection selectedClips: { trackId: string; clipId: string }[]; @@ -53,12 +54,30 @@ interface TimelineStore { // Computed values getTotalDuration: () => number; + + // New actions + undo: () => void; + pushHistory: () => void; } export const useTimelineStore = create((set, get) => ({ tracks: [], + history: [], selectedClips: [], + pushHistory: () => { + const { tracks, history } = get(); + // Deep copy tracks + set({ history: [...history, JSON.parse(JSON.stringify(tracks))] }); + }, + + undo: () => { + const { history } = get(); + if (history.length === 0) return; + const prev = history[history.length - 1]; + set({ tracks: prev, history: history.slice(0, -1) }); + }, + selectClip: (trackId, clipId, multi = false) => { set((state) => { const exists = state.selectedClips.some( @@ -86,6 +105,7 @@ export const useTimelineStore = create((set, get) => ({ setSelectedClips: (clips) => set({ selectedClips: clips }), addTrack: (type) => { + get().pushHistory(); const newTrack: TimelineTrack = { id: crypto.randomUUID(), name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, @@ -100,12 +120,14 @@ export const useTimelineStore = create((set, get) => ({ }, removeTrack: (trackId) => { + get().pushHistory(); set((state) => ({ tracks: state.tracks.filter((track) => track.id !== trackId), })); }, addClipToTrack: (trackId, clipData) => { + get().pushHistory(); const newClip: TimelineClip = { ...clipData, id: crypto.randomUUID(), @@ -124,19 +146,21 @@ export const useTimelineStore = create((set, get) => ({ }, removeClipFromTrack: (trackId, clipId) => { + get().pushHistory(); set((state) => ({ - tracks: state.tracks.map((track) => - track.id === trackId - ? { - ...track, - clips: track.clips.filter((clip) => clip.id !== clipId), - } - : track - ), + tracks: state.tracks + .map((track) => + track.id === trackId + ? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) } + : track + ) + // Remove track if it becomes empty + .filter((track) => track.clips.length > 0), })); }, moveClipToTrack: (fromTrackId, toTrackId, clipId) => { + get().pushHistory(); set((state) => { const fromTrack = state.tracks.find((track) => track.id === fromTrackId); const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); @@ -144,25 +168,29 @@ export const useTimelineStore = create((set, get) => ({ if (!clipToMove) return state; return { - tracks: state.tracks.map((track) => { - if (track.id === fromTrackId) { - return { - ...track, - clips: track.clips.filter((clip) => clip.id !== clipId), - }; - } else if (track.id === toTrackId) { - return { - ...track, - clips: [...track.clips, clipToMove], - }; - } - return track; - }), + tracks: state.tracks + .map((track) => { + if (track.id === fromTrackId) { + return { + ...track, + clips: track.clips.filter((clip) => clip.id !== clipId), + }; + } else if (track.id === toTrackId) { + return { + ...track, + clips: [...track.clips, clipToMove], + }; + } + return track; + }) + // Remove track if it becomes empty + .filter((track) => track.clips.length > 0), }; }); }, updateClipTrim: (trackId, clipId, trimStart, trimEnd) => { + get().pushHistory(); set((state) => ({ tracks: state.tracks.map((track) => track.id === trackId @@ -178,6 +206,7 @@ export const useTimelineStore = create((set, get) => ({ }, updateClipStartTime: (trackId, clipId, startTime) => { + get().pushHistory(); set((state) => ({ tracks: state.tracks.map((track) => track.id === trackId @@ -193,6 +222,7 @@ export const useTimelineStore = create((set, get) => ({ }, toggleTrackMute: (trackId) => { + get().pushHistory(); set((state) => ({ tracks: state.tracks.map((track) => track.id === trackId ? { ...track, muted: !track.muted } : track From 9570847edd5afc8d005d4c1596eb7ce84d3fcdd1 Mon Sep 17 00:00:00 2001 From: aashishparuvada Date: Tue, 24 Jun 2025 09:27:13 +0530 Subject: [PATCH 10/12] updated package-lock.json --- package-lock.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9891ac9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "OpenCut", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 073f0d89bd8ee6ccd81a3a4b3948f88816812bdf Mon Sep 17 00:00:00 2001 From: aashishparuvada Date: Tue, 24 Jun 2025 09:31:53 +0530 Subject: [PATCH 11/12] feat:enabled-redo-functionality --- apps/web/src/components/editor/timeline.tsx | 17 +++++++++++++- apps/web/src/stores/timeline-store.ts | 25 +++++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 484e1a6..064d0dd 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -40,7 +40,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, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips, updateClipTrim, undo } = + const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips, updateClipTrim, undo, redo } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore(); @@ -110,6 +110,21 @@ export function Timeline() { return () => window.removeEventListener("keydown", handleKeyDown); }, [undo]); + // Keyboard event for redo (Cmd+Shift+Z or Cmd+Y) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) { + e.preventDefault(); + redo(); + } else if ((e.metaKey || e.ctrlKey) && e.key === "y") { + e.preventDefault(); + redo(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [redo]); + // Mouse down on timeline background to start marquee const handleTimelineMouseDown = (e: React.MouseEvent) => { if (e.target === e.currentTarget && e.button === 0) { diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index a369d57..e1474ba 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -21,6 +21,7 @@ export interface TimelineTrack { interface TimelineStore { tracks: TimelineTrack[]; history: TimelineTrack[][]; + redoStack: TimelineTrack[][]; // Multi-selection selectedClips: { trackId: string; clipId: string }[]; @@ -57,25 +58,34 @@ interface TimelineStore { // New actions undo: () => void; + redo: () => void; pushHistory: () => void; } export const useTimelineStore = create((set, get) => ({ tracks: [], history: [], + redoStack: [], selectedClips: [], pushHistory: () => { - const { tracks, history } = get(); + const { tracks, history, redoStack } = get(); // Deep copy tracks - set({ history: [...history, JSON.parse(JSON.stringify(tracks))] }); + set({ + history: [...history, JSON.parse(JSON.stringify(tracks))], + redoStack: [] // Clear redo stack when new action is performed + }); }, undo: () => { - const { history } = get(); + const { history, redoStack, tracks } = get(); if (history.length === 0) return; const prev = history[history.length - 1]; - set({ tracks: prev, history: history.slice(0, -1) }); + set({ + tracks: prev, + history: history.slice(0, -1), + redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack + }); }, selectClip: (trackId, clipId, multi = false) => { @@ -244,4 +254,11 @@ export const useTimelineStore = create((set, get) => ({ return Math.max(...trackEndTimes, 0); }, + + redo: () => { + const { redoStack } = get(); + if (redoStack.length === 0) return; + const next = redoStack[redoStack.length - 1]; + set({ tracks: next, redoStack: redoStack.slice(0, -1) }); + }, })); From e664ea0271a9c34bb7290447823367ba6fffac7d Mon Sep 17 00:00:00 2001 From: Pulkit Garg Date: Tue, 24 Jun 2025 10:28:58 +0530 Subject: [PATCH 12/12] feat: Enable Playhead Dragging for Video Navigation --- apps/web/package-lock.json | 1 + apps/web/src/components/editor/timeline.tsx | 67 +++++++++++++++++---- apps/web/src/components/ui/video-player.tsx | 4 +- package-lock.json | 6 ++ 4 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 package-lock.json diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 7718a97..4017882 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -45,6 +45,7 @@ "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.1", + "zod": "^3.25.67", "zustand": "^5.0.2" }, "devDependencies": { diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 675b398..e97b9ce 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -27,7 +27,7 @@ import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { processMediaFiles } from "@/lib/media-processing"; import { toast } from "sonner"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { Select, SelectContent, @@ -69,6 +69,10 @@ export function Timeline() { additive: boolean; } | null>(null); + // Playhead scrubbing state + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubTime, setScrubTime] = useState(null); + // Update timeline duration when tracks change useEffect(() => { const totalDuration = getTotalDuration(); @@ -304,6 +308,41 @@ export function Timeline() { setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); }; + // --- Playhead Scrubbing Handlers --- + 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]); + + useEffect(() => { + if (!isScrubbing) return; + const onMouseMove = (e: MouseEvent) => handleScrub(e); + const onMouseUp = (e: MouseEvent) => { + setIsScrubbing(false); + if (scrubTime !== null) seek(scrubTime); // finalize seek + setScrubTime(null); + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [isScrubbing, scrubTime, seek, handleScrub]); + + const playheadPosition = isScrubbing && scrubTime !== null ? scrubTime : currentTime; + const dragProps = { onDragEnter: handleDragEnter, onDragOver: handleDragOver, @@ -555,10 +594,11 @@ export function Timeline() { }).filter(Boolean); })()} - {/* Playhead in ruler */} + {/* Playhead in ruler (scrubbable) */}
@@ -668,14 +708,17 @@ export function Timeline() {
))} - {/* Playhead for tracks area */} -
+ {/* Playhead for tracks area (scrubbable) */} + {tracks.length > 0 && ( +
+ )} )}
diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx index 8731e1a..fdc2ed0 100644 --- a/apps/web/src/components/ui/video-player.tsx +++ b/apps/web/src/components/ui/video-player.tsx @@ -42,7 +42,7 @@ export function VideoPlayer({ if (!video) return; const handleSeekEvent = (e: CustomEvent) => { - if (!isInClipRange) return; + // Always update video time, even if outside clip range const timelineTime = e.detail.time; const newVideoTime = Math.max(trimStart, Math.min( clipDuration - trimEnd, @@ -52,7 +52,7 @@ export function VideoPlayer({ }; const handleUpdateEvent = (e: CustomEvent) => { - if (!isInClipRange) return; + // Always update video time, even if outside clip range const timelineTime = e.detail.time; const targetVideoTime = Math.max(trimStart, Math.min( clipDuration - trimEnd, diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e1f6b63 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Opencut", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}