diff --git a/apps/electron/layers/main/src/application-menu.ts b/apps/electron/layers/main/src/application-menu.ts new file mode 100644 index 000000000..935f34c79 --- /dev/null +++ b/apps/electron/layers/main/src/application-menu.ts @@ -0,0 +1,128 @@ +import { app, Menu } from 'electron'; + +import { isMacOS } from '../../utils'; +import { subjects } from './events'; + +// Unique id for menuitems +const MENUITEM_NEW_PAGE = 'affine:new-page'; + +export function createApplicationMenu() { + const isMac = isMacOS(); + + // Electron menu cannot be modified + // You have to copy the complete default menu template event if you want to add a single custom item + // See https://www.electronjs.org/docs/latest/api/menu#examples + const template = [ + // { role: 'appMenu' } + ...(isMac + ? [ + { + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + ] + : []), + // { role: 'fileMenu' } + { + label: 'File', + submenu: [ + { + id: MENUITEM_NEW_PAGE, + label: 'New Page', + accelerator: isMac ? 'Cmd+N' : 'Ctrl+N', + click: () => { + subjects.applicationMenu.newPageAction.next(); + }, + }, + { type: 'separator' }, + isMac ? { role: 'close' } : { role: 'quit' }, + ], + }, + // { role: 'editMenu' } + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + ...(isMac + ? [ + { role: 'pasteAndMatchStyle' }, + { role: 'delete' }, + { role: 'selectAll' }, + { type: 'separator' }, + { + label: 'Speech', + submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }], + }, + ] + : [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]), + ], + }, + // { role: 'viewMenu' } + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + // { role: 'windowMenu' } + { + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'zoom' }, + ...(isMac + ? [ + { type: 'separator' }, + { role: 'front' }, + { type: 'separator' }, + { role: 'window' }, + ] + : [{ role: 'close' }]), + ], + }, + { + role: 'help', + submenu: [ + { + label: 'Learn More', + click: async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { shell } = require('electron'); + await shell.openExternal('https://affine.pro/'); + }, + }, + ], + }, + ]; + + // @ts-ignore The snippet is copied from Electron official docs. + // It's working as expected. No idea why it contains type errors. + // Just ignore for now. + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); + + return menu; +} diff --git a/apps/electron/layers/main/src/events/application-menu.ts b/apps/electron/layers/main/src/events/application-menu.ts new file mode 100644 index 000000000..5aefaa737 --- /dev/null +++ b/apps/electron/layers/main/src/events/application-menu.ts @@ -0,0 +1,22 @@ +import { Subject } from 'rxjs'; + +import type { MainEventListener } from './type'; + +export const applicationMenuSubjects = { + newPageAction: new Subject(), +}; + +/** + * Events triggered by application menu + */ +export const applicationMenuEvents = { + /** + * File -> New Page + */ + onNewPageAction: (fn: () => void) => { + const sub = applicationMenuSubjects.newPageAction.subscribe(fn); + return () => { + sub.unsubscribe(); + }; + }, +} satisfies Record; diff --git a/apps/electron/layers/main/src/events/index.ts b/apps/electron/layers/main/src/events/index.ts index 780a5979d..a74b916f7 100644 --- a/apps/electron/layers/main/src/events/index.ts +++ b/apps/electron/layers/main/src/events/index.ts @@ -1,7 +1,9 @@ export * from './register'; +import { applicationMenuSubjects } from './application-menu'; import { dbSubjects } from './db'; export const subjects = { db: dbSubjects, + applicationMenu: applicationMenuSubjects, }; diff --git a/apps/electron/layers/main/src/events/register.ts b/apps/electron/layers/main/src/events/register.ts index 6dd43acc1..b3825d862 100644 --- a/apps/electron/layers/main/src/events/register.ts +++ b/apps/electron/layers/main/src/events/register.ts @@ -1,12 +1,14 @@ import { app, BrowserWindow } from 'electron'; import { logger } from '../logger'; +import { applicationMenuEvents } from './application-menu'; import { dbEvents } from './db'; import { updaterEvents } from './updater'; export const allEvents = { db: dbEvents, updater: updaterEvents, + applicationMenu: applicationMenuEvents, }; function getActiveWindows() { diff --git a/apps/electron/layers/main/src/index.ts b/apps/electron/layers/main/src/index.ts index 5ebe120e1..5f490be3f 100644 --- a/apps/electron/layers/main/src/index.ts +++ b/apps/electron/layers/main/src/index.ts @@ -2,6 +2,7 @@ import './security-restrictions'; import { app } from 'electron'; +import { createApplicationMenu } from './application-menu'; import { registerEvents } from './events'; import { registerHandlers } from './handlers'; import { registerUpdater } from './handlers/updater'; @@ -57,6 +58,7 @@ app .then(registerHandlers) .then(registerEvents) .then(restoreOrCreateWindow) + .then(createApplicationMenu) .then(registerUpdater) .catch(e => console.error('Failed create window:', e)); /** diff --git a/apps/web/src/components/root-app-sidebar/index.tsx b/apps/web/src/components/root-app-sidebar/index.tsx index b609e37ef..eeab8a9ed 100644 --- a/apps/web/src/components/root-app-sidebar/index.tsx +++ b/apps/web/src/components/root-app-sidebar/index.tsx @@ -89,6 +89,14 @@ export const RootAppSidebar = ({ const page = await createPage(); openPage(page.id); }, [createPage, openPage]); + + // Listen to the "New Page" action from the menu + useEffect(() => { + if (environment.isDesktop) { + return window.events?.applicationMenu.onNewPageAction(onClickNewPage); + } + }, [onClickNewPage]); + const sidebarOpen = useAtomValue(appSidebarOpenAtom); useEffect(() => { if (environment.isDesktop && typeof sidebarOpen === 'boolean') {