From eb02a422decfb44850f074963538d32d40665207 Mon Sep 17 00:00:00 2001 From: Milan Burda Date: Thu, 10 Jan 2019 14:32:03 +0100 Subject: [PATCH] feat: add `fileMenu` / `viewMenu` / `appMenu` roles (#16328) --- default_app/menu.js | 187 ++++++----------------------- docs/api/menu-item.md | 3 + docs/api/menu.md | 95 ++++++++------- lib/browser/api/menu-item-roles.js | 150 +++++++++++++---------- lib/browser/api/menu.js | 2 +- spec/api-menu-item-spec.js | 97 ++++++++++++++- 6 files changed, 271 insertions(+), 263 deletions(-) diff --git a/default_app/menu.js b/default_app/menu.js index 6dd37b2d3287..cc9dd1a324aa 100644 --- a/default_app/menu.js +++ b/default_app/menu.js @@ -1,164 +1,51 @@ const { shell, Menu } = require('electron') +const isMac = process.platform === 'darwin' + const setDefaultApplicationMenu = () => { if (Menu.getApplicationMenu()) return - const template = [ - { - label: 'Edit', - submenu: [ - { - role: 'undo' - }, - { - role: 'redo' - }, - { - type: 'separator' - }, - { - role: 'cut' - }, - { - role: 'copy' - }, - { - role: 'paste' - }, - { - role: 'pasteandmatchstyle' - }, - { - role: 'delete' - }, - { - role: 'selectall' + const helpMenu = { + role: 'help', + submenu: [ + { + label: 'Learn More', + click () { + shell.openExternal('https://electronjs.org') } - ] - }, - { - label: 'View', - submenu: [ - { - role: 'reload' - }, - { - role: 'forcereload' - }, - { - role: 'toggledevtools' - }, - { - type: 'separator' - }, - { - role: 'resetzoom' - }, - { - role: 'zoomin' - }, - { - role: 'zoomout' - }, - { - type: 'separator' - }, - { - role: 'togglefullscreen' + }, + { + label: 'Documentation', + click () { + shell.openExternal( + `https://github.com/electron/electron/tree/v${process.versions.electron}/docs#readme` + ) } - ] - }, - { - role: 'windowMenu' - }, - { - role: 'help', - submenu: [ - { - label: 'Learn More', - click () { - shell.openExternal('https://electronjs.org') - } - }, - { - label: 'Documentation', - click () { - shell.openExternal( - `https://github.com/electron/electron/tree/v${process.versions.electron}/docs#readme` - ) - } - }, - { - label: 'Community Discussions', - click () { - shell.openExternal('https://discuss.atom.io/c/electron') - } - }, - { - label: 'Search Issues', - click () { - shell.openExternal('https://github.com/electron/electron/issues') - } + }, + { + label: 'Community Discussions', + click () { + shell.openExternal('https://discuss.atom.io/c/electron') } - ] - } - ] - - if (process.platform === 'darwin') { - template.unshift({ - label: 'Electron', - submenu: [ - { - role: 'about' - }, - { - type: 'separator' - }, - { - role: 'services' - }, - { - type: 'separator' - }, - { - role: 'hide' - }, - { - role: 'hideothers' - }, - { - role: 'unhide' - }, - { - type: 'separator' - }, - { - role: 'quit' + }, + { + label: 'Search Issues', + click () { + shell.openExternal('https://github.com/electron/electron/issues') } - ] - }) - template[1].submenu.push({ - type: 'separator' - }, { - label: 'Speech', - submenu: [ - { - role: 'startspeaking' - }, - { - role: 'stopspeaking' - } - ] - }) - } else { - template.unshift({ - label: 'File', - submenu: [{ - role: 'quit' - }] - }) + } + ] } + const template = [ + ...(isMac ? [{ role: 'appMenu' }] : []), + { role: 'fileMenu' }, + { role: 'editMenu' }, + { role: 'viewMenu' }, + { role: 'windowMenu' }, + helpMenu + ] + const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) } diff --git a/docs/api/menu-item.md b/docs/api/menu-item.md index 5c72b45fe176..cc924a92b9e0 100644 --- a/docs/api/menu-item.md +++ b/docs/api/menu-item.md @@ -82,11 +82,14 @@ The `role` property can have following values: * `resetZoom` - Reset the focused page's zoom level to the original size. * `zoomIn` - Zoom in the focused page by 10%. * `zoomOut` - Zoom out the focused page by 10%. +* `fileMenu` - Whole default "File" menu (Close / Quit) * `editMenu` - Whole default "Edit" menu (Undo, Copy, etc.). +* `viewMenu` - Whole default "View" menu (Reload, Toggle Developer Tools, etc.) * `windowMenu` - Whole default "Window" menu (Minimize, Close, etc.). The following additional roles are available on _macOS_: +* `appMenu` - Whole default "App" menu (About, Services, etc.) * `about` - Map to the `orderFrontStandardAboutPanel` action. * `hide` - Map to the `hide` action. * `hideOthers` - Map to the `hideOtherApplications` action. diff --git a/docs/api/menu.md b/docs/api/menu.md index 0363ffdb4950..d2c7b872c871 100644 --- a/docs/api/menu.md +++ b/docs/api/menu.md @@ -158,6 +158,29 @@ simple template API: const { app, Menu } = require('electron') const template = [ + // { role: 'appMenu' } + ...(process.platform === 'darwin' ? [{ + label: app.getName(), + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideothers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' } + ] + }] : []), + // { role: 'fileMenu' } + { + label: 'File', + submenu: [ + isMac ? { role: 'close' } : { role: 'quit' } + ] + }, + // { role: 'editMenu' } { label: 'Edit', submenu: [ @@ -167,11 +190,26 @@ const template = [ { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, - { role: 'pasteandmatchstyle' }, - { role: 'delete' }, - { role: 'selectall' } + ...(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: [ @@ -186,11 +224,20 @@ const template = [ { role: 'togglefullscreen' } ] }, + // { role: 'windowMenu' } { - role: 'window', + label: 'Window', submenu: [ { role: 'minimize' }, - { role: 'close' } + { role: 'zoom' }, + ...(isMac ? [ + { type: 'separator' }, + { role: 'front' }, + { type: 'separator' }, + { role: 'window' } + ] : [ + { role: 'close' } + ]) ] }, { @@ -204,44 +251,6 @@ const template = [ } ] -if (process.platform === 'darwin') { - template.unshift({ - label: app.getName(), - submenu: [ - { role: 'about' }, - { type: 'separator' }, - { role: 'services' }, - { type: 'separator' }, - { role: 'hide' }, - { role: 'hideothers' }, - { role: 'unhide' }, - { type: 'separator' }, - { role: 'quit' } - ] - }) - - // Edit menu - template[1].submenu.push( - { type: 'separator' }, - { - label: 'Speech', - submenu: [ - { role: 'startspeaking' }, - { role: 'stopspeaking' } - ] - } - ) - - // Window menu - template[3].submenu = [ - { role: 'close' }, - { role: 'minimize' }, - { role: 'zoom' }, - { type: 'separator' }, - { role: 'front' } - ] -} - const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) ``` diff --git a/lib/browser/api/menu-item-roles.js b/lib/browser/api/menu-item-roles.js index 9162bc1e6896..192f993b501d 100644 --- a/lib/browser/api/menu-item-roles.js +++ b/lib/browser/api/menu-item-roles.js @@ -2,14 +2,18 @@ const { app } = require('electron') +const isMac = process.platform === 'darwin' +const isWindows = process.platform === 'win32' +const isLinux = process.platform === 'linux' + const roles = { about: { get label () { - return process.platform === 'linux' ? 'About' : `About ${app.getName()}` + return isLinux ? 'About' : `About ${app.getName()}` } }, close: { - label: process.platform === 'darwin' ? 'Close Window' : 'Close', + label: isMac ? 'Close Window' : 'Close', accelerator: 'CommandOrControl+W', windowMethod: 'close' }, @@ -78,12 +82,12 @@ const roles = { default: return 'Quit' } }, - accelerator: process.platform === 'win32' ? null : 'CommandOrControl+Q', + accelerator: isWindows ? null : 'CommandOrControl+Q', appMethod: 'quit' }, redo: { label: 'Redo', - accelerator: process.platform === 'win32' ? 'Control+Y' : 'Shift+CommandOrControl+Z', + accelerator: isWindows ? 'Control+Y' : 'Shift+CommandOrControl+Z', webContentsMethod: 'redo' }, reload: { @@ -122,13 +126,13 @@ const roles = { }, toggledevtools: { label: 'Toggle Developer Tools', - accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I', + accelerator: isMac ? 'Alt+Command+I' : 'Ctrl+Shift+I', nonNativeMacOSRole: true, windowMethod: 'toggleDevTools' }, togglefullscreen: { label: 'Toggle Full Screen', - accelerator: process.platform === 'darwin' ? 'Control+Command+F' : 'F11', + accelerator: isMac ? 'Control+Command+F' : 'F11', windowMethod: (window) => { window.setFullScreen(!window.isFullScreen()) } @@ -167,79 +171,95 @@ const roles = { }) } }, - // Edit submenu (should fit both Mac & Windows) + // App submenu should be used for Mac only + appmenu: { + get label () { + return app.getName() + }, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideothers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' } + ] + }, + // File submenu + filemenu: { + label: 'File', + submenu: [ + isMac ? { role: 'close' } : { role: 'quit' } + ] + }, + // Edit submenu editmenu: { label: 'Edit', submenu: [ - { - role: 'undo' - }, - { - role: 'redo' - }, - { - type: 'separator' - }, - { - role: 'cut' - }, - { - role: 'copy' - }, - { - role: 'paste' - }, - - process.platform === 'darwin' ? { - role: 'pasteAndMatchStyle' - } : null, - - { - role: 'delete' - }, - - process.platform === 'win32' ? { - type: 'separator' - } : null, - - { - role: 'selectAll' - } + { 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' } + ]) ] }, - - // Window submenu should be used for Mac only + // View submenu + viewmenu: { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forcereload' }, + { role: 'toggledevtools' }, + { type: 'separator' }, + { role: 'resetzoom' }, + { role: 'zoomin' }, + { role: 'zoomout' }, + { type: 'separator' }, + { role: 'togglefullscreen' } + ] + }, + // Window submenu windowmenu: { label: 'Window', submenu: [ - { - role: 'minimize' - }, - { - role: 'zoom' - }, - process.platform !== 'darwin' ? { - label: 'close' - } : null, - process.platform === 'darwin' ? { - type: 'separator' - } : null, - process.platform === 'darwin' ? { - role: 'front' - } : null, - process.platform === 'darwin' ? { - type: 'separator' - } : null, - process.platform === 'darwin' ? { - role: 'window' - } : null + { role: 'minimize' }, + { role: 'zoom' }, + ...(isMac ? [ + { type: 'separator' }, + { role: 'front' }, + { type: 'separator' }, + { role: 'window' } + ] : [ + { role: 'close' } + ]) ] } } const canExecuteRole = (role) => { if (!roles.hasOwnProperty(role)) return false - if (process.platform !== 'darwin') return true + if (!isMac) return true // macOS handles all roles natively except for a few return roles[role].nonNativeMacOSRole diff --git a/lib/browser/api/menu.js b/lib/browser/api/menu.js index 14fcaba0a2dd..1b282a4a188d 100644 --- a/lib/browser/api/menu.js +++ b/lib/browser/api/menu.js @@ -159,13 +159,13 @@ Menu.buildFromTemplate = function (template) { if (!Array.isArray(template)) { throw new TypeError('Invalid template for Menu: Menu template must be an array') } - const menu = new Menu() if (!areValidTemplateItems(template)) { throw new TypeError('Invalid template for MenuItem: must have at least one of label, role or type') } const filtered = removeExtraSeparators(template) const sorted = sortTemplate(filtered) + const menu = new Menu() sorted.forEach((item) => menu.append(new MenuItem(item))) return menu diff --git a/spec/api-menu-item-spec.js b/spec/api-menu-item-spec.js index 49d0366f3413..10d8e1279c5d 100644 --- a/spec/api-menu-item-spec.js +++ b/spec/api-menu-item-spec.js @@ -306,6 +306,64 @@ describe('MenuItems', () => { }) }) + describe('MenuItem appMenu', () => { + before(function () { + if (process.platform !== 'darwin') { + this.skip() + } + }) + + it('includes a default submenu layout when submenu is empty', () => { + const item = new MenuItem({ role: 'appMenu' }) + + expect(item.label).to.equal(app.getName()) + expect(item.submenu.items[0].role).to.equal('about') + expect(item.submenu.items[1].type).to.equal('separator') + expect(item.submenu.items[2].role).to.equal('services') + expect(item.submenu.items[3].type).to.equal('separator') + expect(item.submenu.items[4].role).to.equal('hide') + expect(item.submenu.items[5].role).to.equal('hideothers') + expect(item.submenu.items[6].role).to.equal('unhide') + expect(item.submenu.items[7].type).to.equal('separator') + expect(item.submenu.items[8].role).to.equal('quit') + }) + + it('overrides default layout when submenu is specified', () => { + const item = new MenuItem({ + role: 'appMenu', + submenu: [{ + role: 'close' + }] + }) + expect(item.label).to.equal(app.getName()) + expect(item.submenu.items[0].role).to.equal('close') + }) + }) + + describe('MenuItem fileMenu', () => { + it('includes a default submenu layout when submenu is empty', () => { + const item = new MenuItem({ role: 'fileMenu' }) + + expect(item.label).to.equal('File') + if (process.platform === 'darwin') { + expect(item.submenu.items[0].role).to.equal('close') + } else { + expect(item.submenu.items[0].role).to.equal('quit') + } + }) + + it('overrides default layout when submenu is specified', () => { + const item = new MenuItem({ + role: 'fileMenu', + submenu: [{ + role: 'about' + }] + }) + expect(item.label).to.equal('File') + expect(item.submenu.items[0].role).to.equal('about') + }) + }) + describe('MenuItem editMenu', () => { it('includes a default submenu layout when submenu is empty', () => { const item = new MenuItem({ role: 'editMenu' }) @@ -322,9 +380,11 @@ describe('MenuItems', () => { expect(item.submenu.items[6].role).to.equal('pasteandmatchstyle') expect(item.submenu.items[7].role).to.equal('delete') expect(item.submenu.items[8].role).to.equal('selectall') - } - - if (process.platform === 'win32') { + expect(item.submenu.items[9].type).to.equal('separator') + expect(item.submenu.items[10].label).to.equal('Speech') + expect(item.submenu.items[10].submenu.items[0].role).to.equal('startspeaking') + expect(item.submenu.items[10].submenu.items[1].role).to.equal('stopspeaking') + } else { expect(item.submenu.items[6].role).to.equal('delete') expect(item.submenu.items[7].type).to.equal('separator') expect(item.submenu.items[8].role).to.equal('selectall') @@ -343,6 +403,34 @@ describe('MenuItems', () => { }) }) + describe('MenuItem viewMenu', () => { + it('includes a default submenu layout when submenu is empty', () => { + const item = new MenuItem({ role: 'viewMenu' }) + + expect(item.label).to.equal('View') + expect(item.submenu.items[0].role).to.equal('reload') + expect(item.submenu.items[1].role).to.equal('forcereload') + expect(item.submenu.items[2].role).to.equal('toggledevtools') + expect(item.submenu.items[3].type).to.equal('separator') + expect(item.submenu.items[4].role).to.equal('resetzoom') + expect(item.submenu.items[5].role).to.equal('zoomin') + expect(item.submenu.items[6].role).to.equal('zoomout') + expect(item.submenu.items[7].type).to.equal('separator') + expect(item.submenu.items[8].role).to.equal('togglefullscreen') + }) + + it('overrides default layout when submenu is specified', () => { + const item = new MenuItem({ + role: 'viewMenu', + submenu: [{ + role: 'close' + }] + }) + expect(item.label).to.equal('View') + expect(item.submenu.items[0].role).to.equal('close') + }) + }) + describe('MenuItem windowMenu', () => { it('includes a default submenu layout when submenu is empty', () => { const item = new MenuItem({ role: 'windowMenu' }) @@ -354,9 +442,10 @@ describe('MenuItems', () => { if (process.platform === 'darwin') { expect(item.submenu.items[2].type).to.equal('separator') expect(item.submenu.items[3].role).to.equal('front') - expect(item.submenu.items[4].type).to.equal('separator') expect(item.submenu.items[5].role).to.equal('window') + } else { + expect(item.submenu.items[2].role).to.equal('close') } })