diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index b7314ea7c..f954fc99e 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -284,6 +284,7 @@ interface Window { onMenuLoadProject: (callback: () => void) => () => void; onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; + quitApp: () => void; getPlatform: () => Promise; revealInFolder: ( filePath: string, diff --git a/electron/main.ts b/electron/main.ts index fde69aa6f..4313bc035 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -370,6 +370,12 @@ ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => { editorHasUnsavedChanges = hasChanges; }); +// Quit requested from the editor's in-app File menu. Mirrors the native +// menu's role:"quit" so the unsaved-changes close flow still runs. +ipcMain.on("app-quit", () => { + app.quit(); +}); + function forceCloseEditorWindow(windowToClose: BrowserWindow | null) { if (!windowToClose || windowToClose.isDestroyed()) return; diff --git a/electron/preload.ts b/electron/preload.ts index 32e014953..f02a2ccd2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -233,6 +233,9 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("menu-save-project-as", listener); return () => ipcRenderer.removeListener("menu-save-project-as", listener); }, + quitApp: () => { + ipcRenderer.send("app-quit"); + }, getPlatform: () => { return ipcRenderer.invoke("get-platform"); }, diff --git a/electron/windows.ts b/electron/windows.ts index 2f87e6c3d..60dbc9df4 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -196,6 +196,13 @@ export function createEditorWindow(): BrowserWindow { win.maximize(); + // The editor renders its own File/Edit/View menu bar in the custom titlebar, + // so hide the native OS menu bar on Windows/Linux (it stays reachable via Alt). + // macOS keeps its global menu bar. + if (process.platform !== "darwin") { + win.setAutoHideMenuBar(true); + } + // Show only once painted to avoid a white flash on cold Vite start. win.once("ready-to-show", () => { if (!HEADLESS) win.show(); @@ -345,6 +352,11 @@ export function createNotesWindow(): BrowserWindow { }, }); + // Match the editor: no native OS menu bar on Windows/Linux (reachable via Alt). + if (process.platform !== "darwin") { + win.setAutoHideMenuBar(true); + } + win.setContentProtection(true); win.once("ready-to-show", () => { win.setContentProtection(true); diff --git a/src/components/video-editor/EditorMenuBar.test.tsx b/src/components/video-editor/EditorMenuBar.test.tsx new file mode 100644 index 000000000..5011985a2 --- /dev/null +++ b/src/components/video-editor/EditorMenuBar.test.tsx @@ -0,0 +1,176 @@ +import "@testing-library/jest-dom"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + buildEditorMenuModel, + EditorMenuBar, + type EditorMenuBarProps, + formatShiftShortcut, + formatShortcut, +} from "./EditorMenuBar"; + +const LABELS: Record = { + "common.actions.file": "File", + "common.actions.edit": "Edit", + "common.actions.view": "View", + "common.actions.quit": "Quit", + "common.actions.undo": "Undo", + "common.actions.redo": "Redo", + "common.actions.reload": "Reload", + "dialogs.unsavedChanges.newProject": "New Project", + "dialogs.unsavedChanges.loadProject": "Load Project…", + "dialogs.unsavedChanges.saveProject": "Save Project…", + "dialogs.unsavedChanges.saveProjectAs": "Save Project As…", +}; + +function makeProps(overrides: Partial = {}): EditorMenuBarProps { + return { + isMac: false, + t: (key: string) => LABELS[key] ?? key, + onNewProject: vi.fn(), + onLoadProject: vi.fn(), + onSaveProject: vi.fn(), + onSaveProjectAs: vi.fn(), + onQuit: vi.fn(), + onUndo: vi.fn(), + onRedo: vi.fn(), + onReload: vi.fn(), + canUndo: true, + canRedo: true, + ...overrides, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("formatShortcut", () => { + it("uses Ctrl on non-mac and the ⌘ symbol on mac", () => { + expect(formatShortcut(false, "N")).toBe("Ctrl+N"); + expect(formatShortcut(true, "N")).toBe("⌘N"); + }); + + it("formats shift combos per platform", () => { + expect(formatShiftShortcut(false, "S")).toBe("Ctrl+Shift+S"); + expect(formatShiftShortcut(true, "S")).toBe("⌘⇧S"); + }); +}); + +describe("buildEditorMenuModel", () => { + it("returns the File, Edit and View menus with translated labels", () => { + const model = buildEditorMenuModel(makeProps()); + expect(model.map((m) => m.id)).toEqual(["file", "edit", "view"]); + expect(model.map((m) => m.label)).toEqual(["File", "Edit", "View"]); + }); + + it("wires each File item to its handler", () => { + const props = makeProps(); + const [file] = buildEditorMenuModel(props); + const byId = Object.fromEntries(file.items.map((i) => [i.id, i])); + + byId["new-project"].onSelect(); + byId["load-project"].onSelect(); + byId["save-project"].onSelect(); + byId["save-project-as"].onSelect(); + byId.quit.onSelect(); + + expect(props.onNewProject).toHaveBeenCalledOnce(); + expect(props.onLoadProject).toHaveBeenCalledOnce(); + expect(props.onSaveProject).toHaveBeenCalledOnce(); + expect(props.onSaveProjectAs).toHaveBeenCalledOnce(); + expect(props.onQuit).toHaveBeenCalledOnce(); + }); + + it("marks Quit as a destructive item after a separator", () => { + const [file] = buildEditorMenuModel(makeProps()); + const quit = file.items.find((i) => i.id === "quit"); + expect(quit).toMatchObject({ danger: true, separatorBefore: true }); + }); + + it("shows Ctrl-based shortcut hints on non-mac", () => { + const model = buildEditorMenuModel(makeProps({ isMac: false })); + const shortcuts = Object.fromEntries( + model.flatMap((m) => m.items).map((i) => [i.id, i.shortcut]), + ); + expect(shortcuts).toMatchObject({ + "new-project": "Ctrl+N", + "load-project": "Ctrl+O", + "save-project": "Ctrl+S", + "save-project-as": "Ctrl+Shift+S", + quit: "Ctrl+Q", + undo: "Ctrl+Z", + redo: "Ctrl+Y", + reload: "Ctrl+R", + }); + }); + + it("shows ⌘-based shortcut hints on mac (redo uses ⌘⇧Z)", () => { + const model = buildEditorMenuModel(makeProps({ isMac: true })); + const shortcuts = Object.fromEntries( + model.flatMap((m) => m.items).map((i) => [i.id, i.shortcut]), + ); + expect(shortcuts).toMatchObject({ + "new-project": "⌘N", + "save-project-as": "⌘⇧S", + undo: "⌘Z", + redo: "⌘⇧Z", + reload: "⌘R", + }); + }); + + it("disables Undo/Redo according to canUndo/canRedo", () => { + const model = buildEditorMenuModel(makeProps({ canUndo: false, canRedo: false })); + const edit = model.find((m) => m.id === "edit"); + expect(edit?.items.find((i) => i.id === "undo")?.disabled).toBe(true); + expect(edit?.items.find((i) => i.id === "redo")?.disabled).toBe(true); + + const enabled = buildEditorMenuModel(makeProps({ canUndo: true, canRedo: true })); + const editEnabled = enabled.find((m) => m.id === "edit"); + expect(editEnabled?.items.find((i) => i.id === "undo")?.disabled).toBe(false); + expect(editEnabled?.items.find((i) => i.id === "redo")?.disabled).toBe(false); + }); +}); + +describe("", () => { + // Radix relies on pointer-capture / scroll APIs jsdom does not implement. + beforeAll(() => { + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.releasePointerCapture = vi.fn(); + Element.prototype.scrollIntoView = vi.fn(); + }); + + it("renders the three top-level menu triggers", () => { + render(); + expect(screen.getByRole("button", { name: "File" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "View" })).toBeInTheDocument(); + }); + + it("opens the File menu and invokes the handler for a selected item", async () => { + const user = userEvent.setup(); + const props = makeProps(); + render(); + + await user.click(screen.getByRole("button", { name: "File" })); + + expect(await screen.findByText("New Project")).toBeInTheDocument(); + expect(screen.getByText("Ctrl+N")).toBeInTheDocument(); + + await user.click(screen.getByText("Save Project…")); + expect(props.onSaveProject).toHaveBeenCalledOnce(); + }); + + it("renders a disabled Undo item when canUndo is false", async () => { + const user = userEvent.setup(); + const props = makeProps({ canUndo: false }); + render(); + + await user.click(screen.getByRole("button", { name: "Edit" })); + + const undoItem = await screen.findByRole("menuitem", { name: /Undo/ }); + expect(undoItem).toHaveAttribute("aria-disabled", "true"); + }); +}); diff --git a/src/components/video-editor/EditorMenuBar.tsx b/src/components/video-editor/EditorMenuBar.tsx new file mode 100644 index 000000000..629f66482 --- /dev/null +++ b/src/components/video-editor/EditorMenuBar.tsx @@ -0,0 +1,198 @@ +import { Fragment } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export interface EditorMenuBarProps { + /** Whether the app is running on macOS. Drives shortcut-hint formatting. */ + isMac: boolean; + /** Qualified-key translator (e.g. the `rawT` from `useI18n`). */ + t: (qualifiedKey: string) => string; + onNewProject: () => void; + onLoadProject: () => void; + onSaveProject: () => void; + onSaveProjectAs: () => void; + onQuit: () => void; + onUndo: () => void; + onRedo: () => void; + onReload: () => void; + canUndo: boolean; + canRedo: boolean; +} + +export interface EditorMenuItem { + id: string; + label: string; + /** Human-readable, platform-aware shortcut hint (e.g. "Ctrl+N" / "⌘N"). */ + shortcut?: string; + onSelect: () => void; + disabled?: boolean; + /** Renders the item in a destructive (red) style, e.g. Quit. */ + danger?: boolean; + /** Draws a separator immediately before this item. */ + separatorBefore?: boolean; +} + +export interface EditorMenu { + id: string; + label: string; + minWidthClass: string; + items: EditorMenuItem[]; +} + +/** + * Formats a menu shortcut hint for the current platform. macOS uses the symbol + * modifier with no separator ("⌘N"); everywhere else uses "Ctrl+N". These mirror + * the accelerators wired in the native menu (electron/main.ts) and the editor + * keydown handler, so the hints stay truthful. + */ +export function formatShortcut(isMac: boolean, key: string): string { + return isMac ? `⌘${key}` : `Ctrl+${key}`; +} + +export function formatShiftShortcut(isMac: boolean, key: string): string { + return isMac ? `⌘⇧${key}` : `Ctrl+Shift+${key}`; +} + +/** + * Pure description of the editor's File / Edit / View menus. Kept separate from + * the rendering so the labels, shortcut hints, disabled states, and handler + * wiring can be unit-tested without mounting Radix/jsdom. + */ +export function buildEditorMenuModel(props: EditorMenuBarProps): EditorMenu[] { + const { isMac, t } = props; + + return [ + { + id: "file", + label: t("common.actions.file"), + minWidthClass: "min-w-[170px]", + items: [ + { + id: "new-project", + label: t("dialogs.unsavedChanges.newProject"), + shortcut: formatShortcut(isMac, "N"), + onSelect: props.onNewProject, + }, + { + id: "load-project", + label: t("dialogs.unsavedChanges.loadProject"), + shortcut: formatShortcut(isMac, "O"), + onSelect: props.onLoadProject, + separatorBefore: true, + }, + { + id: "save-project", + label: t("dialogs.unsavedChanges.saveProject"), + shortcut: formatShortcut(isMac, "S"), + onSelect: props.onSaveProject, + }, + { + id: "save-project-as", + label: t("dialogs.unsavedChanges.saveProjectAs"), + shortcut: formatShiftShortcut(isMac, "S"), + onSelect: props.onSaveProjectAs, + }, + { + id: "quit", + label: t("common.actions.quit"), + shortcut: formatShortcut(isMac, "Q"), + onSelect: props.onQuit, + danger: true, + separatorBefore: true, + }, + ], + }, + { + id: "edit", + label: t("common.actions.edit"), + minWidthClass: "min-w-[130px]", + items: [ + { + id: "undo", + label: t("common.actions.undo"), + shortcut: formatShortcut(isMac, "Z"), + onSelect: props.onUndo, + disabled: !props.canUndo, + }, + { + id: "redo", + label: t("common.actions.redo"), + // Redo is bound to both Ctrl+Y and Ctrl+Shift+Z; show the + // idiomatic hint per platform (⌘⇧Z on macOS, Ctrl+Y elsewhere). + shortcut: isMac ? formatShiftShortcut(isMac, "Z") : formatShortcut(isMac, "Y"), + onSelect: props.onRedo, + disabled: !props.canRedo, + }, + ], + }, + { + id: "view", + label: t("common.actions.view"), + minWidthClass: "min-w-[130px]", + items: [ + { + id: "reload", + label: t("common.actions.reload"), + shortcut: formatShortcut(isMac, "R"), + onSelect: props.onReload, + }, + ], + }, + ]; +} + +/** + * Custom in-app menu bar (File / Edit / View) shown in the editor's titlebar. + * Replaces the native OS menu bar that is auto-hidden on Windows/Linux + * (see electron/windows.ts). + */ +export function EditorMenuBar(props: EditorMenuBarProps) { + const menus = buildEditorMenuModel(props); + + return ( +
+ {menus.map((menu) => ( + + + + + + {menu.items.map((item) => ( + + {item.separatorBefore && } + item.onSelect()} + disabled={item.disabled} + className={ + item.danger + ? "hover:bg-red-500/20 focus:bg-red-500/20 focus:text-red-400 text-red-400 cursor-pointer justify-between" + : "hover:bg-white/[0.08] focus:bg-white/[0.08] focus:text-white cursor-pointer justify-between" + } + > + {item.label} + {item.shortcut && ( + {item.shortcut} + )} + + + ))} + + + ))} +
+ ); +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 96da01f28..31ba6c11d 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,5 +1,5 @@ import type { Span } from "dnd-timeline"; -import { FolderOpen, Languages, Save, Video } from "lucide-react"; +import { ChevronDown, FolderOpen, Languages, Save, Video } from "lucide-react"; import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { toast } from "sonner"; @@ -68,6 +68,7 @@ import { isPortraitAspectRatio, } from "@/utils/aspectRatioUtils"; import { EditorEmptyState } from "./EditorEmptyState"; +import { EditorMenuBar } from "./EditorMenuBar"; import { ExportDialog } from "./ExportDialog"; import { DEFAULT_CURSOR_SETTINGS, @@ -198,6 +199,8 @@ export default function VideoEditor() { undo, redo, resetState, + canUndo, + canRedo, } = useEditorHistory(INITIAL_EDITOR_STATE); const { @@ -2731,17 +2734,33 @@ export default function VideoEditor() { style={{ WebkitAppRegion: "drag" } as CSSProperties} >
-
- + {/* Custom in-app menu bar. Replaces the native OS menu bar that is + auto-hidden on Windows/Linux (see electron/windows.ts). */} + window.electronAPI.quitApp()} + onUndo={undo} + onRedo={redo} + onReload={() => window.location.reload()} + canUndo={canUndo} + canRedo={canRedo} + /> + +
+ +