From b3469c469b474a3a3aa32b909088b8611ee96f58 Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 12:28:04 +0200 Subject: [PATCH 1/5] feat: replace native OS close dialog with custom in-app dialog --- electron/electron-env.d.ts | 2 + electron/main.ts | 43 ++++------ electron/preload.ts | 8 ++ .../video-editor/UnsavedChangesDialog.tsx | 78 +++++++++++++++++++ src/components/video-editor/VideoEditor.tsx | 31 ++++++++ 5 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 src/components/video-editor/UnsavedChangesDialog.tsx diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d82946d..f4b379fb5 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -143,6 +143,8 @@ interface Window { setMicrophoneExpanded: (expanded: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise | boolean) => () => void; + onRequestCloseConfirm: (callback: () => void) => () => void; + sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void; setLocale: (locale: string) => Promise; }; } diff --git a/electron/main.ts b/electron/main.ts index ad0a33fc0..5540419de 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import { app, BrowserWindow, - dialog, ipcMain, Menu, nativeImage, @@ -288,35 +287,27 @@ function createEditorWindowWrapper() { event.preventDefault(); - const choice = dialog.showMessageBoxSync(mainWindow!, { - type: "warning", - buttons: [ - mainT("dialogs", "unsavedChanges.saveAndClose"), - mainT("dialogs", "unsavedChanges.discardAndClose"), - mainT("common", "actions.cancel"), - ], - defaultId: 0, - cancelId: 2, - title: mainT("dialogs", "unsavedChanges.title"), - message: mainT("dialogs", "unsavedChanges.message"), - detail: mainT("dialogs", "unsavedChanges.detail"), - }); - const windowToClose = mainWindow; if (!windowToClose || windowToClose.isDestroyed()) return; - if (choice === 0) { - // Save & Close — tell renderer to save, then close - windowToClose.webContents.send("request-save-before-close"); - ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => { - if (!shouldClose) return; + // Ask renderer to show the custom in-app dialog + windowToClose.webContents.send("request-close-confirm"); + + ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => { + if (!windowToClose || windowToClose.isDestroyed()) return; + + if (choice === "save") { + // Tell renderer to save the project, then close when done + windowToClose.webContents.send("request-save-before-close"); + ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => { + if (!shouldClose) return; + forceCloseEditorWindow(windowToClose); + }); + } else if (choice === "discard") { forceCloseEditorWindow(windowToClose); - }); - } else if (choice === 1) { - // Discard & Close - forceCloseEditorWindow(windowToClose); - } - // choice === 2: Cancel — do nothing, window stays open + } + // "cancel": do nothing, window stays open + }); }); } diff --git a/electron/preload.ts b/electron/preload.ts index 46e16f043..2e065bd5a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -163,4 +163,12 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("request-save-before-close", listener); return () => ipcRenderer.removeListener("request-save-before-close", listener); }, + onRequestCloseConfirm: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("request-close-confirm", listener); + return () => ipcRenderer.removeListener("request-close-confirm", listener); + }, + sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => { + ipcRenderer.send("close-confirm-response", choice); + }, }); diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx new file mode 100644 index 000000000..9b8ee0303 --- /dev/null +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -0,0 +1,78 @@ +import { Save, Trash2, X } from "lucide-react"; +import { useScopedT } from "@/contexts/I18nContext"; + +interface UnsavedChangesDialogProps { + isOpen: boolean; + onSaveAndClose: () => void; + onDiscardAndClose: () => void; + onCancel: () => void; +} + +export function UnsavedChangesDialog({ + isOpen, + onSaveAndClose, + onDiscardAndClose, + onCancel, +}: UnsavedChangesDialogProps) { + const td = useScopedT("dialogs"); + const tc = useScopedT("common"); + + if (!isOpen) return null; + + return ( + <> +
+
+
+ OpenScreen +

+ {td("unsavedChanges.title")} +

+ +
+ +

{td("unsavedChanges.message")}

+

{td("unsavedChanges.detail")}

+ +
+ + + +
+
+ + ); +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 7adc558e8..14c695abe 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -74,6 +74,7 @@ import { type ZoomFocusMode, type ZoomRegion, } from "./types"; +import { UnsavedChangesDialog } from "./UnsavedChangesDialog"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; export default function VideoEditor() { @@ -144,6 +145,7 @@ export default function VideoEditor() { format: string; } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); + const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false); const playerContainerRef = useRef(null); const videoPlaybackRef = useRef(null); @@ -524,6 +526,28 @@ export default function VideoEditor() { return () => cleanup(); }, [saveProject]); + useEffect(() => { + const cleanup = window.electronAPI.onRequestCloseConfirm(() => { + setShowCloseConfirmDialog(true); + }); + return () => cleanup(); + }, []); + + const handleCloseConfirmSave = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("save"); + }, []); + + const handleCloseConfirmDiscard = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("discard"); + }, []); + + const handleCloseConfirmCancel = useCallback(() => { + setShowCloseConfirmDialog(false); + window.electronAPI.sendCloseConfirmResponse("cancel"); + }, []); + const handleSaveProject = useCallback(async () => { await saveProject(false); }, [saveProject]); @@ -2066,6 +2090,13 @@ export default function VideoEditor() { exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined } /> + +
); } From 36076aaf2a3efd77213d11474c38d81177e1e7be Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 13:08:52 +0200 Subject: [PATCH 2/5] fix: address code review feedback on custom close dialog --- electron/main.ts | 7 ++- .../video-editor/UnsavedChangesDialog.tsx | 63 +++++++++---------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 5540419de..94f0a4274 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -252,6 +252,7 @@ function updateTrayMenu(recording: boolean = false) { let editorHasUnsavedChanges = false; let isForceClosing = false; +let isCloseConfirmInFlight = false; ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => { editorHasUnsavedChanges = hasChanges; @@ -283,9 +284,10 @@ function createEditorWindowWrapper() { editorHasUnsavedChanges = false; mainWindow.on("close", (event) => { - if (isForceClosing || !editorHasUnsavedChanges) return; + if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return; event.preventDefault(); + isCloseConfirmInFlight = true; const windowToClose = mainWindow; if (!windowToClose || windowToClose.isDestroyed()) return; @@ -294,6 +296,7 @@ function createEditorWindowWrapper() { windowToClose.webContents.send("request-close-confirm"); ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => { + isCloseConfirmInFlight = false; if (!windowToClose || windowToClose.isDestroyed()) return; if (choice === "save") { @@ -306,7 +309,7 @@ function createEditorWindowWrapper() { } else if (choice === "discard") { forceCloseEditorWindow(windowToClose); } - // "cancel": do nothing, window stays open + // "cancel": flag reset, window stays open }); }); } diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index 9b8ee0303..a0623badb 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -1,4 +1,11 @@ -import { Save, Trash2, X } from "lucide-react"; +import { Save, Trash2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; interface UnsavedChangesDialogProps { @@ -17,41 +24,33 @@ export function UnsavedChangesDialog({ const td = useScopedT("dialogs"); const tc = useScopedT("common"); - if (!isOpen) return null; - return ( - <> -
-
-
- OpenScreen -

- {td("unsavedChanges.title")} -

- -
+ !open && onCancel()}> + + +
+ + + {td("unsavedChanges.title")} + +
+

{td("unsavedChanges.message")}

-

{td("unsavedChanges.detail")}

+ + {td("unsavedChanges.detail")} +
-
- + + ); } From b2cc7226135117165e0b0fc539a913b5e4246d54 Mon Sep 17 00:00:00 2001 From: makaradam Date: Sat, 2 May 2026 13:43:20 +0200 Subject: [PATCH 3/5] fix: use getAssetPath for logo so it resolves correctly in packaged app --- src/components/video-editor/UnsavedChangesDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index a0623badb..f3f88dce0 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -7,6 +7,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; +import getAssetPath from "@/lib/assetPath"; interface UnsavedChangesDialogProps { isOpen: boolean; @@ -30,7 +31,7 @@ export function UnsavedChangesDialog({
Date: Sat, 2 May 2026 14:33:14 +0200 Subject: [PATCH 4/5] fix: use relative path for logo so it resolves in packaged app ./openscreen.png resolves correctly both in dev (Vite serves public/) and in production (loadFile sets base to dist/, where public assets land inside the asar). getAssetPath points to extraResources, which is the wrong location for bundled dist assets. --- src/components/video-editor/UnsavedChangesDialog.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index f3f88dce0..902b1427b 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -7,7 +7,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useScopedT } from "@/contexts/I18nContext"; -import getAssetPath from "@/lib/assetPath"; interface UnsavedChangesDialogProps { isOpen: boolean; @@ -31,7 +30,7 @@ export function UnsavedChangesDialog({
Date: Sat, 2 May 2026 14:36:59 +0200 Subject: [PATCH 5/5] fix: scope IPC close-confirm responses to the originating window Both ipcMain.once handlers now check event.sender.id against windowToClose.webContents.id and ignore messages from any other renderer, preventing cross-window response mix-ups if multiple editor windows are ever open simultaneously. --- electron/main.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 94f0a4274..3e0b2321f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -295,14 +295,16 @@ function createEditorWindowWrapper() { // Ask renderer to show the custom in-app dialog windowToClose.webContents.send("request-close-confirm"); - ipcMain.once("close-confirm-response", (_, choice: "save" | "discard" | "cancel") => { + ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => { + if (event.sender.id !== windowToClose?.webContents.id) return; isCloseConfirmInFlight = false; if (!windowToClose || windowToClose.isDestroyed()) return; if (choice === "save") { // Tell renderer to save the project, then close when done windowToClose.webContents.send("request-save-before-close"); - ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => { + ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => { + if (event.sender.id !== windowToClose?.webContents.id) return; if (!shouldClose) return; forceCloseEditorWindow(windowToClose); });