diff --git a/fixture-menu.js b/fixture-menu.js new file mode 100644 index 0000000..549dede --- /dev/null +++ b/fixture-menu.js @@ -0,0 +1,36 @@ +'use strict'; +const { + app, + BrowserWindow +} = require('electron'); +const contextMenu = require('.'); + +contextMenu({ + menu: actions => [ + actions.separator(), + actions.copyLink({ + transform: content => `modified_link_${content}` + }), + actions.separator(), + { + label: 'Unicorn' + }, + actions.separator(), + actions.copy({ + transform: content => `modified_copy_${content}` + }), + { + label: 'Invisible', + visible: false + }, + actions.paste({ + transform: content => `modified_paste_${content}` + }) + ] +}); + +(async () => { + await app.whenReady(); + + new BrowserWindow().loadURL(`file://${__dirname}/fixture.html`); +})(); diff --git a/fixture.js b/fixture.js index 2584676..ea4794f 100644 --- a/fixture.js +++ b/fixture.js @@ -1,8 +1,5 @@ 'use strict'; -const { - app, - BrowserWindow -} = require('electron'); +const {app, BrowserWindow} = require('electron'); const contextMenu = require('.'); contextMenu({ @@ -13,26 +10,30 @@ contextMenu({ save: 'Configured Save Image', saveImageAs: 'Configured Save Image As…', copyLink: 'Configured Copy Link', - copyImageAddress: 'Configured Copy Image Address', inspect: 'Configured Inspect' }, - prepend: actions => [actions.cut({transform: content => 'modified_cut_' + content})], - menu: actions => [ - actions.separator(), - actions.copyLink({transform: content => 'modified_link_' + content}), - actions.separator(), + prepend: () => [ { label: 'Unicorn' }, - actions.separator(), - actions.copy({transform: content => 'modified_copy_' + content}), + { + type: 'separator' + }, + { + type: 'separator' + }, { label: 'Invisible', visible: false }, - actions.paste({transform: content => 'modified_paste_' + content}) + { + type: 'separator' + }, + { + type: 'separator' + } ], - append: actions => [actions.saveImage(), actions.saveImageAs(), actions.copyImageAddress(), actions.separator(), actions.inspect()], + append: () => {}, showCopyImageAddress: true, showSaveImageAs: true }); diff --git a/index.d.ts b/index.d.ts index be758d7..627e5d4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -50,7 +50,7 @@ export interface Labels { export interface ActionOptions { /** - * Apply transformation function to the content of the action + * Apply a transformation to the content of the action. */ readonly transform?: (content: string) => string; } diff --git a/index.js b/index.js index bfce5de..bc453f1 100644 --- a/index.js +++ b/index.js @@ -5,7 +5,29 @@ const isDev = require('electron-is-dev'); const webContents = win => win.webContents || win.getWebContents(); -function create(win, options) { +const decorateMenuItem = menuItem => { + return (options = {}) => { + if (options.transform && !options.click) { + menuItem.transform = options.transform; + } + + return menuItem; + }; +}; + +const removeUnusedMenuItems = menuTemplate => { + let notDeletedPreviousElement; + + return menuTemplate + .filter(menuItem => menuItem !== undefined && menuItem.visible !== false) + .filter((menuItem, index, array) => { + const toDelete = menuItem.type === 'separator' && (!notDeletedPreviousElement || index === array.length - 1 || array[index + 1].type === 'separator'); + notDeletedPreviousElement = toDelete ? notDeletedPreviousElement : menuItem; + return !toDelete; + }); +}; + +const create = (win, options) => { webContents(win).on('context-menu', (event, props) => { if (typeof options.shouldShowMenu === 'function' && options.shouldShowMenu(event, props) === false) { return; @@ -48,25 +70,19 @@ function create(win, options) { win.webContents.insertText(clipboardContent); } }), - inspect: () => { - return { - id: 'inspect', - label: 'Inspect Element', - enabled: options.showInspectElement || (options.showInspectElement !== false && isDev), - click() { - win.inspectElement(props.x, props.y); - - if (webContents(win).isDevToolsOpened()) { - webContents(win).devToolsWebContents.focus(); - } + inspect: () => ({ + id: 'inspect', + label: 'Inspect Element', + enabled: isDev, + click() { + win.inspectElement(props.x, props.y); + + if (webContents(win).isDevToolsOpened()) { + webContents(win).devToolsWebContents.focus(); } - }; - }, - separator: () => { - return { - type: 'separator' - }; - }, + } + }), + separator: () => ({type: 'separator'}), saveImage: decorateMenuItem({ id: 'save', label: 'Save Image', @@ -79,7 +95,7 @@ function create(win, options) { saveImageAs: decorateMenuItem({ id: 'saveImageAs', label: 'Save Image As…', - visible: options.showSaveImageAs && props.mediaType === 'image', + visible: props.mediaType === 'image', click(menuItem) { props.srcURL = menuItem.transform ? menuItem.transform(props.srcURL) : props.srcURL; download(win, props.srcURL, {saveAs: true}); @@ -101,7 +117,7 @@ function create(win, options) { copyImageAddress: decorateMenuItem({ id: 'copyImageAddress', label: 'Copy Image Address', - visible: options.showCopyImageAddress && props.mediaType === 'image', + visible: props.mediaType === 'image', click(menuItem) { props.srcURL = menuItem.transform ? menuItem.transform(props.srcURL) : props.srcURL; @@ -113,31 +129,31 @@ function create(win, options) { }) }; - let menuTpl = [ + let menuTemplate = [ defaultActions.separator(), defaultActions.cut(), defaultActions.copy(), defaultActions.paste(), defaultActions.separator(), defaultActions.saveImage(), - defaultActions.saveImageAs(), - defaultActions.copyImageAddress(), + options.showSaveImageAs && defaultActions.saveImageAs(), + options.showCopyImageAddress && defaultActions.copyImageAddress(), defaultActions.separator(), defaultActions.copyLink(), defaultActions.separator(), - defaultActions.inspect(), + options.showInspectElement && defaultActions.inspect(), defaultActions.separator() ]; if (options.menu) { - menuTpl = options.menu(defaultActions, props, win); + menuTemplate = options.menu(defaultActions, props, win); } if (options.prepend) { const result = options.prepend(defaultActions, props, win); if (Array.isArray(result)) { - menuTpl.unshift(...result); + menuTemplate.unshift(...result); } } @@ -145,64 +161,44 @@ function create(win, options) { const result = options.append(defaultActions, props, win); if (Array.isArray(result)) { - menuTpl.push(...result); + menuTemplate.push(...result); } } + // Filter out leading/trailing separators + // TODO: https://github.com/electron/electron/issues/5869 + menuTemplate = removeUnusedMenuItems(menuTemplate); + // Apply custom labels for default menu items if (options.labels) { - for (const menuItem of menuTpl) { + for (const menuItem of menuTemplate) { if (options.labels[menuItem.id]) { menuItem.label = options.labels[menuItem.id]; } } } - // Filter out leading/trailing separators - // TODO: https://github.com/electron/electron/issues/5869 - menuTpl = delUnusedElements(menuTpl); - - if (menuTpl.length > 0) { - const menu = (electron.remote ? electron.remote.Menu : electron.Menu).buildFromTemplate(menuTpl); + if (menuTemplate.length > 0) { + const menu = (electron.remote ? electron.remote.Menu : electron.Menu).buildFromTemplate(menuTemplate); /* - * When electron.remote is not available this runs in the browser process. - * We can safely use win in this case as it refers to the window the - * context-menu should open in. - * When this is being called from a webView, we can't use win as this - * would refere to the webView which is not allowed to render a popup menu. - */ + When `electron.remote`` is not available this runs in the browser process. + We can safely use `win`` in this case as it refers to the window the + context-menu should open in. + When this is being called from a webView, we can't use win as this + would refere to the webView which is not allowed to render a popup menu. + */ menu.popup(electron.remote ? electron.remote.getCurrentWindow() : win); } }); -} - -function decorateMenuItem(menuItem) { - return (options = {}) => { - if (options.transform && !options.click) { - menuItem.transform = options.transform; - } - - return menuItem; - }; -} - -function delUnusedElements(menuTpl) { - let notDeletedPrevEl; - return menuTpl.filter(el => el.visible !== false).filter((el, i, array) => { - const toDelete = el.type === 'separator' && (!notDeletedPrevEl || i === array.length - 1 || array[i + 1].type === 'separator'); - notDeletedPrevEl = toDelete ? notDeletedPrevEl : el; - return !toDelete; - }); -} +}; module.exports = (options = {}) => { if (options.window) { const win = options.window; - const wc = webContents(win); // When window is a webview that has not yet finished loading webContents is not available - if (wc === undefined) { + if (webContents(win) === undefined) { win.addEventListener('dom-ready', () => { create(win, options); }, {once: true}); diff --git a/readme.md b/readme.md index 53337be..df7f87c 100644 --- a/readme.md +++ b/readme.md @@ -36,10 +36,10 @@ contextMenu({ }] }); -let win; +let mainWindow; (async () => { await app.whenReady(); - win = new BrowserWindow(); + mainWindow = new BrowserWindow(); })(); ``` @@ -54,46 +54,12 @@ Type: `Object` #### window -Type: `BrowserWindow` `WebView`
+Type: `BrowserWindow | WebView`
Window or WebView to add the context menu to. When not specified, the context menu will be added to all existing and new windows. -#### menu -Type: `Function` - -Should return an array of [MenuItem](https://electronjs.org/docs/api/menu-item/)'s to be shown in the context menu. The first argument is an array of default actions that can be used. These actions are functions that can take an object with a transform property. The transform function will be passed the content of the action and can modify it if needed. If no menu property is defined, the default menu will be used. - -Default actions: -- `cut` -- `copy` -- `paste` -- `inspect` -- `separator` -- `saveImage` -- `saveImageAs` -- `copyLink` -- `copyImageAddress` - -```js -menu: (actions) => [ - actions.separator(), - actions.copyLink({transform: (content) => "modified_link_" + content}), - actions.separator(), - { - label: 'Unicorn' - }, - actions.separator(), - actions.copy({transform: (content) => "modified_copy_" + content}), - { - label: 'Invisible', - visible: false - }, - actions.paste({transform: (content) => "modified_paste_" + content}) - ] -``` - #### prepend Type: `Function` @@ -162,6 +128,60 @@ Example: shouldShowMenu: (event, params) => !params.isEditable ``` +#### menu + +Type: `Function` + +This option lets you manually pick what menu items to include. It's meant for advanced needs. The default menu with the other options should be enough for most use-cases, and it ensures correct behavior, for example, correct order of menu items. Prefer the `append`/`prepend` options instead of `menu` whenever possible. + +The function passed this options is expected to return [`MenuItem[]`](https://electronjs.org/docs/api/menu-item/). The first argument the function receives is an array of default actions that can be used. These actions are functions that can take an object with a transform property (except for `separator` and `inspect`). The transform function will be passed the content of the action and can modify it if needed. + +Even though you include an action, it will still only be shown/enabled when appropriate. For example, the `saveImage` action is only shown when right-clicking an image. + +The following options are ignored when `menu` is used: + +- `showCopyImageAddress` +- `showSaveImageAs` +- `showInspectElement` + +Default actions: + +- `separator` +- `cut` +- `copy` +- `paste` +- `saveImage` +- `saveImageAs` +- `copyImageAddress` +- `copyLink` +- `inspect` + +Example: + +```js +menu: actions => [ + actions.copyLink({ + transform: content => `modified_link_${content}` + }), + actions.separator(), + { + label: 'Unicorn' + }, + actions.separator(), + actions.copy({ + transform: content => `modified_copy_${content}` + }), + { + label: 'Invisible', + visible: false + }, + actions.paste({ + transform: content => `modified_paste_${content}` + }) +] +``` + + ## Related - [electron-util](https://github.com/sindresorhus/electron-util) - Useful utilities for developing Electron apps and modules