fix: merge conflicts, merge

This commit is contained in:
Hyteq
2025-06-24 08:22:24 +03:00
9 changed files with 307 additions and 81 deletions

3
.gitignore vendored
View File

@ -19,3 +19,6 @@
# typescript # typescript
/apps/web/next-env.d.ts /apps/web/next-env.d.ts
/apps/web/yarn.lock /apps/web/yarn.lock
# asdf version management
.tool-versions

View File

@ -45,6 +45,7 @@
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.1", "vaul": "^1.1.1",
"zod": "^3.25.67",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,4 @@
/* Prevent scroll jumping on Mac devices when using the editor */
body {
overflow: hidden;
}

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import "./editor.css";
import { import {
ResizablePanelGroup, ResizablePanelGroup,
ResizablePanel, ResizablePanel,

View File

@ -27,7 +27,7 @@ import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import { processMediaFiles } from "@/lib/media-processing"; import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -40,7 +40,7 @@ export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips. // Timeline shows all tracks (video, audio, effects) and their clips.
// You can drag media here to add it to your project. // You can drag media here to add it to your project.
// Clips can be trimmed, deleted, and moved. // Clips can be trimmed, deleted, and moved.
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, 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(); useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore(); const { mediaItems, addMediaItem } = useMediaStore();
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore(); const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore();
@ -69,6 +69,10 @@ export function Timeline() {
additive: boolean; additive: boolean;
} | null>(null); } | null>(null);
// Playhead scrubbing state
const [isScrubbing, setIsScrubbing] = useState(false);
const [scrubTime, setScrubTime] = useState<number | null>(null);
// Update timeline duration when tracks change // Update timeline duration when tracks change
useEffect(() => { useEffect(() => {
const totalDuration = getTotalDuration(); const totalDuration = getTotalDuration();
@ -98,6 +102,33 @@ export function Timeline() {
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedClips, removeClipFromTrack, clearSelectedClips]); }, [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 // Mouse down on timeline background to start marquee
const handleTimelineMouseDown = (e: React.MouseEvent) => { const handleTimelineMouseDown = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && e.button === 0) { 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) => { 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) { if (e.target === e.currentTarget) {
clearSelectedClips(); clearSelectedClips();
// Calculate the clicked time position and seek to it
handleSeekToPosition(e);
} }
}; };
const handleWheel = (e: React.WheelEvent) => { const handleWheel = (e: React.WheelEvent) => {
e.preventDefault(); // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
const delta = e.deltaY > 0 ? -0.05 : 0.05; if (e.ctrlKey || e.metaKey) {
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); 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 = { const dragProps = {
onDragEnter: handleDragEnter, onDragEnter: handleDragEnter,
onDragOver: handleDragOver, onDragOver: handleDragOver,
@ -311,6 +391,92 @@ export function Timeline() {
onDrop: handleDrop, 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 ( return (
<div <div
className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`} 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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" /> <Scissors className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -419,7 +585,7 @@ export function Timeline() {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" onClick={handleDuplicateSelected}>
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -428,7 +594,7 @@ export function Timeline() {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" /> <Snowflake className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -437,7 +603,7 @@ export function Timeline() {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
@ -487,10 +653,14 @@ export function Timeline() {
<div className="flex-1 relative overflow-hidden"> <div className="flex-1 relative overflow-hidden">
<ScrollArea className="w-full"> <ScrollArea className="w-full">
<div <div
className="relative h-12 bg-muted/30" className="relative h-12 bg-muted/30 cursor-pointer"
style={{ style={{
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`, width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
}} }}
onClick={(e) => {
// Calculate the clicked time position and seek to it
handleSeekToPosition(e);
}}
> >
{/* Time markers */} {/* Time markers */}
{(() => { {(() => {
@ -555,28 +725,11 @@ export function Timeline() {
}).filter(Boolean); }).filter(Boolean);
})()} })()}
{/* Playhead in ruler */} {/* Playhead in ruler (scrubbable) */}
<div <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 className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-10 cursor-ew-resize"
style={{ left: `${currentTime * 50 * zoomLevel}px` }} style={{ left: `${playheadPosition * 50 * zoomLevel}px` }}
onMouseDown={(e) => { onMouseDown={handlePlayheadMouseDown}
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
}}
> >
<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 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> </div>
@ -631,15 +784,14 @@ export function Timeline() {
</div> </div>
{/* Timeline Tracks Content */} {/* Timeline Tracks Content */}
<div className="flex-1 relative"> <div className="flex-1 relative overflow-hidden">
<ScrollArea className="h-full w-full"> <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 <div
ref={timelineRef} className="relative flex-1"
className="relative cursor-pointer select-none"
style={{ style={{
height: `${tracks.length * 60}px`,
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`, width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
minHeight:
tracks.length > 0 ? `${tracks.length * 60}px` : "200px",
}} }}
onClick={handleTimelineAreaClick} onClick={handleTimelineAreaClick}
onMouseDown={handleTimelineMouseDown} onMouseDown={handleTimelineMouseDown}
@ -682,22 +834,24 @@ export function Timeline() {
zoomLevel={zoomLevel} zoomLevel={zoomLevel}
setContextMenu={setContextMenu} setContextMenu={setContextMenu}
/> />
</div> </div>
))} ))}
{/* Playhead for tracks area */} {/* Playhead for tracks area (scrubbable) */}
<div {tracks.length > 0 && (
className="absolute top-0 w-0.5 bg-red-500 cursor-ew-resize z-20" <div
style={{ className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-20 cursor-ew-resize"
left: `${currentTime * 50 * zoomLevel}px`, style={{
height: `${tracks.length * 60}px`, left: `${playheadPosition * 50 * zoomLevel}px`,
}} height: `${tracks.length * 60}px`,
/> }}
onMouseDown={handlePlayheadMouseDown}
/>
)}
</> </>
)} )}
</div> </div>
</ScrollArea> </div>
</div> </div>
</div> </div>
</div> </div>
@ -763,7 +917,7 @@ export function Timeline() {
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) { if (splitTime > effectiveStart && splitTime < effectiveEnd) {
useTimelineStore.getState().updateClipTrim( updateClipTrim(
track.id, track.id,
clip.id, clip.id,
clip.trimStart, clip.trimStart,
@ -1427,9 +1581,18 @@ function TimelineTrackContent({
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }} style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const isSelected = selectedClips.some(
(c) => c.trackId === track.id && c.clipId === clip.id
);
if (e.metaKey || e.ctrlKey || e.shiftKey) { if (e.metaKey || e.ctrlKey || e.shiftKey) {
// Multi-selection mode: toggle the clip
selectClip(track.id, clip.id, true); selectClip(track.id, clip.id, true);
} else if (isSelected) {
// If clip is already selected, deselect it
deselectClip(track.id, clip.id);
} else { } else {
// If clip is not selected, select it (replacing other selections)
selectClip(track.id, clip.id, false); selectClip(track.id, clip.id, false);
} }
}} }}
@ -1502,8 +1665,7 @@ function TimelineTrackContent({
{/* Drop position indicator */} {/* Drop position indicator */}
{isDraggedOver && dropPosition !== null && ( {isDraggedOver && dropPosition !== null && (
<div <div
className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${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={{ style={{
left: `${dropPosition * 50 * zoomLevel}px`, left: `${dropPosition * 50 * zoomLevel}px`,
transform: "translateX(-50%)", transform: "translateX(-50%)",

View File

@ -82,7 +82,7 @@ export function Hero({ signupCount }: HeroProps) {
}; };
return ( 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 <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}

View File

@ -34,7 +34,8 @@ export function VideoPlayer({
const video = videoRef.current; const video = videoRef.current;
if (!video || !isInClipRange) return; 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 timelineTime = e.detail.time;
const videoTime = Math.max(trimStart, Math.min( const videoTime = Math.max(trimStart, Math.min(
clipDuration - trimEnd, clipDuration - trimEnd,
@ -43,7 +44,8 @@ export function VideoPlayer({
video.currentTime = videoTime; 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 timelineTime = e.detail.time;
const targetTime = Math.max(trimStart, Math.min( const targetTime = Math.max(trimStart, Math.min(
clipDuration - trimEnd, clipDuration - trimEnd,
@ -59,13 +61,13 @@ export function VideoPlayer({
video.playbackRate = e.detail.speed; video.playbackRate = e.detail.speed;
}; };
window.addEventListener("playback-seek", handleSeek as EventListener); window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener("playback-update", handleUpdate as EventListener); window.addEventListener("playback-update", handleUpdateEvent as EventListener);
window.addEventListener("playback-speed", handleSpeed as EventListener); window.addEventListener("playback-speed", handleSpeed as EventListener);
return () => { return () => {
window.removeEventListener("playback-seek", handleSeek as EventListener); window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
window.removeEventListener("playback-update", handleUpdate as EventListener); window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
window.removeEventListener("playback-speed", handleSpeed as EventListener); window.removeEventListener("playback-speed", handleSpeed as EventListener);
}; };
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]); }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);

View File

@ -20,6 +20,8 @@ export interface TimelineTrack {
interface TimelineStore { interface TimelineStore {
tracks: TimelineTrack[]; tracks: TimelineTrack[];
history: TimelineTrack[][];
redoStack: TimelineTrack[][];
// Multi-selection // Multi-selection
selectedClips: { trackId: string; clipId: string }[]; selectedClips: { trackId: string; clipId: string }[];
@ -53,12 +55,39 @@ interface TimelineStore {
// Computed values // Computed values
getTotalDuration: () => number; getTotalDuration: () => number;
// New actions
undo: () => void;
redo: () => void;
pushHistory: () => void;
} }
export const useTimelineStore = create<TimelineStore>((set, get) => ({ export const useTimelineStore = create<TimelineStore>((set, get) => ({
tracks: [], tracks: [],
history: [],
redoStack: [],
selectedClips: [], 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) => { selectClip: (trackId, clipId, multi = false) => {
set((state) => { set((state) => {
const exists = state.selectedClips.some( const exists = state.selectedClips.some(
@ -86,6 +115,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
setSelectedClips: (clips) => set({ selectedClips: clips }), setSelectedClips: (clips) => set({ selectedClips: clips }),
addTrack: (type) => { addTrack: (type) => {
get().pushHistory();
const newTrack: TimelineTrack = { const newTrack: TimelineTrack = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
@ -100,12 +130,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}, },
removeTrack: (trackId) => { removeTrack: (trackId) => {
get().pushHistory();
set((state) => ({ set((state) => ({
tracks: state.tracks.filter((track) => track.id !== trackId), tracks: state.tracks.filter((track) => track.id !== trackId),
})); }));
}, },
addClipToTrack: (trackId, clipData) => { addClipToTrack: (trackId, clipData) => {
get().pushHistory();
const newClip: TimelineClip = { const newClip: TimelineClip = {
...clipData, ...clipData,
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -124,19 +156,21 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}, },
removeClipFromTrack: (trackId, clipId) => { removeClipFromTrack: (trackId, clipId) => {
get().pushHistory();
set((state) => ({ set((state) => ({
tracks: state.tracks.map((track) => tracks: state.tracks
track.id === trackId .map((track) =>
? { track.id === trackId
...track, ? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
clips: track.clips.filter((clip) => clip.id !== clipId), : track
} )
: track // Remove track if it becomes empty
), .filter((track) => track.clips.length > 0),
})); }));
}, },
moveClipToTrack: (fromTrackId, toTrackId, clipId) => { moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
get().pushHistory();
set((state) => { set((state) => {
const fromTrack = state.tracks.find((track) => track.id === fromTrackId); const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
@ -144,25 +178,29 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
if (!clipToMove) return state; if (!clipToMove) return state;
return { return {
tracks: state.tracks.map((track) => { tracks: state.tracks
if (track.id === fromTrackId) { .map((track) => {
return { if (track.id === fromTrackId) {
...track, return {
clips: track.clips.filter((clip) => clip.id !== clipId), ...track,
}; clips: track.clips.filter((clip) => clip.id !== clipId),
} else if (track.id === toTrackId) { };
return { } else if (track.id === toTrackId) {
...track, return {
clips: [...track.clips, clipToMove], ...track,
}; clips: [...track.clips, clipToMove],
} };
return track; }
}), return track;
})
// Remove track if it becomes empty
.filter((track) => track.clips.length > 0),
}; };
}); });
}, },
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => { updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
get().pushHistory();
set((state) => ({ set((state) => ({
tracks: state.tracks.map((track) => tracks: state.tracks.map((track) =>
track.id === trackId track.id === trackId
@ -178,6 +216,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}, },
updateClipStartTime: (trackId, clipId, startTime) => { updateClipStartTime: (trackId, clipId, startTime) => {
get().pushHistory();
set((state) => ({ set((state) => ({
tracks: state.tracks.map((track) => tracks: state.tracks.map((track) =>
track.id === trackId track.id === trackId
@ -193,6 +232,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
}, },
toggleTrackMute: (trackId) => { toggleTrackMute: (trackId) => {
get().pushHistory();
set((state) => ({ set((state) => ({
tracks: state.tracks.map((track) => tracks: state.tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : 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); 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) });
},
})); }));

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "OpenCut",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}