Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ interface Window {
getAssetBasePath: () => Promise<string | null>;
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
switchToRecording: () => Promise<void>;
openSourceSelector: () => Promise<void>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource>;
showSourceHighlight: (source: ProcessedDesktopSource) => Promise<{ success: boolean }>;
Expand Down
33 changes: 33 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
3 changes: 3 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
},
Expand Down
172 changes: 164 additions & 8 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Crop,
Cursor,
DownloadSimple as Download,
FileText,
FolderOpen,
Gear,
Pause,
Expand All @@ -20,6 +21,7 @@ import {
Sparkle,
ArrowCounterClockwise as Undo2,
UserCircle as User,
VideoCamera,
SpeakerLow as Volume1,
SpeakerHigh as Volume2,
SpeakerX as VolumeX,
Expand All @@ -44,6 +46,8 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
Expand Down Expand Up @@ -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<CancelableExporter | null>(null);
Expand Down Expand Up @@ -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"))) {
Expand Down Expand Up @@ -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;
Comment on lines +4600 to +4603

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Ignore Shift for the New Recording shortcut.

Ctrl+Shift+N and Cmd+Shift+N also return to recording because this branch does not check e.shiftKey. This shortcut is not displayed or registered. Require !e.shiftKey before handling key === "n".

Proposed fix
-			if (key === "n") {
+			if (key === "n" && !e.shiftKey) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (key === "n") {
e.preventDefault();
void handleReturnToRecording();
return;
if (key === "n" && !e.shiftKey) {
e.preventDefault();
void handleReturnToRecording();
return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/video-editor/VideoEditor.tsx` around lines 4600 - 4603, Update
the keyboard shortcut branch in VideoEditor’s key handler so the "n" path calls
handleReturnToRecording only when the control/meta modifier is valid and
e.shiftKey is false, leaving Ctrl/Cmd+Shift+N unhandled.

}

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);
Expand Down Expand Up @@ -5787,14 +5884,23 @@ export default function VideoEditor() {
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<div className="text-destructive">{error}</div>
<button
ref={projectBrowserFallbackTriggerRef}
type="button"
onClick={handleOpenProjectBrowser}
className="rounded-[5px] bg-neutral-800 px-3 py-1.5 text-sm font-semibold 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"
>
Open Projects
</button>
<div className="flex items-center gap-2">
<button
ref={projectBrowserFallbackTriggerRef}
type="button"
onClick={handleOpenProjectBrowser}
className="rounded-[5px] bg-neutral-800 px-3 py-1.5 text-sm font-semibold 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"
>
Open Projects
</button>
<button
type="button"
onClick={() => void handleReturnToRecording()}
className="rounded-[5px] border border-foreground/15 px-3 py-1.5 text-sm font-semibold text-foreground transition-colors hover:bg-foreground/10"
>
{t("editor.actions.returnToRecording", "Return to recording")}
</button>
</div>
</div>
{projectBrowser}
{projectSaveDialog}
Expand All @@ -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}
>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void handleReturnToRecording()}
className={APP_HEADER_ICON_BUTTON_CLASS}
title={t("editor.actions.returnToRecording", "Return to recording")}
aria-label={t("editor.actions.returnToRecording", "Return to recording")}
>
<VideoCamera className="h-4 w-4" />
</Button>
<Button
ref={projectBrowserTriggerRef}
type="button"
Expand All @@ -5827,6 +5944,45 @@ export default function VideoEditor() {
>
<FolderOpen className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={APP_HEADER_ICON_BUTTON_CLASS}
title={t("editor.project.menu", "Project")}
aria-label={t("editor.project.menu", "Project")}
>
<FileText className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-60">
<DropdownMenuItem onSelect={() => void handleReturnToRecording()}>
{t("editor.project.newRecording", "New recording")}
<DropdownMenuShortcut>{primaryModifierLabel}N</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleImportMediaOrProject()}>
{t("editor.project.newFromFile", "New project from file…")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleOpenProjectBrowser()}>
{t("editor.project.open", "Open projects…")}
<DropdownMenuShortcut>{primaryModifierLabel}O</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleSaveProject()}>
{t("editor.project.save", "Save project")}
<DropdownMenuShortcut>{primaryModifierLabel}S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleSaveProjectAs()}>
{t("editor.project.saveAs", "Save project as…")}
<DropdownMenuShortcut>
{isMac ? "⇧⌘S" : "Ctrl+Shift+S"}
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DiscordLinkButton />
<FeedbackDialog />
<div className="ml-1 h-5 w-px bg-foreground/10" />
Expand Down
12 changes: 10 additions & 2 deletions src/i18n/locales/de/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
12 changes: 10 additions & 2 deletions src/i18n/locales/en/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
12 changes: 10 additions & 2 deletions src/i18n/locales/es/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
12 changes: 10 additions & 2 deletions src/i18n/locales/fr/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
12 changes: 10 additions & 2 deletions src/i18n/locales/it/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading