diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8a4eedd63..f5155eef4 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -221,6 +221,7 @@ interface Window { getAssetBasePath: () => Promise; getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; + switchToRecording: () => Promise; openSourceSelector: () => Promise; selectSource: (source: ProcessedDesktopSource) => Promise; showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>; diff --git a/electron/main.ts b/electron/main.ts index 38f4333ff..2ab9a1706 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -879,6 +879,39 @@ function createEditorWindowWrapper() { return editorWindow; } +/** + * Leaves the editor and brings the recording UI (HUD overlay) back. + * + * The HUD is shown *before* the editor is closed so that a window always exists: + * on non-macOS platforms `window-all-closed` quits the app, and reassigning + * `mainWindow` first keeps the editor's own `closed` handler from clearing it. + * + * The renderer is responsible for confirming unsaved changes, so the editor is + * closed through `closeEditorWindowBypassingUnsavedPrompt` to avoid showing the + * native "Unsaved Changes" prompt a second time. + */ +function returnToRecording() { + const editorWindow = getExistingEditorWindow(); + const hudWindow = getHudOverlayWindow(); + + if (hudWindow && !hudWindow.isDestroyed()) { + mainWindow = hudWindow; + showHudOverlayFromTray(); + } else { + mainWindow = null; + createWindow(); + } + + if (editorWindow) { + closeEditorWindowBypassingUnsavedPrompt(editorWindow); + } +} + +ipcMain.handle("switch-to-recording", () => { + console.log("[switch-to-recording] Returning to the recording UI"); + returnToRecording(); +}); + function createSourceSelectorWindowWrapper() { sourceSelectorWindow = createSourceSelectorWindow(); sourceSelectorWindow.on("closed", () => { diff --git a/electron/preload.ts b/electron/preload.ts index e55d42cbd..d40a4802b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -482,6 +482,9 @@ contextBridge.exposeInMainWorld("electronAPI", { switchToEditor: () => { return ipcRenderer.invoke("switch-to-editor"); }, + switchToRecording: () => { + return ipcRenderer.invoke("switch-to-recording"); + }, openSourceSelector: () => { return ipcRenderer.invoke("open-source-selector"); }, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index c2e16ed60..edb672731 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -6,6 +6,7 @@ import { Crop, Cursor, DownloadSimple as Download, + FileText, FolderOpen, Gear, Pause, @@ -20,6 +21,7 @@ import { Sparkle, ArrowCounterClockwise as Undo2, UserCircle as User, + VideoCamera, SpeakerLow as Volume1, SpeakerHigh as Volume2, SpeakerX as VolumeX, @@ -44,6 +46,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; @@ -694,6 +698,7 @@ export default function VideoEditor() { const nextAudioIdRef = useRef(1); const { shortcuts, isMac } = useShortcuts(); + const primaryModifierLabel = isMac ? "⌘" : "Ctrl+"; const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order const exporterRef = useRef(null); @@ -3253,6 +3258,40 @@ export default function VideoEditor() { [hasUnsavedChanges, openUnsavedChangesDialog, saveProject], ); + /** + * Leaves the editor and brings the recording UI back, discarding the current + * project unless the user chooses to save it first. + */ + const handleReturnToRecording = useCallback(async () => { + // Leaving closes the editor window, which would silently kill a running export. + if (isExporting) { + toast.error( + t( + "editor.actions.returnToRecordingBlockedByExport", + "Wait for the export to finish before returning to recording", + ), + ); + return; + } + + if (!(await confirmReplaceSourceWithUnsavedChanges("return to recording"))) { + return; + } + + try { + videoPlaybackRef.current?.pause(); + } catch { + // no-op + } + setIsPlaying(false); + + try { + await window.electronAPI.switchToRecording(); + } catch (error) { + toast.error(getErrorMessage(error)); + } + }, [confirmReplaceSourceWithUnsavedChanges, isExporting, t]); + const handleOpenProjectFromLibrary = useCallback( async (projectPath: string) => { if (!(await confirmReplaceSourceWithUnsavedChanges("open another project"))) { @@ -4536,6 +4575,64 @@ export default function VideoEditor() { return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); }, [shortcuts, isMac, handleUndo, handleRedo, startPlayback]); + // Project shortcuts. On macOS the native File menu owns Cmd+S / Cmd+Shift+S / Cmd+O and + // swallows those keystrokes before they reach the renderer; every other platform runs + // without an application menu, so the editor has to bind them itself. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + const isEditableTarget = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target?.isContentEditable; + + if (isEditableTarget) { + return; + } + + const usesPrimaryModifier = isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey; + if (!usesPrimaryModifier || e.altKey) { + return; + } + + const key = e.key.toLowerCase(); + + if (key === "n") { + e.preventDefault(); + void handleReturnToRecording(); + return; + } + + if (isMac) { + return; + } + + if (key === "s") { + e.preventDefault(); + if (e.shiftKey) { + void handleSaveProjectAs(); + } else { + void handleSaveProject(); + } + return; + } + + if (key === "o" && !e.shiftKey) { + e.preventDefault(); + void handleOpenProjectBrowser(); + } + }; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }, [ + isMac, + handleOpenProjectBrowser, + handleReturnToRecording, + handleSaveProject, + handleSaveProjectAs, + ]); + useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { setSelectedZoomId(null); @@ -5787,14 +5884,23 @@ export default function VideoEditor() {
{error}
- +
+ + +
{projectBrowser} {projectSaveDialog} @@ -5815,6 +5921,17 @@ export default function VideoEditor() { className={`flex items-center gap-1.5 justify-self-start ${headerLeftControlsPaddingClass}`} style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} > + + + + + + + void handleReturnToRecording()}> + {t("editor.project.newRecording", "New recording")} + {primaryModifierLabel}N + + void handleImportMediaOrProject()}> + {t("editor.project.newFromFile", "New project from file…")} + + + void handleOpenProjectBrowser()}> + {t("editor.project.open", "Open projects…")} + {primaryModifierLabel}O + + + void handleSaveProject()}> + {t("editor.project.save", "Save project")} + {primaryModifierLabel}S + + void handleSaveProjectAs()}> + {t("editor.project.saveAs", "Save project as…")} + + {isMac ? "⇧⌘S" : "Ctrl+Shift+S"} + + + +
diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index 572f6574b..e5a0c23f2 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -107,10 +107,18 @@ }, "actions": { "saveAgain": "Erneut speichern", - "showInFolder": "In Ordner anzeigen" + "showInFolder": "In Ordner anzeigen", + "returnToRecording": "Zurück zur Aufnahme", + "returnToRecordingBlockedByExport": "Warte, bis der Export abgeschlossen ist, bevor du zur Aufnahme zurückkehrst" }, "project": { - "untitled": "Unbenannt" + "untitled": "Unbenannt", + "menu": "Projekt", + "newRecording": "Neue Aufnahme", + "newFromFile": "Neues Projekt aus Datei…", + "open": "Projekte öffnen…", + "save": "Projekt speichern", + "saveAs": "Projekt speichern unter…" }, "nativeCaptureUnavailable": { "title": "Es ist nichts kaputt, aber wir können kein animiertes Cursor-Overlay rendern.", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 902522306..32be89926 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -108,10 +108,18 @@ }, "actions": { "saveAgain": "Save Again", - "showInFolder": "Show In Folder" + "showInFolder": "Show In Folder", + "returnToRecording": "Return to recording", + "returnToRecordingBlockedByExport": "Wait for the export to finish before returning to recording" }, "project": { - "untitled": "Untitled" + "untitled": "Untitled", + "menu": "Project", + "newRecording": "New recording", + "newFromFile": "New project from file…", + "open": "Open projects…", + "save": "Save project", + "saveAs": "Save project as…" }, "nativeCaptureUnavailable": { "title": "Nothing’s broken, but we won’t be able to render an animated cursor overlay.", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 40373f1a6..cf930c0e1 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -108,10 +108,18 @@ }, "actions": { "saveAgain": "Guardar de nuevo", - "showInFolder": "Mostrar en carpeta" + "showInFolder": "Mostrar en carpeta", + "returnToRecording": "Volver a grabar", + "returnToRecordingBlockedByExport": "Espera a que termine la exportación antes de volver a grabar" }, "project": { - "untitled": "Sin título" + "untitled": "Sin título", + "menu": "Proyecto", + "newRecording": "Nueva grabación", + "newFromFile": "Nuevo proyecto desde archivo…", + "open": "Abrir proyectos…", + "save": "Guardar proyecto", + "saveAs": "Guardar proyecto como…" }, "nativeCaptureUnavailable": { "title": "Nada está roto, pero no podremos renderizar una superposición de cursor animada.", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index cb4b4d13f..72981f79a 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -108,10 +108,18 @@ }, "actions": { "saveAgain": "Enregistrer à nouveau", - "showInFolder": "Afficher dans le dossier" + "showInFolder": "Afficher dans le dossier", + "returnToRecording": "Retour à l'enregistrement", + "returnToRecordingBlockedByExport": "Attendez la fin de l'exportation avant de revenir à l'enregistrement" }, "project": { - "untitled": "Sans titre" + "untitled": "Sans titre", + "menu": "Projet", + "newRecording": "Nouvel enregistrement", + "newFromFile": "Nouveau projet depuis un fichier…", + "open": "Ouvrir des projets…", + "save": "Enregistrer le projet", + "saveAs": "Enregistrer le projet sous…" }, "nativeCaptureUnavailable": { "title": "Rien n'est cassé, mais nous ne pourrons pas afficher une superposition animée du curseur.", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index b23d247da..e9a246f63 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -108,10 +108,18 @@ }, "actions": { "saveAgain": "Salva di nuovo", - "showInFolder": "Mostra nella cartella" + "showInFolder": "Mostra nella cartella", + "returnToRecording": "Torna alla registrazione", + "returnToRecordingBlockedByExport": "Attendi il termine dell'esportazione prima di tornare alla registrazione" }, "project": { - "untitled": "Senza titolo" + "untitled": "Senza titolo", + "menu": "Progetto", + "newRecording": "Nuova registrazione", + "newFromFile": "Nuovo progetto da file…", + "open": "Apri progetti…", + "save": "Salva progetto", + "saveAs": "Salva progetto con nome…" }, "nativeCaptureUnavailable": { "title": "Niente è rotto, ma non sarà possibile renderizzare un overlay del cursore animato.", diff --git a/src/i18n/locales/ko/editor.json b/src/i18n/locales/ko/editor.json index 49678dee1..0a0fe1fd6 100644 --- a/src/i18n/locales/ko/editor.json +++ b/src/i18n/locales/ko/editor.json @@ -109,10 +109,18 @@ }, "actions": { "saveAgain": "다시 저장", - "showInFolder": "폴더에서 보기" + "showInFolder": "폴더에서 보기", + "returnToRecording": "녹화로 돌아가기", + "returnToRecordingBlockedByExport": "내보내기가 끝난 후에 녹화로 돌아갈 수 있습니다" }, "project": { - "untitled": "제목 없음" + "untitled": "제목 없음", + "menu": "프로젝트", + "newRecording": "새 녹화", + "newFromFile": "파일에서 새 프로젝트…", + "open": "프로젝트 열기…", + "save": "프로젝트 저장", + "saveAs": "다른 이름으로 프로젝트 저장…" }, "nativeCaptureUnavailable": { "title": "문제가 생긴 것은 아니지만, 애니메이션 커서 오버레이를 렌더링할 수 없습니다.", diff --git a/src/i18n/locales/nl/editor.json b/src/i18n/locales/nl/editor.json index d58b9e525..f2af4ab30 100644 --- a/src/i18n/locales/nl/editor.json +++ b/src/i18n/locales/nl/editor.json @@ -109,10 +109,18 @@ }, "actions": { "saveAgain": "Opnieuw opslaan", - "showInFolder": "Tonen in map" + "showInFolder": "Tonen in map", + "returnToRecording": "Terug naar opnemen", + "returnToRecordingBlockedByExport": "Wacht tot het exporteren klaar is voordat je teruggaat naar opnemen" }, "project": { - "untitled": "Naamloos" + "untitled": "Naamloos", + "menu": "Project", + "newRecording": "Nieuwe opname", + "newFromFile": "Nieuw project uit bestand…", + "open": "Projecten openen…", + "save": "Project opslaan", + "saveAs": "Project opslaan als…" }, "nativeCaptureUnavailable": { "title": "Er is niets kapot, maar we kunnen geen geanimeerde cursor-overlay renderen.", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 702939fd1..5346fd2d2 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -108,10 +108,18 @@ }, "actions": { "saveAgain": "Salvar novamente", - "showInFolder": "Mostrar na pasta" + "showInFolder": "Mostrar na pasta", + "returnToRecording": "Voltar para a gravação", + "returnToRecordingBlockedByExport": "Aguarde a exportação terminar antes de voltar para a gravação" }, "project": { - "untitled": "Sem título" + "untitled": "Sem título", + "menu": "Projeto", + "newRecording": "Nova gravação", + "newFromFile": "Novo projeto a partir de arquivo…", + "open": "Abrir projetos…", + "save": "Salvar projeto", + "saveAs": "Salvar projeto como…" }, "nativeCaptureUnavailable": { "title": "Nada está quebrado, mas não poderemos renderizar uma sobreposição animada do cursor.", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 673f27eec..11288569c 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -108,10 +108,18 @@ }, "actions": { "saveAgain": "Сохранить ещё раз", - "showInFolder": "Показать в папке" + "showInFolder": "Показать в папке", + "returnToRecording": "Вернуться к записи", + "returnToRecordingBlockedByExport": "Дождитесь завершения экспорта, прежде чем вернуться к записи" }, "project": { - "untitled": "Без названия" + "untitled": "Без названия", + "menu": "Проект", + "newRecording": "Новая запись", + "newFromFile": "Новый проект из файла…", + "open": "Открыть проекты…", + "save": "Сохранить проект", + "saveAs": "Сохранить проект как…" }, "nativeCaptureUnavailable": { "title": "Всё в порядке, но мы не можем отобразить анимированное наложение курсора.", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 1bf3a6084..79348998e 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -108,10 +108,18 @@ }, "actions": { "saveAgain": "再次保存", - "showInFolder": "在文件夹中显示" + "showInFolder": "在文件夹中显示", + "returnToRecording": "返回录制", + "returnToRecordingBlockedByExport": "请等待导出完成后再返回录制" }, "project": { - "untitled": "未命名" + "untitled": "未命名", + "menu": "项目", + "newRecording": "新建录制", + "newFromFile": "从文件新建项目…", + "open": "打开项目…", + "save": "保存项目", + "saveAs": "项目另存为…" }, "nativeCaptureUnavailable": { "title": "没有出错,但我们无法渲染动画光标叠加层。", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 6f2b0f823..6f6972edb 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -108,10 +108,18 @@ }, "actions": { "saveAgain": "再次保存", - "showInFolder": "在資料夾中顯示" + "showInFolder": "在資料夾中顯示", + "returnToRecording": "返回錄製", + "returnToRecordingBlockedByExport": "請等待匯出完成後再返回錄製" }, "project": { - "untitled": "未命名" + "untitled": "未命名", + "menu": "專案", + "newRecording": "新增錄製", + "newFromFile": "從檔案新增專案…", + "open": "開啟專案…", + "save": "儲存專案", + "saveAs": "另存專案…" }, "nativeCaptureUnavailable": { "title": "沒有出錯,但我們無法轉譯動畫游標覆蓋層。", diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index d43846930..bd48d0373 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -39,6 +39,14 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [ }, { label: "Pan Timeline", display: "Shift + Scroll", bindings: [] }, { label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] }, + { label: "New Recording", display: "Ctrl + N", bindings: [{ key: "n", ctrl: true }] }, + { label: "Save Project", display: "Ctrl + S", bindings: [{ key: "s", ctrl: true }] }, + { + label: "Save Project As", + display: "Ctrl + Shift + S", + bindings: [{ key: "s", ctrl: true, shift: true }], + }, + { label: "Open Projects", display: "Ctrl + O", bindings: [{ key: "o", ctrl: true }] }, ]; export type ShortcutConflict =