Merge pull request #304 from webadderall/fix/editor-main-port

Fix editor project save UX and clip cleanup
This commit is contained in:
webadderall
2026-04-21 15:27:51 +10:00
committed by GitHub
7 changed files with 545 additions and 70 deletions
+13
View File
@@ -341,6 +341,19 @@ interface Window {
) => Promise<{
success: boolean;
path?: string;
projectId?: string;
message?: string;
canceled?: boolean;
error?: string;
}>;
saveProjectFileNamed: (
projectData: unknown,
projectName: string,
thumbnailDataUrl?: string | null,
) => Promise<{
success: boolean;
path?: string;
projectId?: string;
message?: string;
canceled?: boolean;
error?: string;
+243 -6
View File
@@ -1,4 +1,5 @@
import { constants as fsConstants } from "node:fs";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { dialog, ipcMain, shell } from "electron";
@@ -18,14 +19,17 @@ import {
} from "../state";
import {
getProjectsDir,
getProjectThumbnailPath,
isAllowedLocalMediaPath,
isPathInsideDirectory,
isTrustedProjectPath,
listProjectLibraryEntries,
loadRecentProjectPaths,
loadProjectFromPath,
persistRecordingsDirectorySetting,
replaceApprovedSessionLocalReadPaths,
rememberRecentProject,
saveRecentProjectPaths,
saveProjectThumbnail,
} from "../project/manager";
import {
@@ -41,6 +45,161 @@ function normalizeRecordingTimeOffsetMs(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0;
}
/**
* Produces a filesystem-safe project base name without the project extension.
*/
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 withoutInvalidFilesystemChars = withoutExtension.replace(/[<>:"/\\|?*]/g, "");
const withoutControlChars = Array.from(withoutInvalidFilesystemChars)
.filter((character) => character.charCodeAt(0) > 31)
.join("");
const sanitizedName = withoutControlChars
.replace(/\s+/g, " ")
.replace(/[. ]+$/g, "")
.trim();
return sanitizedName || null;
}
/**
* Extracts the persisted source video path from a saved project payload.
*/
function getProjectVideoPath(projectData: unknown) {
if (!projectData || typeof projectData !== "object") {
return null;
}
const candidate = projectData as { videoPath?: unknown };
return typeof candidate.videoPath === "string" ? candidate.videoPath : null;
}
function getProjectId(projectData: unknown) {
if (!projectData || typeof projectData !== "object") {
return null;
}
const candidate = projectData as { projectId?: unknown };
return typeof candidate.projectId === "string" && candidate.projectId.trim().length > 0
? candidate.projectId
: null;
}
function withProjectId(projectData: unknown, projectId: string) {
if (!projectData || typeof projectData !== "object" || Array.isArray(projectData)) {
return projectData;
}
return {
...projectData,
projectId,
};
}
function ensureProjectDataHasProjectId(projectData: unknown) {
const existingProjectId = getProjectId(projectData);
if (existingProjectId) {
return {
projectId: existingProjectId,
projectData,
};
}
const projectId = randomUUID();
return {
projectId,
projectData: withProjectId(projectData, projectId),
};
}
async function resolveComparablePath(filePath: string) {
return fs.realpath(filePath).catch(() => path.resolve(filePath));
}
/**
* Prevents a named save from silently overwriting a different project file.
*/
async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject(
targetProjectPath: string,
projectData: unknown,
activeProjectPath?: string | null,
) {
try {
await fs.stat(targetProjectPath);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return { success: true };
}
throw error;
}
const targetResolvedPath = await resolveComparablePath(targetProjectPath);
if (activeProjectPath) {
const activeResolvedPath = await resolveComparablePath(activeProjectPath);
if (activeResolvedPath === targetResolvedPath) {
return { success: true };
}
}
const incomingProjectId = getProjectId(projectData);
const incomingVideoPath = getProjectVideoPath(projectData);
try {
const existingProjectRaw = await fs.readFile(targetProjectPath, "utf-8");
const existingProjectData = JSON.parse(existingProjectRaw) as unknown;
const existingProjectId = getProjectId(existingProjectData);
const existingVideoPath = getProjectVideoPath(existingProjectData);
if (existingProjectId && incomingProjectId) {
if (existingProjectId === incomingProjectId) {
return { success: true };
}
return {
success: false,
message: "A different project already uses this name",
};
}
if (existingVideoPath && incomingVideoPath && existingVideoPath !== incomingVideoPath) {
return {
success: false,
message: "A different project already uses this name",
};
}
if (!existingProjectId && !incomingProjectId && existingVideoPath && incomingVideoPath) {
return {
success: false,
message: "Unable to verify project identity for the chosen name",
};
}
return {
success: false,
message: "Unable to verify project identity for the chosen name",
};
} catch (error) {
console.error("Failed to verify existing named project before overwrite:", error);
return {
success: false,
message: "Unable to verify project identity for the chosen name",
};
}
}
export function registerProjectHandlers() {
ipcMain.handle('reveal-in-folder', async (_, filePath: string) => {
try {
@@ -126,26 +285,26 @@ export function registerProjectHandlers() {
ipcMain.handle('save-project-file', async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string, thumbnailDataUrl?: string | null) => {
try {
const projectsDir = await getProjectsDir()
const preparedProject = ensureProjectDataHasProjectId(projectData)
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
? existingProjectPath
: null
if (trustedExistingProjectPath) {
await fs.writeFile(trustedExistingProjectPath, JSON.stringify(projectData, null, 2), 'utf-8')
await fs.writeFile(trustedExistingProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8')
setCurrentProjectPath(trustedExistingProjectPath)
await saveProjectThumbnail(trustedExistingProjectPath, thumbnailDataUrl)
await rememberRecentProject(trustedExistingProjectPath)
return {
success: true,
path: trustedExistingProjectPath,
projectId: preparedProject.projectId,
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 safeName = normalizeProjectSaveName(suggestedName) || `project-${Date.now()}`
const defaultName = `${safeName}.${PROJECT_FILE_EXTENSION}`
const result = await dialog.showSaveDialog({
title: 'Save Recordly Project',
@@ -165,7 +324,7 @@ export function registerProjectHandlers() {
}
}
await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), 'utf-8')
await fs.writeFile(result.filePath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8')
setCurrentProjectPath(result.filePath)
await saveProjectThumbnail(result.filePath, thumbnailDataUrl)
await rememberRecentProject(result.filePath)
@@ -173,6 +332,7 @@ export function registerProjectHandlers() {
return {
success: true,
path: result.filePath,
projectId: preparedProject.projectId,
message: 'Project saved successfully'
}
} catch (error) {
@@ -185,6 +345,83 @@ 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 preparedProject = ensureProjectDataHasProjectId(projectData)
const activeProjectPath = isTrustedProjectPath(currentProjectPath)
? currentProjectPath
: null
const targetProjectPath = path.join(
projectsDir,
`${normalizedProjectName}.${PROJECT_FILE_EXTENSION}`,
)
const overwriteCheck = await ensureNamedProjectSaveDoesNotOverwriteDifferentProject(
targetProjectPath,
preparedProject.projectData,
activeProjectPath,
)
if (!overwriteCheck.success) {
return overwriteCheck
}
await fs.writeFile(targetProjectPath, JSON.stringify(preparedProject.projectData, null, 2), 'utf-8')
await saveProjectThumbnail(targetProjectPath, thumbnailDataUrl)
await rememberRecentProject(targetProjectPath)
if (activeProjectPath) {
const [activeResolvedPath, targetResolvedPath] = await Promise.all([
resolveComparablePath(activeProjectPath),
resolveComparablePath(targetProjectPath),
])
if (activeResolvedPath !== targetResolvedPath) {
await fs.unlink(activeProjectPath).catch((unlinkError: NodeJS.ErrnoException) => {
if (unlinkError.code !== 'ENOENT') {
throw unlinkError
}
})
await fs.rm(getProjectThumbnailPath(activeProjectPath), { force: true }).catch(() => undefined)
const recentProjectPaths = await loadRecentProjectPaths()
const filteredRecentProjectPaths: string[] = []
for (const recentProjectPath of recentProjectPaths) {
const recentResolvedPath = await resolveComparablePath(recentProjectPath)
if (recentResolvedPath !== activeResolvedPath) {
filteredRecentProjectPaths.push(recentProjectPath)
}
}
await saveRecentProjectPaths(filteredRecentProjectPaths)
}
}
setCurrentProjectPath(targetProjectPath)
return {
success: true,
path: targetProjectPath,
projectId: preparedProject.projectId,
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");
},
+269 -62
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({
@@ -1568,7 +1592,11 @@ export default function VideoEditor() {
setLastSavedSnapshot(
cloneStructured(
createProjectData(sourcePath, buildPersistedEditorState(normalizedEditor)),
createProjectData(
sourcePath,
buildPersistedEditorState(normalizedEditor),
project.projectId ?? null,
),
),
);
await refreshProjectLibrary();
@@ -1581,8 +1609,12 @@ export default function VideoEditor() {
if (!currentSourcePath) {
return null;
}
return createProjectData(currentSourcePath, currentPersistedEditorState);
}, [currentPersistedEditorState, currentSourcePath]);
return createProjectData(
currentSourcePath,
currentPersistedEditorState,
lastSavedSnapshot?.projectId ?? null,
);
}, [currentPersistedEditorState, currentSourcePath, lastSavedSnapshot?.projectId]);
const syncRecordingSessionWebcam = useCallback(
async (webcamPath: string | null) => {
@@ -1672,12 +1704,11 @@ export default function VideoEditor() {
const hasUnsavedChanges = useMemo(
() =>
Boolean(
currentProjectPath &&
currentProjectSnapshot &&
lastSavedSnapshot &&
!areDeepEqual(currentProjectSnapshot, lastSavedSnapshot),
currentProjectSnapshot &&
(!lastSavedSnapshot ||
!areDeepEqual(currentProjectSnapshot, lastSavedSnapshot)),
),
[currentProjectPath, currentProjectSnapshot, lastSavedSnapshot],
[currentProjectSnapshot, lastSavedSnapshot],
);
useEffect(() => {
@@ -2096,7 +2127,11 @@ export default function VideoEditor() {
const projectData =
currentProjectSnapshot?.videoPath === currentSourcePath
? currentProjectSnapshot
: createProjectData(currentSourcePath, currentPersistedEditorState);
: createProjectData(
currentSourcePath,
currentPersistedEditorState,
lastSavedSnapshot?.projectId ?? null,
);
const fileNameBase =
currentSourcePath
@@ -2135,7 +2170,15 @@ export default function VideoEditor() {
if (result.path) {
setCurrentProjectPath(result.path);
}
setLastSavedSnapshot(cloneStructured(projectData));
setLastSavedSnapshot(
cloneStructured(
createProjectData(
projectData.videoPath,
projectData.editor,
result.projectId ?? projectData.projectId ?? null,
),
),
);
await refreshProjectLibrary();
toast.success(`Project saved to ${result.path}`);
@@ -2150,6 +2193,7 @@ export default function VideoEditor() {
currentProjectPath,
currentProjectSnapshot,
currentPersistedEditorState,
lastSavedSnapshot?.projectId,
refreshProjectLibrary,
remountPreview,
],
@@ -2178,6 +2222,119 @@ export default function VideoEditor() {
}
}, [saveProject]);
/**
* Saves the current project directly into the projects library under a chosen name.
*/
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,
lastSavedSnapshot?.projectId ?? null,
);
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(
createProjectData(
projectData.videoPath,
projectData.editor,
result.projectId ?? projectData.projectId ?? null,
),
),
);
await refreshProjectLibrary();
toast.success(result.path ? `Project saved to ${result.path}` : "Project saved");
return true;
} finally {
remountPreview();
}
},
[
captureProjectThumbnail,
currentPersistedEditorState,
currentProjectSnapshot,
currentSourcePath,
lastSavedSnapshot?.projectId,
refreshProjectLibrary,
remountPreview,
],
);
/**
* Resets the inline project-name editor back to the current saved display name.
*/
const closeProjectNameEditor = useCallback(() => {
setProjectNameDraft(projectDisplayName);
setIsEditingProjectName(false);
}, [projectDisplayName]);
/**
* Commits the inline project-name editor and persists the project under that name.
*/
const handleProjectNameSubmit = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
const trimmedProjectName = projectNameDraft.trim();
if (!trimmedProjectName) {
closeProjectNameEditor();
return;
}
setIsSavingProjectName(true);
let saved = false;
try {
saved = await saveProjectWithName(trimmedProjectName);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
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);
@@ -2729,6 +2886,16 @@ export default function VideoEditor() {
const oldClip = clipRegions.find((c) => c.id === id);
const newStart = Math.round(span.start);
const newEnd = Math.round(span.end);
const removedSegments = oldClip
? [
...(newStart > oldClip.startMs
? [{ startMs: oldClip.startMs, endMs: newStart }]
: []),
...(newEnd < oldClip.endMs
? [{ startMs: newEnd, endMs: oldClip.endMs }]
: []),
]
: [];
if (oldClip) {
const startDelta = newStart - oldClip.startMs;
@@ -2754,6 +2921,22 @@ export default function VideoEditor() {
}
}
if (removedSegments.length > 0) {
const removeTrimmedRegions = <T extends { startMs: number; endMs: number }>(
regions: T[],
): T[] =>
regions.filter(
(region) =>
!removedSegments.some(
(segment) => region.startMs < segment.endMs && region.endMs > segment.startMs,
),
);
setZoomRegions((prev) => removeTrimmedRegions(prev));
setAnnotationRegions((prev) => removeTrimmedRegions(prev));
setSpeedRegions((prev) => removeTrimmedRegions(prev));
setAudioRegions((prev) => removeTrimmedRegions(prev));
}
setClipRegions((prev) =>
prev.map((clip) =>
clip.id === id ? { ...clip, startMs: newStart, endMs: newEnd } : clip,
@@ -2808,12 +2991,28 @@ 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.endMs <= startMs || region.startMs >= endMs),
);
setAnnotationRegions((prev) =>
prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs),
);
setSpeedRegions((prev) =>
prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs),
);
setAudioRegions((prev) =>
prev.filter((region) => region.endMs <= startMs || region.startMs >= endMs),
);
}
if (selectedClipId === id) {
setSelectedClipId(null);
}
},
[selectedClipId],
[clipRegions, selectedClipId],
);
const handleSelectSpeed = useCallback((id: string | null) => {
@@ -4138,17 +4337,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 +4481,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 +4519,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}
@@ -118,6 +118,7 @@ export interface ProjectEditorState {
export interface EditorProjectData {
version: number;
projectId?: string;
videoPath: string;
editor: Partial<ProjectEditorState>;
}
@@ -281,6 +282,7 @@ export function validateProjectData(candidate: unknown): candidate is EditorProj
if (!candidate || typeof candidate !== "object") return false;
const project = candidate as Partial<EditorProjectData>;
if (typeof project.version !== "number") return false;
if (project.projectId !== undefined && typeof project.projectId !== "string") return false;
if (typeof project.videoPath !== "string" || !project.videoPath) return false;
if (!project.editor || typeof project.editor !== "object") return false;
return true;
@@ -861,9 +863,13 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
export function createProjectData(
videoPath: string,
editor: Partial<ProjectEditorState>,
projectId?: string | null,
): EditorProjectData {
return {
version: PROJECT_VERSION,
...(typeof projectId === "string" && projectId.trim().length > 0
? { projectId }
: {}),
videoPath,
editor,
};
@@ -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>