fix(editor): port project save UX and clip cleanup to main

This commit is contained in:
webadderall
2026-04-21 11:46:07 +10:00
parent 7494c90863
commit 337569739c
6 changed files with 274 additions and 58 deletions
+11
View File
@@ -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;
+61 -4
View File
@@ -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()
+12
View File
@@ -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");
},
+188 -52
View File
@@ -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<string | null>(null);
const [projectLibraryEntries, setProjectLibraryEntries] = useState<ProjectLibraryEntry[]>([]);
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<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
@@ -631,6 +633,7 @@ export default function VideoEditor() {
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const projectBrowserTriggerRef = useRef<HTMLButtonElement | null>(null);
const projectBrowserFallbackTriggerRef = useRef<HTMLButtonElement | null>(null);
const projectNameInputRef = useRef<HTMLInputElement | null>(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<HTMLFormElement>) => {
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}
>
<Button
ref={projectBrowserTriggerRef}
type="button"
variant="ghost"
size="sm"
onClick={() => void openRecordingsFolder()}
onClick={handleOpenProjectBrowser}
className={APP_HEADER_ICON_BUTTON_CLASS}
title={t("common.app.manageRecordings", "Open recordings folder")}
aria-label={t("common.app.manageRecordings", "Open recordings folder")}
title={t("editor.project.projects", "Open projects")}
aria-label={t("editor.project.projects", "Open projects")}
>
<FolderOpen className="h-4 w-4" />
</Button>
@@ -4330,48 +4448,66 @@ export default function VideoEditor() {
</Button>
</div>
<div
className="pointer-events-none absolute left-1/2 flex min-w-0 -translate-x-1/2 items-baseline justify-center gap-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
className="absolute left-1/2 flex min-w-0 -translate-x-1/2 items-center justify-center"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<span className="text-sm font-semibold tracking-tight text-foreground/90">
{projectDisplayName}
</span>
<span className="text-xs font-medium tracking-tight text-muted-foreground/70">
.recordly
</span>
{isEditingProjectName ? (
<form
onSubmit={(event) => 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 ? (
<span className="mt-[1px] size-2 shrink-0 rounded-full bg-[#2563EB]" />
) : null}
<input
ref={projectNameInputRef}
type="text"
value={projectNameDraft}
onChange={(event) => 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")}
/>
<span className="shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
.recordly
</span>
</form>
) : (
<button
type="button"
onClick={() => setIsEditingProjectName(true)}
className="inline-flex max-w-[min(52vw,460px)] items-baseline gap-1 rounded-[7px] px-2.5 py-1 transition-colors hover:bg-foreground/5"
title={t("editor.project.renameTitle", "Rename project")}
aria-label={t("editor.project.renameTitle", "Rename project")}
>
{hasUnsavedChanges ? (
<span className="mt-[1px] size-2 shrink-0 rounded-full bg-[#2563EB]" />
) : null}
<span className="truncate text-sm font-semibold tracking-tight text-foreground/90">
{projectDisplayName}
</span>
<span className="shrink-0 text-xs font-medium tracking-tight text-muted-foreground/70">
.recordly
</span>
</button>
)}
</div>
<div
className="flex items-center gap-2 justify-self-end pr-3"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<Button
ref={projectBrowserTriggerRef}
type="button"
onClick={handleOpenProjectBrowser}
className="inline-flex h-8 min-w-[96px] items-center justify-center gap-1.5 rounded-[5px] bg-neutral-800 px-4 text-white shadow-[0_14px_32px_rgba(0,0,0,0.18)] transition-colors hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90"
>
<FolderOpen className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">
{t("editor.project.projects", "Projects")}
</span>
</Button>
<Button
type="button"
onClick={handleSaveProject}
className="inline-flex h-8 min-w-[96px] items-center justify-center gap-1.5 rounded-[5px] bg-neutral-800 px-4 text-white transition-colors hover:bg-neutral-700 dark:bg-white dark:text-black dark:hover:bg-white/90"
>
<span
className={`${hasUnsavedChanges ? "flex" : "hidden"} size-2 relative`}
>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#2563EB] opacity-75"></span>
<span className="relative inline-flex size-2 rounded-full bg-[#2563EB]"></span>
</span>
<Save className="h-4 w-4" weight="fill" />
<span className="text-sm font-semibold tracking-tight">
{t("common.actions.save")}
</span>
</Button>
<div className="mx-1 h-5 w-px bg-foreground/10" />
<DropdownMenu
open={showExportDropdown}
onOpenChange={setShowExportDropdown}
@@ -83,7 +83,7 @@ export default function AudioWaveform({ peaks }: AudioWaveformProps) {
<canvas
ref={setCanvasRef}
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ zIndex: 0 }}
style={{ display: "block" }}
/>
);
}
+1 -1
View File
@@ -30,7 +30,7 @@ export default function Row({ id, children, label, hint, isEmpty, labelColor = "
<span className="text-[11px] text-foreground/15 font-medium">{hint}</span>
</div>
)}
<div ref={setNodeRef} className="relative h-full min-h-0" style={rowStyle}>
<div ref={setNodeRef} className="relative h-full min-h-0 overflow-hidden" style={rowStyle}>
{children}
</div>
</div>