diff --git a/.gitignore b/.gitignore index 3913db1..680ad3b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ lerna-debug.log* node_modules dist +dist-electron dist-ssr *.local diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index dba3f16..d7deb55 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -39,6 +39,10 @@ interface Window { setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> clearCurrentVideoPath: () => Promise<{ success: boolean }> + saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ success: boolean; path?: string; message?: string; cancelled?: boolean; error?: string }> + loadProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; cancelled?: boolean; error?: string }> + onMenuLoadProject: (callback: () => void) => () => void + onMenuSaveProject: (callback: () => void) => () => void getPlatform: () => Promise hudOverlayHide: () => void; hudOverlayClose: () => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 867b72b..dfc4a1f 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -4,6 +4,8 @@ import fs from 'node:fs/promises' import path from 'node:path' import { RECORDINGS_DIR } from '../main' +const PROJECT_FILE_EXTENSION = 'openscreen' + let selectedSource: any = null export function registerIpcHandlers( @@ -198,6 +200,93 @@ export function registerIpcHandlers( } }); + ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { + try { + if (existingProjectPath) { + await fs.writeFile(existingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') + return { + success: true, + path: existingProjectPath, + message: 'Project saved successfully' + } + } + + const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, '_') + const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`) + ? safeName + : `${safeName}.${PROJECT_FILE_EXTENSION}` + + const result = await dialog.showSaveDialog({ + title: 'Save OpenScreen Project', + defaultPath: path.join(RECORDINGS_DIR, defaultName), + filters: [ + { name: 'OpenScreen Project', extensions: [PROJECT_FILE_EXTENSION] }, + { name: 'JSON', extensions: ['json'] } + ], + properties: ['createDirectory', 'showOverwriteConfirmation'] + }) + + if (result.canceled || !result.filePath) { + return { + success: false, + cancelled: true, + message: 'Save project cancelled' + } + } + + await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), 'utf-8') + + return { + success: true, + path: result.filePath, + message: 'Project saved successfully' + } + } catch (error) { + console.error('Failed to save project file:', error) + return { + success: false, + message: 'Failed to save project file', + error: String(error) + } + } + }) + + ipcMain.handle('load-project-file', async () => { + try { + const result = await dialog.showOpenDialog({ + title: 'Open OpenScreen Project', + defaultPath: RECORDINGS_DIR, + filters: [ + { name: 'OpenScreen Project', extensions: [PROJECT_FILE_EXTENSION] }, + { name: 'JSON', extensions: ['json'] }, + { name: 'All Files', extensions: ['*'] } + ], + properties: ['openFile'] + }) + + if (result.canceled || result.filePaths.length === 0) { + return { success: false, cancelled: true, message: 'Open project cancelled' } + } + + const filePath = result.filePaths[0] + const content = await fs.readFile(filePath, 'utf-8') + const project = JSON.parse(content) + + return { + success: true, + path: filePath, + project + } + } catch (error) { + console.error('Failed to load project file:', error) + return { + success: false, + message: 'Failed to load project file', + error: String(error) + } + } + }) + let currentVideoPath: string | null = null; ipcMain.handle('set-current-video-path', (_, path: string) => { diff --git a/electron/main.ts b/electron/main.ts index e40e08b..6d863d1 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -53,6 +53,96 @@ function createWindow() { mainWindow = createHudOverlayWindow() } +function isEditorWindow(window: BrowserWindow) { + return window.webContents.getURL().includes('windowType=editor') +} + +function sendEditorMenuAction(channel: 'menu-load-project' | 'menu-save-project') { + let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow + + if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) { + createEditorWindowWrapper() + targetWindow = mainWindow + if (!targetWindow || targetWindow.isDestroyed()) return + + targetWindow.webContents.once('did-finish-load', () => { + if (!targetWindow || targetWindow.isDestroyed()) return + targetWindow.webContents.send(channel) + }) + return + } + + targetWindow.webContents.send(channel) +} + +function setupApplicationMenu() { + const template: Electron.MenuItemConstructorOptions[] = [ + { + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'File', + submenu: [ + { + label: 'Load Project…', + accelerator: 'CmdOrCtrl+O', + click: () => sendEditorMenuAction('menu-load-project'), + }, + { + label: 'Save Project…', + accelerator: 'CmdOrCtrl+S', + click: () => sendEditorMenuAction('menu-save-project'), + }, + ], + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' }, + ], + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'close' }, + ], + }, + ] + + const menu = Menu.buildFromTemplate(template) + Menu.setApplicationMenu(menu) +} + function createTray() { tray = new Tray(defaultTrayIcon); } @@ -145,6 +235,7 @@ app.whenReady().then(async () => { }); createTray() updateTrayMenu() + setupApplicationMenu() // Ensure recordings directory exists await ensureRecordingsDir() diff --git a/electron/preload.ts b/electron/preload.ts index 02fcc97..425440f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -60,6 +60,22 @@ contextBridge.exposeInMainWorld('electronAPI', { clearCurrentVideoPath: () => { return ipcRenderer.invoke('clear-current-video-path') }, + saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { + return ipcRenderer.invoke('save-project-file', projectData, suggestedName, existingProjectPath) + }, + loadProjectFile: () => { + return ipcRenderer.invoke('load-project-file') + }, + onMenuLoadProject: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('menu-load-project', listener) + return () => ipcRenderer.removeListener('menu-load-project', listener) + }, + onMenuSaveProject: (callback: () => void) => { + const listener = () => callback() + ipcRenderer.on('menu-save-project', listener) + return () => ipcRenderer.removeListener('menu-save-project', listener) + }, getPlatform: () => { return ipcRenderer.invoke('get-platform') }, diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index db5e9d8..eff37cf 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -7,7 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { useState } from "react"; import Block from '@uiw/react-color-block'; -import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette } from "lucide-react"; +import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette, Save, FolderOpen } from "lucide-react"; import { toast } from "sonner"; import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types"; import { CropControl } from "./CropControl"; @@ -82,6 +82,8 @@ interface SettingsPanelProps { gifSizePreset?: GifSizePreset; onGifSizePresetChange?: (preset: GifSizePreset) => void; gifOutputDimensions?: { width: number; height: number }; + onSaveProject?: () => void; + onLoadProject?: () => void; onExport?: () => void; selectedAnnotationId?: string | null; annotationRegions?: AnnotationRegion[]; @@ -137,6 +139,8 @@ export function SettingsPanel({ gifSizePreset = 'medium', onGifSizePresetChange, gifOutputDimensions = { width: 1280, height: 720 }, + onSaveProject, + onLoadProject, onExport, selectedAnnotationId, annotationRegions = [], @@ -682,6 +686,27 @@ export function SettingsPanel({ )} +
+ + +
+ + ); } @@ -748,6 +1110,7 @@ export default function VideoEditor() {
diff --git a/src/projectPersistence.test.ts b/src/projectPersistence.test.ts new file mode 100644 index 0000000..10ecbaa --- /dev/null +++ b/src/projectPersistence.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' + +vi.mock('../electron/main', () => ({ + RECORDINGS_DIR: '/recordings', +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + on: vi.fn(), + }, + desktopCapturer: { + getSources: vi.fn().mockResolvedValue([]), + }, + shell: { + openExternal: vi.fn().mockResolvedValue(undefined), + }, + app: { + isPackaged: false, + getPath: vi.fn().mockReturnValue('/downloads'), + getAppPath: vi.fn().mockReturnValue('/app'), + }, + dialog: { + showSaveDialog: vi.fn(), + showOpenDialog: vi.fn(), + }, + BrowserWindow: class {}, +})) + +vi.mock('node:fs/promises', () => ({ + default: { + writeFile: vi.fn(), + readFile: vi.fn(), + readdir: vi.fn().mockResolvedValue([]), + }, +})) + +import { ipcMain, dialog } from 'electron' +import fs from 'node:fs/promises' +import { registerIpcHandlers } from '../electron/ipc/handlers' + +describe('project save/load handlers', () => { + const setupHandlers = () => { + registerIpcHandlers( + () => {}, + () => ({ close: vi.fn(), focus: vi.fn() }) as any, + () => null, + () => null, + ) + } + + const getRegisteredHandler = (channel: string) => { + const calls = (ipcMain.handle as unknown as Mock).mock.calls + const match = calls.find(([name]) => name === channel) + if (!match) { + throw new Error(`Handler not found for channel: ${channel}`) + } + return match[1] as (...args: any[]) => Promise + } + + beforeEach(() => { + vi.clearAllMocks() + setupHandlers() + }) + + it('overwrites existing project path without showing save dialog', async () => { + const saveHandler = getRegisteredHandler('save-project-file') + const projectData = { version: 1, videoPath: '/tmp/video.webm', editor: { zoomRegions: [] } } + + ;(fs.writeFile as unknown as Mock).mockResolvedValue(undefined) + + const result = await saveHandler({}, projectData, 'project-name', '/tmp/current.openscreen') + + expect(dialog.showSaveDialog).not.toHaveBeenCalled() + expect(fs.writeFile).toHaveBeenCalledWith( + '/tmp/current.openscreen', + JSON.stringify(projectData, null, 2), + 'utf-8', + ) + expect(result).toMatchObject({ success: true, path: '/tmp/current.openscreen' }) + }) + + it('uses save dialog when no existing project path is provided', async () => { + const saveHandler = getRegisteredHandler('save-project-file') + const projectData = { version: 1, videoPath: '/tmp/video.webm', editor: { zoomRegions: [] } } + + ;(dialog.showSaveDialog as unknown as Mock).mockResolvedValue({ + canceled: false, + filePath: '/tmp/new.openscreen', + }) + ;(fs.writeFile as unknown as Mock).mockResolvedValue(undefined) + + const result = await saveHandler({}, projectData, 'new-project') + + expect(dialog.showSaveDialog).toHaveBeenCalled() + expect(fs.writeFile).toHaveBeenCalledWith( + '/tmp/new.openscreen', + JSON.stringify(projectData, null, 2), + 'utf-8', + ) + expect(result).toMatchObject({ success: true, path: '/tmp/new.openscreen' }) + }) + + it('loads project JSON payload from selected file', async () => { + const loadHandler = getRegisteredHandler('load-project-file') + const serialized = JSON.stringify({ version: 1, videoPath: '/tmp/video.webm', editor: {} }) + + ;(dialog.showOpenDialog as unknown as Mock).mockResolvedValue({ + canceled: false, + filePaths: ['/tmp/example.openscreen'], + }) + ;(fs.readFile as unknown as Mock).mockResolvedValue(serialized) + + const result = await loadHandler({}) + + expect(dialog.showOpenDialog).toHaveBeenCalled() + expect(fs.readFile).toHaveBeenCalledWith('/tmp/example.openscreen', 'utf-8') + expect(result).toMatchObject({ + success: true, + path: '/tmp/example.openscreen', + project: { version: 1, videoPath: '/tmp/video.webm' }, + }) + }) +}) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 0afbf58..cc0a9dc 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -42,5 +42,22 @@ interface Window { setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> clearCurrentVideoPath: () => Promise<{ success: boolean }> + saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ + success: boolean + path?: string + message?: string + cancelled?: boolean + error?: string + }> + loadProjectFile: () => Promise<{ + success: boolean + path?: string + project?: unknown + message?: string + cancelled?: boolean + error?: string + }> + onMenuLoadProject: (callback: () => void) => () => void + onMenuSaveProject: (callback: () => void) => () => void } } \ No newline at end of file