diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4eff6ece..9e0adb6f 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -345,6 +345,17 @@ interface Window { canceled?: boolean; error?: string; }>; + saveProjectFileNamed: ( + projectData: unknown, + projectName: string, + thumbnailDataUrl?: string | null, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; loadProjectFile: () => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index dee0af16..9310dc2d 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -41,6 +41,29 @@ function normalizeRecordingTimeOffsetMs(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0; } +function normalizeProjectSaveName(projectName?: string | null) { + if (typeof projectName !== "string") { + return null; + } + + const trimmedName = projectName.trim(); + if (!trimmedName) { + return null; + } + + const withoutExtension = trimmedName.replace( + new RegExp(`\\.${PROJECT_FILE_EXTENSION}$`, "i"), + "", + ); + const sanitizedName = withoutExtension + .replace(/[<>:"/\\|?*\u0000-\u001F]/g, "") + .replace(/\s+/g, " ") + .replace(/[. ]+$/g, "") + .trim(); + + return sanitizedName || null; +} + export function registerProjectHandlers() { ipcMain.handle('reveal-in-folder', async (_, filePath: string) => { try { @@ -142,10 +165,8 @@ export function registerProjectHandlers() { } } - 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 safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}` + const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}` const result = await dialog.showSaveDialog({ title: 'Save Recordly Project', @@ -185,6 +206,42 @@ export function registerProjectHandlers() { } }) + ipcMain.handle('save-project-file-named', async (_, projectData: unknown, projectName: string, thumbnailDataUrl?: string | null) => { + try { + const normalizedProjectName = normalizeProjectSaveName(projectName) + if (!normalizedProjectName) { + return { + success: false, + message: 'Project name is required', + } + } + + const projectsDir = await getProjectsDir() + const targetProjectPath = path.join( + projectsDir, + `${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`, + ) + + await fs.writeFile(targetProjectPath, JSON.stringify(projectData, null, 2), 'utf-8') + setCurrentProjectPath(targetProjectPath) + await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl) + await rememberRecentProject(targetProjectPath) + + return { + success: true, + path: targetProjectPath, + message: 'Project saved successfully' + } + } catch (error) { + console.error('Failed to save named project file:', error) + return { + success: false, + message: 'Failed to save project file', + error: String(error) + } + } + }) + ipcMain.handle('load-project-file', async () => { try { const projectsDir = await getProjectsDir() diff --git a/electron/preload.ts b/electron/preload.ts index 4ae5d8bf..fe226ba0 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -407,6 +407,18 @@ contextBridge.exposeInMainWorld("electronAPI", { thumbnailDataUrl, ); }, + saveProjectFileNamed: ( + projectData: unknown, + projectName: string, + thumbnailDataUrl?: string | null, + ) => { + return ipcRenderer.invoke( + "save-project-file-named", + projectData, + projectName, + thumbnailDataUrl, + ); + }, loadProjectFile: () => { return ipcRenderer.invoke("load-project-file"); }, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 42debcad..ffe7e4c9 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -14,7 +14,6 @@ import { Plus, PuzzlePiece, ArrowClockwise as Redo2, - FloppyDisk as Save, Scissors, SkipBack, SkipForward, @@ -485,6 +484,9 @@ export default function VideoEditor() { const [currentProjectPath, setCurrentProjectPath] = useState(null); const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); + const [isEditingProjectName, setIsEditingProjectName] = useState(false); + const [projectNameDraft, setProjectNameDraft] = useState(""); + const [isSavingProjectName, setIsSavingProjectName] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); @@ -631,6 +633,7 @@ export default function VideoEditor() { const videoPlaybackRef = useRef(null); const projectBrowserTriggerRef = useRef(null); const projectBrowserFallbackTriggerRef = useRef(null); + const projectNameInputRef = useRef(null); const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextClipIdRef = useRef(1); @@ -1240,6 +1243,27 @@ export default function VideoEditor() { return withoutExtension || t("editor.project.untitled", "Untitled"); }, [currentProjectPath, currentSourcePath, t]); + useEffect(() => { + if (!isEditingProjectName) { + setProjectNameDraft(projectDisplayName); + } + }, [isEditingProjectName, projectDisplayName]); + + useEffect(() => { + if (!isEditingProjectName) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + projectNameInputRef.current?.focus(); + projectNameInputRef.current?.select(); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [isEditingProjectName]); + const currentPersistedEditorState = useMemo( () => buildPersistedEditorState({ @@ -2178,6 +2202,91 @@ export default function VideoEditor() { } }, [saveProject]); + const saveProjectWithName = useCallback( + async (projectName: string) => { + const trimmedProjectName = projectName.trim(); + if (!trimmedProjectName) { + toast.error("Project name is required"); + return false; + } + + if (!currentSourcePath) { + toast.error("No video loaded"); + return false; + } + + try { + const projectData = + currentProjectSnapshot?.videoPath === currentSourcePath + ? currentProjectSnapshot + : createProjectData(currentSourcePath, currentPersistedEditorState); + const thumbnailDataUrl = await captureProjectThumbnail(); + const result = await window.electronAPI.saveProjectFileNamed( + projectData, + trimmedProjectName, + thumbnailDataUrl, + ); + + if (result.canceled) { + toast.info("Project save canceled"); + return false; + } + + if (!result.success) { + toast.error(result.message || "Failed to save project"); + return false; + } + + if (result.path) { + setCurrentProjectPath(result.path); + } + setLastSavedSnapshot(cloneStructured(projectData)); + await refreshProjectLibrary(); + toast.success(result.path ? `Project saved to ${result.path}` : "Project saved"); + return true; + } finally { + remountPreview(); + } + }, + [ + captureProjectThumbnail, + currentPersistedEditorState, + currentProjectSnapshot, + currentSourcePath, + refreshProjectLibrary, + remountPreview, + ], + ); + + const closeProjectNameEditor = useCallback(() => { + setProjectNameDraft(projectDisplayName); + setIsEditingProjectName(false); + }, [projectDisplayName]); + + const handleProjectNameSubmit = useCallback( + async (event?: React.FormEvent) => { + event?.preventDefault(); + const trimmedProjectName = projectNameDraft.trim(); + if (!trimmedProjectName) { + closeProjectNameEditor(); + return; + } + + setIsSavingProjectName(true); + const saved = await saveProjectWithName(trimmedProjectName); + setIsSavingProjectName(false); + + if (saved) { + setIsEditingProjectName(false); + return; + } + + projectNameInputRef.current?.focus(); + projectNameInputRef.current?.select(); + }, + [closeProjectNameEditor, projectNameDraft, saveProjectWithName], + ); + const handleOpenProjectFromLibrary = useCallback( async (projectPath: string) => { const result = await window.electronAPI.openProjectFileAtPath(projectPath); @@ -2808,12 +2917,31 @@ export default function VideoEditor() { const handleClipDelete = useCallback( (id: string) => { + const deletedClip = clipRegions.find((clip) => clip.id === id); setClipRegions((prev) => prev.filter((clip) => clip.id !== id)); + if (deletedClip) { + const { startMs, endMs } = deletedClip; + setZoomRegions((prev) => + prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + ); + setAnnotationRegions((prev) => + prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + ); + setTrimRegions((prev) => + prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + ); + setSpeedRegions((prev) => + prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + ); + setAudioRegions((prev) => + prev.filter((region) => region.startMs < startMs || region.endMs > endMs), + ); + } if (selectedClipId === id) { setSelectedClipId(null); } }, - [selectedClipId], + [clipRegions, selectedClipId], ); const handleSelectSpeed = useCallback((id: string | null) => { @@ -4138,17 +4266,6 @@ export default function VideoEditor() { return top > 0 || left > 0 || bottom > 0 || right > 0; }, [cropRegion]); - const openRecordingsFolder = useCallback(async () => { - try { - const result = await window.electronAPI.openRecordingsFolder(); - if (!result.success) { - toast.error(result.message || result.error || "Failed to open recordings folder."); - } - } catch (error) { - toast.error(`Failed to open recordings folder: ${String(error)}`); - } - }, []); - const revealExportedFile = useCallback(async () => { if (!exportedFilePath) return; @@ -4293,13 +4410,14 @@ export default function VideoEditor() { style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > @@ -4330,48 +4448,66 @@ export default function VideoEditor() {
- - {projectDisplayName} - - - .recordly - + {isEditingProjectName ? ( +
void handleProjectNameSubmit(event)} + className="flex max-w-[min(52vw,460px)] items-baseline gap-1 rounded-[7px] border border-foreground/10 bg-editor-panel/[0.88] px-2.5 py-1 shadow-[0_10px_28px_rgba(0,0,0,0.18)]" + > + {hasUnsavedChanges ? ( + + ) : null} + setProjectNameDraft(event.target.value)} + onBlur={() => { + if (!isSavingProjectName) { + closeProjectNameEditor(); + } + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + closeProjectNameEditor(); + } + }} + disabled={isSavingProjectName} + className="min-w-[10ch] max-w-[min(40vw,360px)] bg-transparent text-sm font-semibold tracking-tight text-foreground/95 outline-none placeholder:text-muted-foreground/60 disabled:cursor-wait" + style={{ width: `${Math.max(projectNameDraft.length, 10)}ch` }} + aria-label={t("editor.project.renameInput", "Project name")} + /> + + .recordly + + + ) : ( + + )}
- - -
); } diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index a59286e7..f2aba723 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -30,7 +30,7 @@ export default function Row({ id, children, label, hint, isEmpty, labelColor = " {hint}
)} -
+
{children}