mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
fix(editor): port project save UX and clip cleanup to main
This commit is contained in:
Vendored
+11
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user