From 350b55081cde3a4332c982ea5c53b7d359029f6b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 16:17:06 +0200 Subject: refactor(core): switch to scene-based architecture --- apps/web/src/stores/timeline-store.ts | 76 ++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 10 deletions(-) (limited to 'apps/web/src/stores/timeline-store.ts') diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index f0bae00c..8caa71a7 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -15,6 +15,7 @@ import { MediaFile } from "@/types/media"; import { findBestCanvasPreset } from "@/lib/editor-utils"; import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; +import { useSceneStore } from "./scene-store"; import { generateUUID } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; @@ -178,8 +179,20 @@ interface TimelineStore { pushHistory: () => void; // Persistence actions - loadProjectTimeline: (projectId: string) => Promise; - saveProjectTimeline: (projectId: string) => Promise; + loadProjectTimeline: ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => Promise; + saveProjectTimeline: ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => Promise; clearTimeline: () => void; // Clipboard actions @@ -235,9 +248,15 @@ export const useTimelineStore = create((set, get) => { // Helper to auto-save timeline changes const autoSaveTimeline = async () => { const activeProject = useProjectStore.getState().activeProject; - if (activeProject) { + const currentScene = useSceneStore.getState().currentScene; + + if (activeProject && currentScene) { try { - await storageService.saveTimeline(activeProject.id, get()._tracks); + await storageService.saveTimeline({ + projectId: activeProject.id, + tracks: get()._tracks, + sceneId: currentScene.id, + }); } catch (error) { console.error("Failed to auto-save timeline:", error); } @@ -1229,8 +1248,24 @@ export const useTimelineStore = create((set, get) => { getProjectThumbnail: async (projectId) => { try { - const tracks = await storageService.loadTimeline(projectId); - const mediaItems = await storageService.loadAllMediaFiles(projectId); + const project = await storageService.loadProject({ id: projectId }); + if (!project) return null; + + // For scene-based projects, use main scene timeline + // For legacy projects, use legacy timeline format + let sceneId: string | undefined; + if (project.scenes && project.scenes.length > 0) { + const mainScene = project.scenes.find((s) => s.isMain); + sceneId = mainScene?.id; + } + + const tracks = await storageService.loadTimeline({ + projectId, + sceneId, + }); + const mediaItems = await storageService.loadAllMediaFiles({ + projectId, + }); if (!tracks || !mediaItems.length) return null; @@ -1330,9 +1365,19 @@ export const useTimelineStore = create((set, get) => { }); }, - loadProjectTimeline: async (projectId) => { + loadProjectTimeline: async ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => { try { - const tracks = await storageService.loadTimeline(projectId); + const tracks = await storageService.loadTimeline({ + projectId, + sceneId, + }); + if (tracks) { updateTracks(tracks); } else { @@ -1348,9 +1393,20 @@ export const useTimelineStore = create((set, get) => { } }, - saveProjectTimeline: async (projectId) => { + saveProjectTimeline: async ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => { + const { _tracks } = get(); try { - await storageService.saveTimeline(projectId, get()._tracks); + await storageService.saveTimeline({ + projectId, + tracks: _tracks, + sceneId, + }); } catch (error) { console.error("Failed to save timeline:", error); } -- cgit v1.3.1 From af1964cf113bc9cbebc59a94631f8d08506923d2 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 17:18:00 +0200 Subject: fix: scenes now load properly --- .../providers/migrators/scenes-migrator.tsx | 358 +++++++-------- apps/web/src/lib/storage/storage-service.ts | 5 +- apps/web/src/stores/project-store.ts | 15 +- apps/web/src/stores/scene-store.ts | 486 +++++++++++---------- apps/web/src/stores/timeline-store.ts | 9 + apps/web/src/types/project.ts | 1 + 6 files changed, 479 insertions(+), 395 deletions(-) (limited to 'apps/web/src/stores/timeline-store.ts') diff --git a/apps/web/src/components/providers/migrators/scenes-migrator.tsx b/apps/web/src/components/providers/migrators/scenes-migrator.tsx index 3a21faa4..521e24af 100644 --- a/apps/web/src/components/providers/migrators/scenes-migrator.tsx +++ b/apps/web/src/components/providers/migrators/scenes-migrator.tsx @@ -1,171 +1,187 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { usePathname } from "next/navigation"; -import { storageService } from "@/lib/storage/storage-service"; -import { TProject, Scene } from "@/types/project"; -import { generateUUID } from "@/lib/utils"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Progress } from "@/components/ui/progress"; -import { createMainScene } from "@/stores/project-store"; - -interface MigrationProgress { - current: number; - total: number; - currentProjectName: string; -} - -export function ScenesMigrator({ children }: { children: React.ReactNode }) { - const pathname = usePathname(); - const [isMigrating, setIsMigrating] = useState(false); - const [progress, setProgress] = useState({ - current: 0, - total: 0, - currentProjectName: "", - }); - - const shouldCheckMigration = - pathname.startsWith("/editor") || pathname.startsWith("/projects"); - - useEffect(() => { - if (!shouldCheckMigration) return; - - checkAndMigrateProjects(); - }, [shouldCheckMigration]); - - const checkAndMigrateProjects = async () => { - try { - const projects = await storageService.loadAllProjects(); - const legacyProjects = projects.filter( - (project) => !project.scenes || project.scenes.length === 0 - ); - - if (legacyProjects.length === 0) { - // No migration needed - return; - } - - setIsMigrating(true); - setProgress({ - current: 0, - total: legacyProjects.length, - currentProjectName: "", - }); - - // Migrate each legacy project - for (let i = 0; i < legacyProjects.length; i++) { - const project = legacyProjects[i]; - - setProgress({ - current: i, - total: legacyProjects.length, - currentProjectName: project.name, - }); - - await migrateLegacyProject(project); - } - - setProgress({ - current: legacyProjects.length, - total: legacyProjects.length, - currentProjectName: "Complete!", - }); - - setTimeout(() => { - setIsMigrating(false); - }, 1000); - } catch (error) { - console.error("Migration failed:", error); - setIsMigrating(false); - } - }; - - const migrateLegacyProject = async (project: TProject) => { - try { - const mainScene = createMainScene(); - - const migratedProject: TProject = { - ...project, - scenes: [mainScene], - currentSceneId: mainScene.id, - updatedAt: new Date(), - }; - - // Load existing timeline data (legacy format) - const legacyTimeline = await storageService.loadTimeline({ - projectId: project.id, - }); - - await storageService.saveProject({ project: migratedProject }); - - // If timeline data, migrate it to the main scene - if (legacyTimeline && legacyTimeline.length > 0) { - await storageService.saveTimeline({ - projectId: project.id, - tracks: legacyTimeline, - sceneId: mainScene.id, - }); - } - - // Clean up legacy timeline storage - await storageService.deleteProjectTimeline({ projectId: project.id }); - } catch (error) { - console.error(`Failed to migrate project ${project.name}:`, error); - throw error; - } - }; - - if (!shouldCheckMigration) { - return children; - } - - if (isMigrating) { - const progressPercent = - progress.total > 0 ? (progress.current / progress.total) * 100 : 0; - - return ( - - e.preventDefault()} - > - - Updating Projects - - We're adding scene support to your projects. This will only take a - moment. - - - -
-
-
- Progress - - {progress.current} of {progress.total} - -
- -
- - {progress.currentProjectName && ( -
- {progress.current < progress.total - ? `Updating: ${progress.currentProjectName}` - : progress.currentProjectName} -
- )} -
-
-
- ); - } - - return children; -} +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { storageService } from "@/lib/storage/storage-service"; +import { TProject, Scene } from "@/types/project"; +import { generateUUID } from "@/lib/utils"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Progress } from "@/components/ui/progress"; +import { createMainScene } from "@/stores/project-store"; + +interface MigrationProgress { + current: number; + total: number; + currentProjectName: string; +} + +export function ScenesMigrator({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const [isMigrating, setIsMigrating] = useState(false); + const [progress, setProgress] = useState({ + current: 0, + total: 0, + currentProjectName: "", + }); + + const shouldCheckMigration = + pathname.startsWith("/editor") || pathname.startsWith("/projects"); + + useEffect(() => { + if (!shouldCheckMigration) return; + + checkAndMigrateProjects(); + }, [shouldCheckMigration]); + + const checkAndMigrateProjects = async () => { + try { + const projects = await storageService.loadAllProjects(); + const legacyProjects = projects.filter( + (project) => !project.scenes || project.scenes.length === 0 + ); + + if (legacyProjects.length === 0) { + // No migration needed + return; + } + + setIsMigrating(true); + setProgress({ + current: 0, + total: legacyProjects.length, + currentProjectName: "", + }); + + // Migrate each legacy project + for (let i = 0; i < legacyProjects.length; i++) { + const project = legacyProjects[i]; + + setProgress({ + current: i, + total: legacyProjects.length, + currentProjectName: project.name, + }); + + await migrateLegacyProject(project); + } + + setProgress({ + current: legacyProjects.length, + total: legacyProjects.length, + currentProjectName: "Complete!", + }); + + setTimeout(() => { + setIsMigrating(false); + }, 1000); + } catch (error) { + console.error("Migration failed:", error); + setIsMigrating(false); + } + }; + + const migrateLegacyProject = async (project: TProject) => { + try { + const mainScene: Scene = { + id: generateUUID(), + name: "Main Scene", + isMain: true, + isBackground: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const backgroundScene: Scene = { + id: generateUUID(), + name: "Background", + isMain: false, + isBackground: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const migratedProject: TProject = { + ...project, + scenes: [mainScene, backgroundScene], + currentSceneId: mainScene.id, + updatedAt: new Date(), + }; + + // Load existing timeline data (legacy format) + const legacyTimeline = await storageService.loadTimeline({ + projectId: project.id, + }); + + await storageService.saveProject({ project: migratedProject }); + + // If timeline data, migrate it to the main scene + if (legacyTimeline && legacyTimeline.length > 0) { + await storageService.saveTimeline({ + projectId: project.id, + tracks: legacyTimeline, + sceneId: mainScene.id, + }); + } + + // Clean up legacy timeline storage + await storageService.deleteProjectTimeline({ projectId: project.id }); + } catch (error) { + console.error(`Failed to migrate project ${project.name}:`, error); + throw error; + } + }; + + if (!shouldCheckMigration) { + return children; + } + + if (isMigrating) { + const progressPercent = + progress.total > 0 ? (progress.current / progress.total) * 100 : 0; + + return ( + + e.preventDefault()} + > + + Updating Projects + + We're adding scene support to your projects. This will only take a + moment. + + + +
+
+
+ Progress + + {progress.current} of {progress.total} + +
+ +
+ + {progress.currentProjectName && ( +
+ {progress.current < progress.total + ? `Updating: ${progress.currentProjectName}` + : progress.currentProjectName} +
+ )} +
+
+
+ ); + } + + return children; +} diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 80f10d3d..112d09b6 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -78,6 +78,7 @@ class StorageService { id: scene.id, name: scene.name, isMain: scene.isMain, + isBackground: scene.isBackground, createdAt: scene.createdAt.toISOString(), updatedAt: scene.updatedAt.toISOString(), })); @@ -113,12 +114,13 @@ class StorageService { id: scene.id, name: scene.name, isMain: scene.isMain, + isBackground: scene.isBackground || false, // Default for legacy scenes createdAt: new Date(scene.createdAt), updatedAt: new Date(scene.updatedAt), })) || []; // Convert back to TProject format - return { + const project = { id: serializedProject.id, name: serializedProject.name, thumbnail: serializedProject.thumbnail, @@ -134,6 +136,7 @@ class StorageService { canvasSize: serializedProject.canvasSize, canvasMode: serializedProject.canvasMode, }; + return project; } async loadAllProjects(): Promise { diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index 70c11b28..0c67be9f 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -4,7 +4,7 @@ import { storageService } from "@/lib/storage/storage-service"; import { toast } from "sonner"; import { useMediaStore } from "./media-store"; import { useTimelineStore } from "./timeline-store"; -import { useSceneStore } from "./scene-store"; +import { createBackgroundScene, useSceneStore } from "./scene-store"; import { generateUUID } from "@/lib/utils"; import { CanvasSize, CanvasMode } from "@/types/editor"; @@ -16,6 +16,7 @@ export function createMainScene(): Scene { id: generateUUID(), name: "Main scene", isMain: true, + isBackground: false, createdAt: new Date(), updatedAt: new Date(), }; @@ -23,13 +24,15 @@ export function createMainScene(): Scene { const createDefaultProject = (name: string): TProject => { const mainScene = createMainScene(); + const backgroundScene = createBackgroundScene(); + return { id: generateUUID(), name, thumbnail: "", createdAt: new Date(), updatedAt: new Date(), - scenes: [mainScene], + scenes: [mainScene, backgroundScene], currentSceneId: mainScene.id, backgroundColor: "#000000", backgroundType: "color", @@ -226,15 +229,19 @@ export const useProjectStore = create((set, get) => ({ if (project) { set({ activeProject: project }); + let currentScene = null; if (project.scenes && project.scenes.length > 0) { sceneStore.initializeScenes({ scenes: project.scenes, currentSceneId: project.currentSceneId, }); + // Get current scene directly from project data (don't rely on store state) + currentScene = + project.scenes.find((s) => s.id === project.currentSceneId) || + project.scenes.find((s) => s.isMain) || + project.scenes[0]; } - const currentScene = sceneStore.currentScene; - await Promise.all([ mediaStore.loadProjectMedia(id), timelineStore.loadProjectTimeline({ diff --git a/apps/web/src/stores/scene-store.ts b/apps/web/src/stores/scene-store.ts index e16185e9..1b789427 100644 --- a/apps/web/src/stores/scene-store.ts +++ b/apps/web/src/stores/scene-store.ts @@ -1,219 +1,267 @@ -import { create } from "zustand"; -import { Scene } from "@/types/project"; -import { useProjectStore } from "./project-store"; -import { useTimelineStore } from "./timeline-store"; -import { storageService } from "@/lib/storage/storage-service"; -import { generateUUID } from "@/lib/utils"; - -export function getMainScene({ scenes }: { scenes: Scene[] }): Scene | null { - return scenes.find((scene) => scene.isMain) || null; -} - -interface SceneStore { - // Current scene state - currentScene: Scene | null; - scenes: Scene[]; - - // Scene management - createScene: ({ - name, - isMain, - }: { - name: string; - isMain: boolean; - }) => Promise; - renameScene: ({ - sceneId, - name, - }: { - sceneId: string; - name: string; - }) => Promise; - switchToScene: ({ sceneId }: { sceneId: string }) => Promise; - - // Scene utilities - getMainScene: () => Scene | null; - getCurrentScene: () => Scene | null; - - // Project integration - loadProjectScenes: ({ projectId }: { projectId: string }) => Promise; - initializeScenes: ({ - scenes, - currentSceneId, - }: { - scenes: Scene[]; - currentSceneId?: string; - }) => void; - clearScenes: () => void; -} - -export const useSceneStore = create((set, get) => ({ - currentScene: null, - scenes: [], - - createScene: async ({ name, isMain = false }) => { - const { scenes } = get(); - - const newScene = { - id: generateUUID(), - name, - isMain, - createdAt: new Date(), - updatedAt: new Date(), - }; - const updatedScenes = [...scenes, newScene]; - - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - - if (!activeProject) { - throw new Error("No active project"); - } - - const updatedProject = { - ...activeProject, - scenes: updatedScenes, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - useProjectStore.setState({ activeProject: updatedProject }); - set({ scenes: updatedScenes }); - return newScene.id; - } catch (error) { - console.error("Failed to create scene:", error); - throw error; - } - }, - - renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => { - const { scenes } = get(); - const updatedScenes = scenes.map((scene) => - scene.id === sceneId ? { ...scene, name, updatedAt: new Date() } : scene - ); - - // Update project - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - - if (!activeProject) { - throw new Error("No active project"); - } - - const updatedProject = { - ...activeProject, - scenes: updatedScenes, - updatedAt: new Date(), - }; - - try { - await storageService.saveProject({ project: updatedProject }); - useProjectStore.setState({ activeProject: updatedProject }); - set({ - scenes: updatedScenes, - currentScene: updatedScenes.find((s) => s.id === sceneId) || null, - }); - } catch (error) { - console.error("Failed to rename scene:", error); - throw error; - } - }, - - switchToScene: async ({ sceneId }: { sceneId: string }) => { - const { scenes } = get(); - const targetScene = scenes.find((s) => s.id === sceneId); - - if (!targetScene) { - throw new Error("Scene not found"); - } - - const timelineStore = useTimelineStore.getState(); - const projectStore = useProjectStore.getState(); - const { activeProject } = projectStore; - const { currentScene } = get(); - - if (activeProject && currentScene) { - await timelineStore.saveProjectTimeline({ - projectId: activeProject.id, - sceneId: currentScene.id, - }); - } - - if (activeProject) { - await timelineStore.loadProjectTimeline({ - projectId: activeProject.id, - sceneId, - }); - - const updatedProject = { - ...activeProject, - currentSceneId: sceneId, - updatedAt: new Date(), - }; - - await storageService.saveProject({ project: updatedProject }); - useProjectStore.setState({ activeProject: updatedProject }); - } - - set({ currentScene: targetScene }); - }, - - getMainScene: () => { - const { scenes } = get(); - return scenes.find((scene) => scene.isMain) || null; - }, - - getCurrentScene: () => { - return get().currentScene; - }, - - loadProjectScenes: async ({ projectId }: { projectId: string }) => { - try { - const project = await storageService.loadProject({ id: projectId }); - if (project?.scenes) { - const ensuredScenes = project.scenes.map((scene) => ({ - ...scene, - isMain: scene.isMain || false, - })); - const currentScene = - ensuredScenes.find((s) => s.id === project.currentSceneId) || - ensuredScenes[0]; - - set({ - scenes: ensuredScenes, - currentScene, - }); - } - } catch (error) { - console.error("Failed to load project scenes:", error); - set({ scenes: [], currentScene: null }); - } - }, - - initializeScenes: ({ - scenes, - currentSceneId, - }: { - scenes: Scene[]; - currentSceneId?: string; - }) => { - const currentScene = currentSceneId - ? scenes.find((s) => s.id === currentSceneId) - : null; - - const fallbackScene = getMainScene({ scenes }); - - set({ - scenes, - currentScene: currentScene || fallbackScene, - }); - }, - - clearScenes: () => { - set({ - scenes: [], - currentScene: null, - }); - }, -})); +import { create } from "zustand"; +import { Scene } from "@/types/project"; +import { useProjectStore } from "./project-store"; +import { useTimelineStore } from "./timeline-store"; +import { storageService } from "@/lib/storage/storage-service"; +import { generateUUID } from "@/lib/utils"; + +export function getMainScene({ scenes }: { scenes: Scene[] }): Scene | null { + return scenes.find((scene) => scene.isMain) || null; +} + +export function getBackgroundScene({ + scenes, +}: { + scenes: Scene[]; +}): Scene | null { + return scenes.find((scene) => scene.isBackground) || null; +} + +export function createBackgroundScene(): Scene { + return { + id: generateUUID(), + name: "Background", + isMain: false, + isBackground: true, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function ensureBackgroundScene({ scenes }: { scenes: Scene[] }): Scene[] { + const hasBackground = scenes.some((scene) => scene.isBackground); + if (!hasBackground) { + return [...scenes, createBackgroundScene()]; + } + return scenes; +} + +interface SceneStore { + // Current scene state + currentScene: Scene | null; + scenes: Scene[]; + + // Scene management + createScene: ({ + name, + isMain, + }: { + name: string; + isMain: boolean; + }) => Promise; + renameScene: ({ + sceneId, + name, + }: { + sceneId: string; + name: string; + }) => Promise; + switchToScene: ({ sceneId }: { sceneId: string }) => Promise; + + // Scene utilities + getMainScene: () => Scene | null; + getCurrentScene: () => Scene | null; + + // Project integration + loadProjectScenes: ({ projectId }: { projectId: string }) => Promise; + initializeScenes: ({ + scenes, + currentSceneId, + }: { + scenes: Scene[]; + currentSceneId?: string; + }) => void; + clearScenes: () => void; +} + +export const useSceneStore = create((set, get) => ({ + currentScene: null, + scenes: [], + + createScene: async ({ name, isMain = false }) => { + const { scenes } = get(); + + const newScene = { + id: generateUUID(), + name, + isMain, + isBackground: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + const updatedScenes = [...scenes, newScene]; + + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (!activeProject) { + throw new Error("No active project"); + } + + const updatedProject = { + ...activeProject, + scenes: updatedScenes, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + set({ scenes: updatedScenes }); + return newScene.id; + } catch (error) { + console.error("Failed to create scene:", error); + throw error; + } + }, + + renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => { + const { scenes } = get(); + const updatedScenes = scenes.map((scene) => + scene.id === sceneId ? { ...scene, name, updatedAt: new Date() } : scene + ); + + // Update project + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (!activeProject) { + throw new Error("No active project"); + } + + const updatedProject = { + ...activeProject, + scenes: updatedScenes, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + set({ + scenes: updatedScenes, + currentScene: updatedScenes.find((s) => s.id === sceneId) || null, + }); + } catch (error) { + console.error("Failed to rename scene:", error); + throw error; + } + }, + + switchToScene: async ({ sceneId }: { sceneId: string }) => { + const { scenes } = get(); + const targetScene = scenes.find((s) => s.id === sceneId); + + if (!targetScene) { + throw new Error("Scene not found"); + } + + const timelineStore = useTimelineStore.getState(); + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + const { currentScene } = get(); + + if (activeProject && currentScene) { + await timelineStore.saveProjectTimeline({ + projectId: activeProject.id, + sceneId: currentScene.id, + }); + } + + if (activeProject) { + await timelineStore.loadProjectTimeline({ + projectId: activeProject.id, + sceneId, + }); + + const updatedProject = { + ...activeProject, + currentSceneId: sceneId, + updatedAt: new Date(), + }; + + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + } + + set({ currentScene: targetScene }); + }, + + getMainScene: () => { + const { scenes } = get(); + return scenes.find((scene) => scene.isMain) || null; + }, + + getCurrentScene: () => { + return get().currentScene; + }, + + loadProjectScenes: async ({ projectId }: { projectId: string }) => { + try { + const project = await storageService.loadProject({ id: projectId }); + if (project?.scenes) { + const ensuredScenes = project.scenes.map((scene) => ({ + ...scene, + isMain: scene.isMain || false, + })); + const currentScene = + ensuredScenes.find((s) => s.id === project.currentSceneId) || + ensuredScenes[0]; + + set({ + scenes: ensuredScenes, + currentScene, + }); + } + } catch (error) { + console.error("Failed to load project scenes:", error); + set({ scenes: [], currentScene: null }); + } + }, + + initializeScenes: ({ + scenes, + currentSceneId, + }: { + scenes: Scene[]; + currentSceneId?: string; + }) => { + const ensuredScenes = ensureBackgroundScene({ scenes }); + const currentScene = currentSceneId + ? ensuredScenes.find((s) => s.id === currentSceneId) + : null; + + const fallbackScene = getMainScene({ scenes: ensuredScenes }); + + set({ + scenes: ensuredScenes, + currentScene: currentScene || fallbackScene, + }); + + if (ensuredScenes.length > scenes.length) { + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (activeProject) { + const updatedProject = { + ...activeProject, + scenes: ensuredScenes, + updatedAt: new Date(), + }; + + storageService.saveProject({ project: updatedProject }).then(() => { + useProjectStore.setState({ activeProject: updatedProject }); + }).catch(error => { + console.error("Failed to save project with background scene:", error); + }); + } + } + }, + + clearScenes: () => { + set({ + scenes: [], + currentScene: null, + }); + }, +})); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 8caa71a7..24b6eca3 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -260,6 +260,15 @@ export const useTimelineStore = create((set, get) => { } catch (error) { console.error("Failed to auto-save timeline:", error); } + } else { + console.warn( + "Auto-save skipped - missing activeProject or currentScene:", + { + hasProject: !!activeProject, + hasScene: !!currentScene, + sceneName: currentScene?.name, + } + ); } }; diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index f182653d..7fa53c69 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -6,6 +6,7 @@ export interface Scene { id: string; name: string; isMain: boolean; + isBackground: boolean; createdAt: Date; updatedAt: Date; } -- cgit v1.3.1 From 167e2b83dc072f5d80ab0bf0d3f786a993248c70 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 2 Sep 2025 23:07:11 +0200 Subject: feat: add support for deleting/splitting multiple elements instead of single --- .cursor/rules/ultracite.mdc | 15 +- .../editor/timeline/timeline-element.tsx | 190 ++++---- .../editor/timeline/timeline-toolbar.tsx | 35 +- .../components/editor/timeline/timeline-track.tsx | 15 +- apps/web/src/hooks/use-editor-actions.ts | 13 +- apps/web/src/hooks/use-playback-controls.ts | 6 +- apps/web/src/stores/media-store.ts | 6 + apps/web/src/stores/timeline-store.ts | 495 +++++++++++++++------ 8 files changed, 496 insertions(+), 279 deletions(-) (limited to 'apps/web/src/stores/timeline-store.ts') diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc index 4a6b29fe..4be535af 100644 --- a/.cursor/rules/ultracite.mdc +++ b/.cursor/rules/ultracite.mdc @@ -5,15 +5,18 @@ alwaysApply: true --- # Project Context + Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. ## Key Principles + - Zero configuration required - Subsecond performance - Maximum type safety - AI-friendly code generation ## Before Writing Code + 1. Analyze existing patterns in the codebase 2. Consider edge cases and error scenarios 3. Follow the rules below strictly @@ -23,12 +26,14 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c ## Rules ### Accessibility (a11y) + - Always include a `title` element for icons unless there's text beside the icon. - Always include a `type` attribute for button elements. - Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. - Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. ### Code Complexity and Quality + - Don't use primitive type aliases or misleading types. - Don't use the comma operator. - Use for...of statements instead of Array.forEach. @@ -36,6 +41,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Use .flatMap() instead of map().flat() when possible. ### React and JSX Best Practices + - Don't import `React` itself. - Don't define React components inside other components. - Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. @@ -43,11 +49,13 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Use `<>...` instead of `...`. ### Function Parameters and Props + - Always use destructured props objects instead of individual parameters in functions. - Example: `function helloWorld({ prop }: { prop: string })` instead of `function helloWorld(param: string)`. - This applies to all functions, not just React components. ### Correctness and Safety + - Don't assign a value to itself. - Avoid unused imports and variables. - Don't use await inside loops. @@ -55,13 +63,16 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Don't use the TypeScript directive @ts-ignore. - Make sure the `preconnect` attribute is used when using Google Fonts. - Don't use the `delete` operator. +- Don't use `require()` in TypeScript/ES modules - use proper `import` statements. ### TypeScript Best Practices + - Don't use TypeScript enums. - Use either `T[]` or `Array` consistently. - Don't use the `any` type. ### Style and Consistency + - Don't use global `eval()`. - Use `String.slice()` instead of `String.substr()` and `String.substring()`. - Don't use `else` blocks when the `if` block breaks early. @@ -70,17 +81,19 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. ### Next.js Specific Rules + - Don't use `` elements in Next.js projects. - Don't use `` elements in Next.js projects. ## Example: Error Handling + ```typescript // ✅ Good: Comprehensive error handling try { const result = await fetchData(); return { success: true, data: result }; } catch (error) { - console.error('API call failed:', error); + console.error("API call failed:", error); return { success: false, error: error.message }; } diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index 677b9c61..ca47d3ca 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -16,7 +16,7 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; import AudioWaveform from "../audio-waveform"; import { toast } from "sonner"; -import { TimelineElementProps } from "@/types/timeline"; +import { TimelineElementProps, MediaElement } from "@/types/timeline"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { getTrackElementClasses, @@ -42,16 +42,17 @@ export function TimelineElement({ }: TimelineElementProps) { const { mediaFiles } = useMediaStore(); const { - removeElementFromTrack, - removeElementFromTrackWithRipple, dragState, - splitElement, - addElementToTrack, - replaceElementMedia, - rippleEditingEnabled, - toggleElementHidden, - toggleElementMuted, copySelected, + selectedElements, + deleteSelected, + splitSelected, + toggleSelectedHidden, + toggleSelectedMuted, + duplicateElement, + revealElementInMedia, + replaceElementWithFile, + getContextMenuState, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); @@ -68,7 +69,12 @@ export function TimelineElement({ zoomLevel, }); - const { requestRevealMedia } = useMediaPanelStore.getState(); + const { + isMultipleSelected, + isCurrentElementSelected, + hasAudioElements, + canSplitSelected, + } = getContextMenuState(track.id, element.id); const effectiveDuration = element.duration - element.trimStart - element.trimEnd; @@ -77,44 +83,26 @@ export function TimelineElement({ effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel ); - // Use real-time position during drag, otherwise use stored position const isBeingDragged = dragState.elementId === element.id; const elementStartTime = isBeingDragged && dragState.isDragging ? dragState.currentTime : element.startTime; - // Element should always be positioned at startTime - trimStart only affects content, not position const elementLeft = elementStartTime * 50 * zoomLevel; const handleElementSplitContext = (e: React.MouseEvent) => { e.stopPropagation(); - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const secondElementId = splitElement(track.id, element.id, currentTime); - if (!secondElementId) { - toast.error("Failed to split element"); - } - } else { - toast.error("Playhead must be within element to split"); - } + splitSelected( + currentTime, + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); }; const handleElementDuplicateContext = (e: React.MouseEvent) => { e.stopPropagation(); - const { id, ...elementWithoutId } = element; - addElementToTrack(track.id, { - ...elementWithoutId, - name: element.name + " (copy)", - startTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1, - }); + duplicateElement(track.id, element.id); }; const handleElementCopyContext = (e: React.MouseEvent) => { @@ -124,49 +112,38 @@ export function TimelineElement({ const handleElementDeleteContext = (e: React.MouseEvent) => { e.stopPropagation(); - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(track.id, element.id); - } else { - removeElementFromTrack(track.id, element.id); - } + deleteSelected( + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); }; const handleToggleElementContext = (e: React.MouseEvent) => { e.stopPropagation(); + if (hasAudio && element.type === "media") { - toggleElementMuted(track.id, element.id); - return; + toggleSelectedMuted( + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); + } else { + toggleSelectedHidden( + isMultipleSelected && isCurrentElementSelected ? undefined : track.id, + isMultipleSelected && isCurrentElementSelected ? undefined : element.id + ); } - toggleElementHidden(track.id, element.id); }; const handleReplaceClip = (e: React.MouseEvent) => { e.stopPropagation(); - if (element.type !== "media") { - toast.error("Replace is only available for media clips"); - return; - } - // Create a file input to select replacement media const input = document.createElement("input"); input.type = "file"; input.accept = "video/*,audio/*,image/*"; input.onchange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - - try { - const result = await replaceElementMedia(track.id, element.id, file); - if (result.success) { - toast.success("Clip replaced successfully"); - } else { - toast.error(result.error || "Failed to replace clip"); - } - } catch (error) { - console.error("Unexpected error replacing clip:", error); - toast.error( - `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}` - ); + if (file) { + await replaceElementWithFile(track.id, element.id, file); } }; input.click(); @@ -174,12 +151,7 @@ export function TimelineElement({ const handleRevealInMedia = (e: React.MouseEvent) => { e.stopPropagation(); - if (element.type !== "media") { - toast.error("Reveal is only available for media clips"); - return; - } - - requestRevealMedia(element.mediaId); + revealElementInMedia(element.id); }; const renderElementContent = () => { @@ -191,7 +163,6 @@ export function TimelineElement({ ); } - // Render media element -> const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); if (!mediaItem) { return ( @@ -201,20 +172,15 @@ export function TimelineElement({ ); } - const TILE_ASPECT_RATIO = 16 / 9; - if ( mediaItem.type === "image" || (mediaItem.type === "video" && mediaItem.thumbnailUrl) ) { - // Calculate tile size based on 16:9 aspect ratio const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight; - const tileWidth = tileHeight * TILE_ASPECT_RATIO; + const tileWidth = trackHeight * (16 / 9); const imageUrl = mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl; - const isImage = mediaItem.type === "image"; return (
@@ -228,18 +194,17 @@ export function TimelineElement({ style={{ backgroundImage: imageUrl ? `url(${imageUrl})` : "none", backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${tileHeight}px`, + backgroundSize: `${tileWidth}px ${trackHeight}px`, backgroundPosition: "left center", pointerEvents: "none", }} - aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`} + aria-label={`Tiled ${mediaItem.type === "image" ? "background" : "thumbnail"} of ${mediaItem.name}`} />
); } - // Render audio element -> if (mediaItem.type === "audio") { return (
@@ -267,7 +232,7 @@ export function TimelineElement({ } }; - const isMuted = element.type === "media" ? element.muted === true : false; + const isMuted = element.type === "media" && element.muted; return ( @@ -332,16 +297,33 @@ export function TimelineElement({
- - - Split at playhead - + {(!isMultipleSelected || + (isMultipleSelected && + isCurrentElementSelected && + canSplitSelected)) && ( + + + {isMultipleSelected && isCurrentElementSelected + ? `Split ${selectedElements.length} elements at playhead` + : "Split at playhead"} + + )} + - Copy element + {isMultipleSelected && isCurrentElementSelected + ? `Copy ${selectedElements.length} elements` + : "Copy element"} + - {hasAudio ? ( + {isMultipleSelected && isCurrentElementSelected ? ( + hasAudioElements ? ( + + ) : ( + + ) + ) : hasAudio ? ( isMuted ? ( ) : ( @@ -353,21 +335,29 @@ export function TimelineElement({ )} - {hasAudio - ? isMuted - ? "Unmute" - : "Mute" - : element.hidden - ? "Show" - : "Hide"}{" "} - {element.type === "text" ? "text" : "clip"} + {isMultipleSelected && isCurrentElementSelected + ? hasAudioElements + ? `Toggle mute ${selectedElements.length} elements` + : `Toggle visibility ${selectedElements.length} elements` + : hasAudio + ? isMuted + ? "Unmute" + : "Mute" + : element.hidden + ? "Show" + : "Hide"}{" "} + {!isMultipleSelected && (element.type === "text" ? "text" : "clip")} - - - Duplicate {element.type === "text" ? "text" : "clip"} - - {element.type === "media" && ( + + {!isMultipleSelected && ( + + + Duplicate {element.type === "text" ? "text" : "clip"} + + )} + + {!isMultipleSelected && element.type === "media" && ( <> @@ -379,13 +369,17 @@ export function TimelineElement({ )} + + - Delete {element.type === "text" ? "text" : "clip"} + {isMultipleSelected && isCurrentElementSelected + ? `Delete ${selectedElements.length} elements` + : `Delete ${element.type === "text" ? "text" : "clip"}`} diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index bec4458c..80c6ce08 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -52,11 +52,10 @@ export function TimelineToolbar({ tracks, addTrack, addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, selectedElements, clearSelectedElements, - splitElement, + deleteSelected, + splitSelected, splitAndKeepLeft, splitAndKeepRight, separateAudio, @@ -70,25 +69,7 @@ export function TimelineToolbar({ const { scenes, currentScene } = useSceneStore(); const handleSplitSelected = () => { - if (selectedElements.length === 0) return; - let splitCount = 0; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (element && track) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newElementId = splitElement(trackId, elementId, currentTime); - if (newElementId) splitCount++; - } - } - }); - if (splitCount === 0) { - toast.error("Playhead must be within selected elements to split"); - } + splitSelected(currentTime); }; const handleDuplicateSelected = () => { @@ -173,15 +154,7 @@ export function TimelineToolbar({ }; const handleDeleteSelected = () => { - if (selectedElements.length === 0) return; - selectedElements.forEach(({ trackId, elementId }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); + deleteSelected(); }; const handleZoomIn = () => { diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 42c78aa4..7cfde931 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -1147,7 +1147,7 @@ export function TimelineTrackContent({ const handleElementSplit = () => { const { currentTime } = usePlaybackStore(); - const { splitElement } = useTimelineStore(); + const { splitSelected } = useTimelineStore(); const splitTime = currentTime; const effectiveStart = element.startTime; const effectiveEnd = @@ -1155,14 +1155,7 @@ export function TimelineTrackContent({ (element.duration - element.trimStart - element.trimEnd); if (splitTime > effectiveStart && splitTime < effectiveEnd) { - const secondElementId = splitElement( - track.id, - element.id, - splitTime - ); - if (!secondElementId) { - toast.error("Failed to split element"); - } + splitSelected(splitTime, track.id, element.id); } else { toast.error("Playhead must be within element to split"); } @@ -1182,8 +1175,8 @@ export function TimelineTrackContent({ }; const handleElementDelete = () => { - const { removeElementFromTrack } = useTimelineStore.getState(); - removeElementFromTrack(track.id, element.id); + const { deleteSelected } = useTimelineStore.getState(); + deleteSelected(track.id, element.id); }; return ( diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts index 974f9db5..9287d2e0 100644 --- a/apps/web/src/hooks/use-editor-actions.ts +++ b/apps/web/src/hooks/use-editor-actions.ts @@ -13,8 +13,8 @@ export function useEditorActions() { selectedElements, clearSelectedElements, setSelectedElements, - removeElementFromTrack, - splitElement, + deleteSelected, + splitSelected, addElementToTrack, snappingEnabled, toggleSnapping, @@ -135,7 +135,7 @@ export function useEditorActions() { (element.duration - element.trimStart - element.trimEnd); if (currentTime > effectiveStart && currentTime < effectiveEnd) { - splitElement(trackId, elementId, currentTime); + splitSelected(currentTime, trackId, elementId); } else { toast.error("Playhead must be within selected element"); } @@ -150,12 +150,7 @@ export function useEditorActions() { if (selectedElements.length === 0) { return; } - selectedElements.forEach( - ({ trackId, elementId }: { trackId: string; elementId: string }) => { - removeElementFromTrack(trackId, elementId); - } - ); - clearSelectedElements(); + deleteSelected(); }, undefined ); diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts index ecbd1232..c27911d2 100644 --- a/apps/web/src/hooks/use-playback-controls.ts +++ b/apps/web/src/hooks/use-playback-controls.ts @@ -9,7 +9,7 @@ export const usePlaybackControls = () => { const { selectedElements, tracks, - splitElement, + splitSelected, splitAndKeepLeft, splitAndKeepRight, separateAudio, @@ -37,8 +37,8 @@ export const usePlaybackControls = () => { return; } - splitElement(trackId, elementId, currentTime); - }, [selectedElements, tracks, currentTime, splitElement]); + splitSelected(currentTime, trackId, elementId); + }, [selectedElements, tracks, currentTime, splitSelected]); const handleSplitAndKeepLeftCallback = useCallback(() => { if (selectedElements.length !== 1) { diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index e00cffd5..61c771db 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -203,6 +203,12 @@ export const useMediaStore = create((set, get) => ({ // If there are elements to remove, push history once before batch removal if (elementsToRemove.length > 0) { + const { + removeElementFromTrack, + removeElementFromTrackWithRipple, + rippleEditingEnabled, + pushHistory, + } = useTimelineStore.getState(); pushHistory(); // Remove all elements without pushing additional history entries diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 24b6eca3..aeeb65c5 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -6,12 +6,13 @@ import { TimelineTrack, TextElement, DragData, + MediaElement, sortTracksByOrder, ensureMainTrack, validateElementTrackCompatibility, } from "@/types/timeline"; import { useMediaStore, getMediaAspectRatio } from "./media-store"; -import { MediaFile } from "@/types/media"; +import { MediaFile, MediaType } from "@/types/media"; import { findBestCanvasPreset } from "@/lib/editor-utils"; import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; @@ -20,6 +21,7 @@ import { generateUUID } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { usePlaybackStore } from "./playback-store"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( @@ -99,11 +101,7 @@ interface TimelineStore { removeTrack: (trackId: string) => void; removeTrackWithRipple: (trackId: string) => void; addElementToTrack: (trackId: string, element: CreateTimelineElement) => void; - removeElementFromTrack: ( - trackId: string, - elementId: string, - pushHistory?: boolean - ) => void; + moveElementToTrack: ( fromTrackId: string, toTrackId: string, @@ -129,15 +127,6 @@ interface TimelineStore { pushHistory?: boolean ) => void; toggleTrackMute: (trackId: string) => void; - toggleElementHidden: (trackId: string, elementId: string) => void; - toggleElementMuted: (trackId: string, elementId: string) => void; - - // Split operations for elements - splitElement: ( - trackId: string, - elementId: string, - splitTime: number - ) => string | null; splitAndKeepLeft: ( trackId: string, elementId: string, @@ -198,6 +187,33 @@ interface TimelineStore { // Clipboard actions copySelected: () => void; pasteAtTime: (time: number) => void; + + // Unified selection-aware actions + deleteSelected: (trackId?: string, elementId?: string) => void; + splitSelected: ( + splitTime: number, + trackId?: string, + elementId?: string + ) => void; + toggleSelectedHidden: (trackId?: string, elementId?: string) => void; + toggleSelectedMuted: (trackId?: string, elementId?: string) => void; + duplicateElement: (trackId: string, elementId: string) => void; + revealElementInMedia: (elementId: string) => void; + replaceElementWithFile: ( + trackId: string, + elementId: string, + file: File + ) => Promise; + getContextMenuState: ( + trackId: string, + elementId: string + ) => { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + hasAudioElements: boolean; + canSplitSelected: boolean; + currentTime: number; + }; updateTextElement: ( trackId: string, elementId: string, @@ -575,16 +591,19 @@ export const useTimelineStore = create((set, get) => { get().selectElement(trackId, newElement.id); }, - removeElementFromTrack: (trackId, elementId, pushHistory = true) => { - const { rippleEditingEnabled } = get(); + removeElementFromTrackWithRipple: ( + trackId, + elementId, + pushHistory = true + ) => { + const { _tracks, rippleEditingEnabled } = get(); - if (rippleEditingEnabled) { - get().removeElementFromTrackWithRipple(trackId, elementId, pushHistory); - } else { + if (!rippleEditingEnabled) { + // Inline non-ripple removal logic if (pushHistory) get().pushHistory(); updateTracksAndSave( - get() - ._tracks.map((track) => + _tracks + .map((track) => track.id === trackId ? { ...track, @@ -596,18 +615,6 @@ export const useTimelineStore = create((set, get) => { ) .filter((track) => track.elements.length > 0) ); - } - }, - - removeElementFromTrackWithRipple: ( - trackId, - elementId, - pushHistory = true - ) => { - const { _tracks, rippleEditingEnabled } = get(); - - if (!rippleEditingEnabled) { - get().removeElementFromTrack(trackId, elementId, pushHistory); return; } @@ -864,42 +871,6 @@ export const useTimelineStore = create((set, get) => { ); }, - toggleElementHidden: (trackId, elementId) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId - ? { ...element, hidden: !element.hidden } - : element - ), - } - : track - ) - ); - }, - - toggleElementMuted: (trackId, elementId) => { - get().pushHistory(); - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.map((element) => - element.id === elementId && element.type === "media" - ? { ...element, muted: !element.muted } - : element - ), - } - : track - ) - ); - }, - updateTextElement: (trackId, elementId, updates) => { get().pushHistory(); updateTracksAndSave( @@ -918,60 +889,6 @@ export const useTimelineStore = create((set, get) => { ); }, - splitElement: (trackId, elementId, splitTime) => { - const { _tracks } = get(); - const track = _tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - - if (!element) return null; - - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null; - - get().pushHistory(); - - const relativeTime = splitTime - element.startTime; - const firstDuration = relativeTime; - const secondDuration = - element.duration - element.trimStart - element.trimEnd - relativeTime; - - const secondElementId = generateUUID(); - - updateTracksAndSave( - get()._tracks.map((track) => - track.id === trackId - ? { - ...track, - elements: track.elements.flatMap((c) => - c.id === elementId - ? [ - { - ...c, - trimEnd: c.trimEnd + secondDuration, - name: getElementNameWithSuffix(c.name, "left"), - }, - { - ...c, - id: secondElementId, - startTime: splitTime, - trimStart: c.trimStart + firstDuration, - name: getElementNameWithSuffix(c.name, "right"), - }, - ] - : [c] - ), - } - : track - ) - ); - - return secondElementId; - }, - // Split element and keep only the left portion splitAndKeepLeft: (trackId, elementId, splitTime) => { const { _tracks } = get(); @@ -1150,9 +1067,9 @@ export const useTimelineStore = create((set, get) => { }; } - const mediaData: any = { + const mediaData: Omit = { name: newFile.name, - type: fileType, + type: fileType as MediaType, file: newFile, url: URL.createObjectURL(newFile), }; @@ -1606,6 +1523,332 @@ export const useTimelineStore = create((set, get) => { } } }, + + deleteSelected: (trackId?: string, elementId?: string) => { + const { selectedElements, rippleEditingEnabled } = get(); + + const elementsToDelete = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToDelete.length === 0) return; + + get().pushHistory(); + + if (rippleEditingEnabled) { + for (const { trackId: tId, elementId: eId } of elementsToDelete) { + get().removeElementFromTrackWithRipple(tId, eId, false); + } + } else { + updateTracksAndSave( + get() + ._tracks.map((track) => ({ + ...track, + elements: track.elements.filter( + (element) => + !elementsToDelete.some( + ({ trackId: tId, elementId: eId }) => + track.id === tId && element.id === eId + ) + ), + })) + .filter((track) => track.elements.length > 0) + ); + } + + get().clearSelectedElements(); + }, + + splitSelected: (splitTime, trackId?: string, elementId?: string) => { + const { selectedElements, _tracks } = get(); + + const elementsToProcess = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToProcess.length === 0) return; + + const elementsToSplit: Array<{ + trackId: string; + elementId: string; + element: TimelineElement; + }> = []; + + for (const { trackId: tId, elementId: eId } of elementsToProcess) { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + if (!track || !element) continue; + + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (splitTime > effectiveStart && splitTime < effectiveEnd) { + elementsToSplit.push({ trackId: tId, elementId: eId, element }); + } + } + + if (elementsToSplit.length === 0) { + const { toast } = require("sonner"); + const isMultiple = elementsToProcess.length > 1; + toast.error( + isMultiple + ? "Playhead must be within all selected elements to split" + : "Playhead must be within element to split" + ); + return; + } + + get().pushHistory(); + + updateTracksAndSave( + get()._tracks.map((track) => { + const elementsToSplitInTrack = elementsToSplit.filter( + ({ trackId: tId }) => tId === track.id + ); + + if (elementsToSplitInTrack.length === 0) return track; + + return { + ...track, + elements: track.elements.flatMap((c) => { + const elementToSplit = elementsToSplitInTrack.find( + ({ elementId: eId }) => eId === c.id + ); + + if (!elementToSplit) return [c]; + + const relativeTime = splitTime - elementToSplit.element.startTime; + const firstDuration = relativeTime; + const secondDuration = + elementToSplit.element.duration - + elementToSplit.element.trimStart - + elementToSplit.element.trimEnd - + relativeTime; + + const secondElementId = generateUUID(); + + return [ + { + ...c, + trimEnd: c.trimEnd + secondDuration, + name: getElementNameWithSuffix(c.name, "left"), + }, + { + ...c, + id: secondElementId, + startTime: splitTime, + trimStart: c.trimStart + firstDuration, + name: getElementNameWithSuffix(c.name, "right"), + }, + ]; + }), + }; + }) + ); + }, + + toggleSelectedHidden: (trackId?: string, elementId?: string) => { + const { selectedElements, _tracks } = get(); + + const elementsToProcess = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToProcess.length === 0) return; + + get().pushHistory(); + + const shouldHide = elementsToProcess.some( + ({ trackId: tId, elementId: eId }) => { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + return element && !element.hidden; + } + ); + + updateTracksAndSave( + _tracks.map((track) => ({ + ...track, + elements: track.elements.map((element) => { + const shouldUpdate = elementsToProcess.some( + ({ trackId: tId, elementId: eId }) => + track.id === tId && element.id === eId + ); + return shouldUpdate && element.hidden !== shouldHide + ? { ...element, hidden: shouldHide } + : element; + }), + })) + ); + }, + + toggleSelectedMuted: (trackId?: string, elementId?: string) => { + const { selectedElements, _tracks } = get(); + + const elementsToProcess = + trackId && elementId + ? [{ trackId, elementId }] + : selectedElements.length > 0 + ? selectedElements + : []; + + if (elementsToProcess.length === 0) return; + + get().pushHistory(); + + const audioElements = elementsToProcess.filter( + ({ trackId: tId, elementId: eId }) => { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + return element?.type === "media"; + } + ); + + if (audioElements.length === 0) return; + + const shouldMute = audioElements.some( + ({ trackId: tId, elementId: eId }) => { + const track = _tracks.find((t) => t.id === tId); + const element = track?.elements.find((e) => e.id === eId); + return element?.type === "media" && !element.muted; + } + ); + + updateTracksAndSave( + _tracks.map((track) => ({ + ...track, + elements: track.elements.map((element) => { + const shouldUpdate = audioElements.some( + ({ trackId: tId, elementId: eId }) => + track.id === tId && element.id === eId + ); + return shouldUpdate && + element.type === "media" && + element.muted !== shouldMute + ? { ...element, muted: shouldMute } + : element; + }), + })) + ); + }, + + duplicateElement: (trackId, elementId) => { + const { _tracks } = get(); + const track = _tracks.find((t) => t.id === trackId); + const element = track?.elements.find((e) => e.id === elementId); + if (!track || !element) return; + + const { id, ...elementWithoutId } = element; + const effectiveDuration = + element.duration - element.trimStart - element.trimEnd; + + get().addElementToTrack(trackId, { + ...elementWithoutId, + name: `${element.name} (copy)`, + startTime: element.startTime + effectiveDuration + 0.1, + } as CreateTimelineElement); + }, + + revealElementInMedia: (elementId) => { + const { + useMediaPanelStore, + } = require("../components/editor/media-panel/store"); + const { requestRevealMedia } = useMediaPanelStore.getState(); + + const { _tracks } = get(); + const element = _tracks + .flatMap((track) => track.elements) + .find((el) => el.id === elementId); + + if (element?.type === "media") { + requestRevealMedia(element.mediaId); + } + }, + + replaceElementWithFile: async (trackId, elementId, file) => { + try { + const result = await get().replaceElementMedia( + trackId, + elementId, + file + ); + if (result.success) { + const { toast } = await import("sonner"); + toast.success("Clip replaced successfully"); + } else { + const { toast } = await import("sonner"); + toast.error(result.error || "Failed to replace clip"); + } + } catch (error) { + console.error("Unexpected error replacing clip:", error); + const { toast } = await import("sonner"); + toast.error( + `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + }, + + getContextMenuState: (trackId, elementId) => { + const { selectedElements, _tracks } = get(); + const { currentTime } = usePlaybackStore.getState(); + const { mediaFiles } = useMediaStore.getState(); + + const isMultipleSelected = selectedElements.length > 1; + const isCurrentElementSelected = selectedElements.some( + (sel) => sel.trackId === trackId && sel.elementId === elementId + ); + + const hasAudioElements = selectedElements.some( + ({ trackId: tId, elementId: eId }) => { + const selectedTrack = _tracks.find((t) => t.id === tId); + const selectedElement = selectedTrack?.elements.find( + (e) => e.id === eId + ); + if (selectedElement?.type !== "media") return false; + const mediaElement = selectedElement as MediaElement; + const mediaItem = mediaFiles.find( + (file: MediaFile) => file.id === mediaElement.mediaId + ); + return mediaItem?.type === "audio" || mediaItem?.type === "video"; + } + ); + + const canSplitSelected = selectedElements.every( + ({ trackId: tId, elementId: eId }) => { + const selectedTrack = _tracks.find((t) => t.id === tId); + const selectedElement = selectedTrack?.elements.find( + (e) => e.id === eId + ); + if (!selectedElement) return false; + const effectiveStart = selectedElement.startTime; + const effectiveEnd = + selectedElement.startTime + + (selectedElement.duration - + selectedElement.trimStart - + selectedElement.trimEnd); + return currentTime > effectiveStart && currentTime < effectiveEnd; + } + ); + + return { + isMultipleSelected, + isCurrentElementSelected, + hasAudioElements, + canSplitSelected, + currentTime, + }; + }, }; }); -- cgit v1.3.1