From e225272ec3ad86781e2664e10de8d5e2af83a398 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Thu, 26 Jun 2025 00:37:35 +0200 Subject: fix: some timeline issues --- apps/web/src/stores/timeline-store.ts | 610 +++++++++++++++++++--------------- 1 file changed, 346 insertions(+), 264 deletions(-) (limited to 'apps/web/src/stores') diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index e1474bae..3b068d7c 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,264 +1,346 @@ -import { create } from "zustand"; - -export interface TimelineClip { - id: string; - mediaId: string; - name: string; - duration: number; - startTime: number; - trimStart: number; - trimEnd: number; -} - -export interface TimelineTrack { - id: string; - name: string; - type: "video" | "audio" | "effects"; - clips: TimelineClip[]; - muted?: boolean; -} - -interface TimelineStore { - tracks: TimelineTrack[]; - history: TimelineTrack[][]; - redoStack: TimelineTrack[][]; - - // Multi-selection - selectedClips: { trackId: string; clipId: string }[]; - selectClip: (trackId: string, clipId: string, multi?: boolean) => void; - deselectClip: (trackId: string, clipId: string) => void; - clearSelectedClips: () => void; - setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void; - - // Actions - addTrack: (type: "video" | "audio" | "effects") => string; - removeTrack: (trackId: string) => void; - addClipToTrack: (trackId: string, clip: Omit) => void; - removeClipFromTrack: (trackId: string, clipId: string) => void; - moveClipToTrack: ( - fromTrackId: string, - toTrackId: string, - clipId: string - ) => void; - updateClipTrim: ( - trackId: string, - clipId: string, - trimStart: number, - trimEnd: number - ) => void; - updateClipStartTime: ( - trackId: string, - clipId: string, - startTime: number - ) => void; - toggleTrackMute: (trackId: string) => void; - - // Computed values - getTotalDuration: () => number; - - // New actions - undo: () => void; - redo: () => void; - pushHistory: () => void; -} - -export const useTimelineStore = create((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( - (c) => c.trackId === trackId && c.clipId === clipId - ); - if (multi) { - // Toggle selection - return exists - ? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) } - : { selectedClips: [...state.selectedClips, { trackId, clipId }] }; - } else { - return { selectedClips: [{ trackId, clipId }] }; - } - }); - }, - deselectClip: (trackId, clipId) => { - set((state) => ({ - selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)), - })); - }, - clearSelectedClips: () => { - set({ selectedClips: [] }); - }, - - setSelectedClips: (clips) => set({ selectedClips: clips }), - - addTrack: (type) => { - get().pushHistory(); - const newTrack: TimelineTrack = { - id: crypto.randomUUID(), - name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, - type, - clips: [], - muted: false, - }; - set((state) => ({ - tracks: [...state.tracks, newTrack], - })); - return newTrack.id; - }, - - 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(), - startTime: clipData.startTime || 0, - trimStart: 0, - trimEnd: 0, - }; - - set((state) => ({ - tracks: state.tracks.map((track) => - track.id === trackId - ? { ...track, clips: [...track.clips, newClip] } - : track - ), - })); - }, - - 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 - ) - // 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); - - 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; - }) - // 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 - ? { - ...track, - clips: track.clips.map((clip) => - clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip - ), - } - : track - ), - })); - }, - - updateClipStartTime: (trackId, clipId, startTime) => { - get().pushHistory(); - set((state) => ({ - tracks: state.tracks.map((track) => - track.id === trackId - ? { - ...track, - clips: track.clips.map((clip) => - clip.id === clipId ? { ...clip, startTime } : clip - ), - } - : track - ), - })); - }, - - toggleTrackMute: (trackId) => { - get().pushHistory(); - set((state) => ({ - tracks: state.tracks.map((track) => - track.id === trackId ? { ...track, muted: !track.muted } : track - ), - })); - }, - - getTotalDuration: () => { - const { tracks } = get(); - if (tracks.length === 0) return 0; - - const trackEndTimes = tracks.map((track) => - track.clips.reduce((maxEnd, clip) => { - const clipEnd = - clip.startTime + clip.duration - clip.trimStart - clip.trimEnd; - return Math.max(maxEnd, clipEnd); - }, 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) }); - }, -})); +import { create } from "zustand"; + +export interface TimelineClip { + id: string; + mediaId: string; + name: string; + duration: number; + startTime: number; + trimStart: number; + trimEnd: number; +} + +export interface TimelineTrack { + id: string; + name: string; + type: "video" | "audio" | "effects"; + clips: TimelineClip[]; + muted?: boolean; +} + +interface TimelineStore { + tracks: TimelineTrack[]; + history: TimelineTrack[][]; + redoStack: TimelineTrack[][]; + + // Multi-selection + selectedClips: { trackId: string; clipId: string }[]; + selectClip: (trackId: string, clipId: string, multi?: boolean) => void; + deselectClip: (trackId: string, clipId: string) => void; + clearSelectedClips: () => void; + setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void; + + // Drag state + dragState: { + isDragging: boolean; + clipId: string | null; + trackId: string | null; + startMouseX: number; + startClipTime: number; + clickOffsetTime: number; + currentTime: number; + }; + setDragState: (dragState: Partial) => void; + startDrag: ( + clipId: string, + trackId: string, + startMouseX: number, + startClipTime: number, + clickOffsetTime: number + ) => void; + updateDragTime: (currentTime: number) => void; + endDrag: () => void; + + // Actions + addTrack: (type: "video" | "audio" | "effects") => string; + removeTrack: (trackId: string) => void; + addClipToTrack: (trackId: string, clip: Omit) => void; + removeClipFromTrack: (trackId: string, clipId: string) => void; + moveClipToTrack: ( + fromTrackId: string, + toTrackId: string, + clipId: string + ) => void; + updateClipTrim: ( + trackId: string, + clipId: string, + trimStart: number, + trimEnd: number + ) => void; + updateClipStartTime: ( + trackId: string, + clipId: string, + startTime: number + ) => void; + toggleTrackMute: (trackId: string) => void; + + // Computed values + getTotalDuration: () => number; + + // New actions + undo: () => void; + redo: () => void; + pushHistory: () => void; +} + +export const useTimelineStore = create((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( + (c) => c.trackId === trackId && c.clipId === clipId + ); + if (multi) { + // Toggle selection + return exists + ? { + selectedClips: state.selectedClips.filter( + (c) => !(c.trackId === trackId && c.clipId === clipId) + ), + } + : { selectedClips: [...state.selectedClips, { trackId, clipId }] }; + } else { + return { selectedClips: [{ trackId, clipId }] }; + } + }); + }, + deselectClip: (trackId, clipId) => { + set((state) => ({ + selectedClips: state.selectedClips.filter( + (c) => !(c.trackId === trackId && c.clipId === clipId) + ), + })); + }, + clearSelectedClips: () => { + set({ selectedClips: [] }); + }, + + setSelectedClips: (clips) => set({ selectedClips: clips }), + + addTrack: (type) => { + get().pushHistory(); + const newTrack: TimelineTrack = { + id: crypto.randomUUID(), + name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, + type, + clips: [], + muted: false, + }; + set((state) => ({ + tracks: [...state.tracks, newTrack], + })); + return newTrack.id; + }, + + 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(), + startTime: clipData.startTime || 0, + trimStart: 0, + trimEnd: 0, + }; + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { ...track, clips: [...track.clips, newClip] } + : track + ), + })); + }, + + 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 + ) + // 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); + + 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; + }) + // 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 + ? { + ...track, + clips: track.clips.map((clip) => + clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip + ), + } + : track + ), + })); + }, + + updateClipStartTime: (trackId, clipId, startTime) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.map((clip) => + clip.id === clipId ? { ...clip, startTime } : clip + ), + } + : track + ), + })); + }, + + toggleTrackMute: (trackId) => { + get().pushHistory(); + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId ? { ...track, muted: !track.muted } : track + ), + })); + }, + + getTotalDuration: () => { + const { tracks } = get(); + if (tracks.length === 0) return 0; + + const trackEndTimes = tracks.map((track) => + track.clips.reduce((maxEnd, clip) => { + const clipEnd = + clip.startTime + clip.duration - clip.trimStart - clip.trimEnd; + return Math.max(maxEnd, clipEnd); + }, 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) }); + }, + + dragState: { + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }, + + setDragState: (dragState) => + set((state) => ({ + dragState: { ...state.dragState, ...dragState }, + })), + + startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => { + set({ + dragState: { + isDragging: true, + clipId, + trackId, + startMouseX, + startClipTime, + clickOffsetTime, + currentTime: startClipTime, + }, + }); + }, + + updateDragTime: (currentTime) => { + set((state) => ({ + dragState: { + ...state.dragState, + currentTime, + }, + })); + }, + + endDrag: () => { + set({ + dragState: { + isDragging: false, + clipId: null, + trackId: null, + startMouseX: 0, + startClipTime: 0, + clickOffsetTime: 0, + currentTime: 0, + }, + }); + }, +})); -- cgit v1.3.1