diff options
| author | Hyteq <DerpsDer.es@gmail.com> | 2025-06-24 08:22:24 +0300 |
|---|---|---|
| committer | Hyteq <DerpsDer.es@gmail.com> | 2025-06-24 08:22:24 +0300 |
| commit | 65bff0d2da7ca32436bb5c081c1d4a83d539f11c (patch) | |
| tree | 410616249a476df8d9e6bbbd5f616d3963b9bbad | |
| parent | e29e0892ac92402dc6258aed2d0bc7e8cc5085b7 (diff) | |
| parent | 612d0d8c15a34aefa4995c96523bbf1e9411a6da (diff) | |
| download | opencut-65bff0d2da7ca32436bb5c081c1d4a83d539f11c.tar.gz | |
fix: merge conflicts, merge
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | apps/web/package-lock.json | 1 | ||||
| -rw-r--r-- | apps/web/src/app/editor/editor.css | 4 | ||||
| -rw-r--r-- | apps/web/src/app/editor/page.tsx | 1 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 266 | ||||
| -rw-r--r-- | apps/web/src/components/landing/hero.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/components/ui/video-player.tsx | 14 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 91 | ||||
| -rw-r--r-- | package-lock.json | 6 |
9 files changed, 307 insertions, 81 deletions
@@ -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/package-lock.json b/apps/web/package-lock.json index 7718a971..40178823 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/app/editor/editor.css b/apps/web/src/app/editor/editor.css new file mode 100644 index 00000000..f482c6de --- /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 19df364c..e0013937 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 9af7a204..ce81f9e3 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,
@@ -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, undo, redo } =
useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore();
@@ -69,6 +69,10 @@ export function Timeline() { additive: boolean;
} | null>(null);
+ // Playhead scrubbing state
+ const [isScrubbing, setIsScrubbing] = useState(false);
+ const [scrubTime, setScrubTime] = useState<number | null>(null);
+
// Update timeline duration when tracks change
useEffect(() => {
const totalDuration = getTotalDuration();
@@ -98,6 +102,33 @@ 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]);
+
+ // 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) {
@@ -290,20 +321,69 @@ export function Timeline() { }
};
- // Deselect all clips when clicking empty timeline area
+ 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
+ handleSeekToPosition(e);
}
};
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
};
+ // --- 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,
@@ -311,6 +391,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 (
<div
className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`}
@@ -383,7 +549,7 @@ export function Timeline() { <Tooltip>
<TooltipTrigger asChild>
- <Button variant="ghost" size="icon">
+ <Button variant="ghost" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
@@ -419,7 +585,7 @@ export function Timeline() { <Tooltip>
<TooltipTrigger asChild>
- <Button variant="ghost" size="icon">
+ <Button variant="ghost" size="icon" onClick={handleDuplicateSelected}>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
@@ -428,7 +594,7 @@ export function Timeline() { <Tooltip>
<TooltipTrigger asChild>
- <Button variant="ghost" size="icon">
+ <Button variant="ghost" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" />
</Button>
</TooltipTrigger>
@@ -437,7 +603,7 @@ export function Timeline() { <Tooltip>
<TooltipTrigger asChild>
- <Button variant="ghost" size="icon">
+ <Button variant="ghost" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
@@ -487,10 +653,14 @@ export function Timeline() { <div className="flex-1 relative overflow-hidden">
<ScrollArea className="w-full">
<div
- className="relative h-12 bg-muted/30"
+ className="relative h-12 bg-muted/30 cursor-pointer"
style={{
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
}}
+ onClick={(e) => {
+ // Calculate the clicked time position and seek to it
+ handleSeekToPosition(e);
+ }}
>
{/* Time markers */}
{(() => {
@@ -555,28 +725,11 @@ export function Timeline() { }).filter(Boolean);
})()}
- {/* Playhead in ruler */}
+ {/* Playhead in ruler (scrubbable) */}
<div
- className="absolute top-0 bottom-0 w-0.5 bg-red-500 cursor-ew-resize z-1000" // Remove pointer-events-none to allow dragging
- style={{ left: `${currentTime * 50 * zoomLevel}px` }}
- onMouseDown={(e) => {
- e.preventDefault();
- e.stopPropagation();
- const handleMouseMove = (e: MouseEvent) => {
- const timeline = timelineRef.current; // Get timeline element ref to track the position
- if (!timeline) return; // If no timeline element, exit
- const rect = timeline.getBoundingClientRect(); // Get the bounding rect of the timeline element
- const mouseX = Math.max(0, e.clientX - rect.left); // Calculate the mouse position relative to the timeline element
- const newTime = mouseX / (50 * zoomLevel); // Calculate the time based on the mouse position
- seek(newTime); // Set the current time
- };
- const handleMouseUp = () => {
- window.removeEventListener("mousemove", handleMouseMove); // Remove the mousemove event listener
- window.removeEventListener("mouseup", handleMouseUp); // Remove the mouseup event listener
- };
- window.addEventListener("mousemove", handleMouseMove); // Add the mousemove event listener
- window.addEventListener("mouseup", handleMouseUp); // Add the mouseup event listener
- }}
+ className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-10 cursor-ew-resize"
+ style={{ left: `${playheadPosition * 50 * zoomLevel}px` }}
+ onMouseDown={handlePlayheadMouseDown}
>
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
</div>
@@ -631,15 +784,14 @@ export function Timeline() { </div>
{/* Timeline Tracks Content */}
- <div className="flex-1 relative">
- <ScrollArea className="h-full w-full">
+ <div className="flex-1 relative overflow-hidden">
+ <div className="w-full h-[600px] overflow-hidden flex" ref={timelineRef} style={{ position: 'relative' }}>
+ {/* Timeline grid and clips area (with left margin for sidebar) */}
<div
- ref={timelineRef}
- className="relative cursor-pointer select-none"
+ className="relative flex-1"
style={{
+ height: `${tracks.length * 60}px`,
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
- minHeight:
- tracks.length > 0 ? `${tracks.length * 60}px` : "200px",
}}
onClick={handleTimelineAreaClick}
onMouseDown={handleTimelineMouseDown}
@@ -682,22 +834,24 @@ export function Timeline() { zoomLevel={zoomLevel}
setContextMenu={setContextMenu}
/>
-
</div>
))}
- {/* Playhead for tracks area */}
- <div
- className="absolute top-0 w-0.5 bg-red-500 cursor-ew-resize z-20"
- style={{
- left: `${currentTime * 50 * zoomLevel}px`,
- height: `${tracks.length * 60}px`,
- }}
- />
+ {/* Playhead for tracks area (scrubbable) */}
+ {tracks.length > 0 && (
+ <div
+ className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-20 cursor-ew-resize"
+ style={{
+ left: `${playheadPosition * 50 * zoomLevel}px`,
+ height: `${tracks.length * 60}px`,
+ }}
+ onMouseDown={handlePlayheadMouseDown}
+ />
+ )}
</>
)}
</div>
- </ScrollArea>
+ </div>
</div>
</div>
</div>
@@ -763,7 +917,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,
@@ -1427,9 +1581,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);
}
}}
@@ -1502,8 +1665,7 @@ function TimelineTrackContent({ {/* Drop position indicator */}
{isDraggedOver && dropPosition !== null && (
<div
- 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"
- }`}
+ 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"}`}
style={{
left: `${dropPosition * 50 * zoomLevel}px`,
transform: "translateX(-50%)",
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 3fa959b5..7b2586c3 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -82,7 +82,7 @@ export function Hero({ signupCount }: HeroProps) { };
return (
- <div className="relative min-h-screen flex flex-col items-center justify-center text-center px-4">
+ <div className="relative min-h-[calc(100vh-4rem)] flex flex-col items-center justify-center text-center px-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx index f489f70a..32a12916 100644 --- a/apps/web/src/components/ui/video-player.tsx +++ b/apps/web/src/components/ui/video-player.tsx @@ -34,7 +34,8 @@ export function VideoPlayer({ const video = videoRef.current; if (!video || !isInClipRange) return; - const handleSeek = (e: CustomEvent) => { + const handleSeekEvent = (e: CustomEvent) => { + // Always update video time, even if outside clip range const timelineTime = e.detail.time; const videoTime = Math.max(trimStart, Math.min( clipDuration - trimEnd, @@ -43,7 +44,8 @@ export function VideoPlayer({ video.currentTime = videoTime; }; - const handleUpdate = (e: CustomEvent) => { + const handleUpdateEvent = (e: CustomEvent) => { + // Always update video time, even if outside clip range const timelineTime = e.detail.time; const targetTime = Math.max(trimStart, Math.min( clipDuration - trimEnd, @@ -59,13 +61,13 @@ export function VideoPlayer({ video.playbackRate = e.detail.speed; }; - window.addEventListener("playback-seek", handleSeek as EventListener); - window.addEventListener("playback-update", handleUpdate as EventListener); + window.addEventListener("playback-seek", handleSeekEvent as EventListener); + window.addEventListener("playback-update", handleUpdateEvent as EventListener); window.addEventListener("playback-speed", handleSpeed as EventListener); return () => { - window.removeEventListener("playback-seek", handleSeek as EventListener); - window.removeEventListener("playback-update", handleUpdate as EventListener); + window.removeEventListener("playback-seek", handleSeekEvent as EventListener); + window.removeEventListener("playback-update", handleUpdateEvent as EventListener); window.removeEventListener("playback-speed", handleSpeed as EventListener); }; }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 4b55a53d..e1474bae 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -20,6 +20,8 @@ export interface TimelineTrack { interface TimelineStore {
tracks: TimelineTrack[];
+ history: TimelineTrack[][];
+ redoStack: TimelineTrack[][];
// Multi-selection
selectedClips: { trackId: string; clipId: string }[];
@@ -53,12 +55,39 @@ interface TimelineStore { // Computed values
getTotalDuration: () => number;
+
+ // New actions
+ undo: () => void;
+ redo: () => void;
+ pushHistory: () => void;
}
export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: [],
+ history: [],
+ redoStack: [],
selectedClips: [],
+ pushHistory: () => {
+ const { tracks, history, redoStack } = get();
+ // Deep copy tracks
+ set({
+ history: [...history, JSON.parse(JSON.stringify(tracks))],
+ redoStack: [] // Clear redo stack when new action is performed
+ });
+ },
+
+ undo: () => {
+ const { history, redoStack, tracks } = get();
+ if (history.length === 0) return;
+ const prev = history[history.length - 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) => {
set((state) => {
const exists = state.selectedClips.some(
@@ -86,6 +115,7 @@ export const useTimelineStore = create<TimelineStore>((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 +130,14 @@ export const useTimelineStore = create<TimelineStore>((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 +156,21 @@ export const useTimelineStore = create<TimelineStore>((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 +178,29 @@ export const useTimelineStore = create<TimelineStore>((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 +216,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ },
updateClipStartTime: (trackId, clipId, startTime) => {
+ get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
@@ -193,6 +232,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ },
toggleTrackMute: (trackId) => {
+ get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track
@@ -214,4 +254,11 @@ export const useTimelineStore = create<TimelineStore>((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) });
+ },
}));
diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..9891ac9f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "OpenCut", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} |
