src/context-menu-builder.js
import {clipboard, nativeImage, remote, shell} from 'electron';
const {Menu, MenuItem} = remote;
let d = require('debug-electron')('electron-spellchecker:context-menu-builder');
/**
* ContextMenuBuilder creates context menus based on the content clicked - this
* information is derived from
* https://github.com/electron/electron/blob/master/docs/api/web-contents.md#event-context-menu,
* which we use to generate the menu. We also use the spell-check information to
* generate suggestions.
*/
export default class ContextMenuBuilder {
/**
* Creates an instance of ContextMenuBuilder
*
* @param {SpellCheckHandler} spellCheckHandler The spell checker to generate
* recommendations for.
* @param {BrowserWindow|WebView} windowOrWebView The hosting window/WebView
* @param {Boolean} debugMode If true, display the "Inspect Element" menu item.
*/
constructor(spellCheckHandler, windowOrWebView=null, debugMode=false) {
this.spellCheckHandler = spellCheckHandler;
this.windowOrWebView = windowOrWebView || remote.getCurrentWindow();
this.debugMode = debugMode;
this.menu = null;
}
/**
* Override the default logger for this class. You probably want to use
* {{setGlobalLogger}} instead
*
* @param {Function} fn The function which will operate like console.log
*/
static setLogger(fn) {
d = fn;
}
/**
* Shows a popup menu given the information returned from the context-menu
* event. This is probably the only method you need to call in this class.
*
* @param {Object} contextInfo The object returned from the 'context-menu'
* Electron event.
*
* @return {Promise} Completion
*/
async showPopupMenu(contextInfo) {
let menu = await this.buildMenuForElement(contextInfo);
// Opening a menu blocks the renderer process, which is definitely not
// suitable for running tests
if (!menu) return;
menu.popup(remote.getCurrentWindow());
}
/**
* Builds a context menu specific to the given info that _would_ be shown
* immediately by {{showPopupMenu}}. Use this to add your own menu items to
* the list but use most of the default behavior.
*
* @return {Promise<Menu>} The newly created `Menu`
*/
async buildMenuForElement(info) {
d(`Got context menu event with args: ${JSON.stringify(info)}`);
if (info.linkURL && info.linkURL.length > 0) {
return this.buildMenuForLink(info);
}
if (info.hasImageContents && info.srcURL && info.srcURL.length > 1) {
return this.buildMenuForImage(info);
}
if (info.isEditable || (info.inputFieldType && info.inputFieldType !== 'none')) {
return await this.buildMenuForTextInput(info);
}
return this.buildMenuForText(info);
}
/**
* Builds a menu applicable to a text input field.
*
* @return {Menu} The `Menu`
*/
async buildMenuForTextInput(menuInfo) {
let menu = new Menu();
await this.addSpellingItems(menu, menuInfo);
this.addSearchItems(menu, menuInfo);
this.addCut(menu, menuInfo);
this.addCopy(menu, menuInfo);
this.addPaste(menu, menuInfo);
this.addInspectElement(menu, menuInfo);
return menu;
}
/**
* Builds a menu applicable to a link element.
*
* @return {Menu} The `Menu`
*/
buildMenuForLink(menuInfo) {
let menu = new Menu();
let isEmailAddress = menuInfo.linkURL.startsWith('mailto:');
let copyLink = new MenuItem({
label: isEmailAddress ? 'Copy Email Address' : 'Copy Link',
click: () => {
// Omit the mailto: portion of the link; we just want the address
clipboard.writeText(isEmailAddress ?
menuInfo.linkText : menuInfo.linkURL);
}
});
let openLink = new MenuItem({
label: 'Open Link',
click: () => {
d(`Navigating to: ${menuInfo.linkURL}`);
shell.openExternal(menuInfo.linkURL);
}
});
menu.append(copyLink);
menu.append(openLink);
this.addSeparator(menu);
this.addImageItems(menu, menuInfo);
this.addInspectElement(menu, menuInfo);
return menu;
}
/**
* Builds a menu applicable to a text field.
*
* @return {Menu} The `Menu`
*/
buildMenuForText(menuInfo) {
let menu = new Menu();
this.addSearchItems(menu, menuInfo);
this.addCopy(menu, menuInfo);
this.addInspectElement(menu, menuInfo);
return menu;
}
/**
* Builds a menu applicable to an image.
*
* @return {Menu} The `Menu`
*/
buildMenuForImage(menuInfo) {
let menu = new Menu();
this.addImageItems(menu, menuInfo);
this.addInspectElement(menu, menuInfo);
return menu;
}
/**
* Checks if the current text selection contains a single misspelled word and
* if so, adds suggested spellings as individual menu items.
*/
async addSpellingItems(menu, menuInfo) {
let target = 'webContents' in this.windowOrWebView ?
this.windowOrWebView.webContents : this.windowOrWebView;
if (!menuInfo.misspelledWord || menuInfo.misspelledWord.length < 1) {
return menu;
}
// Ensure that we have a spell-checker for this language
if (!this.spellCheckHandler.currentSpellchecker) {
return menu;
}
// Ensure that we have valid corrections for that word
let corrections = await this.spellCheckHandler.getCorrectionsForMisspelling(menuInfo.misspelledWord);
if (!corrections || !corrections.length) {
return menu;
}
corrections.forEach((correction) => {
let item = new MenuItem({
label: correction,
click: () => target.replaceMisspelling(correction)
});
menu.append(item);
});
this.addSeparator(menu);
// Gate learning words based on OS support. At some point we can manage a
// custom dictionary for Hunspell, but today is not that day
if (process.platform === 'darwin') {
let learnWord = new MenuItem({
label: `Add to Dictionary`,
click: async () => {
// NB: This is a gross fix to invalidate the spelling underline,
// refer to https://github.com/tinyspeck/slack-winssb/issues/354
target.replaceMisspelling(menuInfo.selection);
try {
await this.spellChecker.add(menuInfo.misspelledWord);
} catch (e) {
d(`Failed to add entry to dictionary: ${e.message}`);
}
}
});
menu.append(learnWord);
}
return menu;
}
/**
* Adds search-related menu items.
*/
addSearchItems(menu, menuInfo) {
if (!menuInfo.selectionText || menuInfo.selectionText.length < 1) {
return menu;
}
let match = menuInfo.selectionText.match(/\w/);
if (!match || match.length === 0) {
return menu;
}
if (process.platform === 'darwin') {
let target = 'webContents' in this.windowOrWebView ?
this.windowOrWebView.webContents : this.windowOrWebView;
let lookUpDefinition = new MenuItem({
label: `Look Up “${menuInfo.selectionText}”`,
click: () => target.showDefinitionForSelection()
});
menu.append(lookUpDefinition);
}
let search = new MenuItem({
label: 'Search with Google',
click: () => {
let url = `https://www.google.com/#q=${encodeURIComponent(menuInfo.selectionText)}`;
d(`Searching Google using ${url}`);
shell.openExternal(url);
}
});
menu.append(search);
this.addSeparator(menu);
return menu;
}
/**
* Adds "Copy Image" and "Copy Image URL" items when `src` is valid.
*/
addImageItems(menu, menuInfo) {
if (!menuInfo.srcURL || menuInfo.srcURL.length === 0) {
return menu;
}
let copyImage = new MenuItem({
label: 'Copy Image',
click: () => this.convertImageToBase64(menuInfo.srcURL,
(dataURL) => clipboard.writeImage(nativeImage.createFromDataURL(dataURL)))
});
menu.append(copyImage);
let copyImageUrl = new MenuItem({
label: 'Copy Image URL',
click: () => clipboard.writeText(menuInfo.srcURL)
});
menu.append(copyImageUrl);
return menu;
}
/**
* Adds the Cut menu item
*/
addCut(menu, menuInfo) {
let target = 'webContents' in this.windowOrWebView ?
this.windowOrWebView.webContents : this.windowOrWebView;
menu.append(new MenuItem({
label: 'Cut',
accelerator: 'CommandOrControl+X',
enabled: menuInfo.editFlags.canCut,
click: () => target.cut()
}));
return menu;
}
/**
* Adds the Copy menu item.
*/
addCopy(menu, menuInfo) {
let target = 'webContents' in this.windowOrWebView ?
this.windowOrWebView.webContents : this.windowOrWebView;
menu.append(new MenuItem({
label: 'Copy',
accelerator: 'CommandOrControl+C',
enabled: menuInfo.editFlags.canCopy,
click: () => target.copy()
}));
return menu;
}
/**
* Adds the Paste menu item.
*/
addPaste(menu, menuInfo) {
let target = 'webContents' in this.windowOrWebView ?
this.windowOrWebView.webContents : this.windowOrWebView;
menu.append(new MenuItem({
label: 'Paste',
accelerator: 'CommandOrControl+V',
enabled: menuInfo.editFlags.canPaste,
click: () => target.paste()
}));
return menu;
}
/**
* Adds a separator item.
*/
addSeparator(menu) {
menu.append(new MenuItem({type: 'separator'}));
return menu;
}
/**
* Adds the "Inspect Element" menu item.
*/
addInspectElement(menu, menuInfo, needsSeparator=true) {
let target = 'webContents' in this.windowOrWebView ?
this.windowOrWebView.webContents : this.windowOrWebView;
if (!this.devMode) return menu;
if (needsSeparator) this.addSeparator(menu);
let inspect = new MenuItem({
label: 'Inspect Element',
click: () => target.inspectElement(menuInfo.x, menuInfo.y)
});
menu.append(inspect);
return menu;
}
/**
* Converts an image to a base-64 encoded string.
*
* @param {String} url The image URL
* @param {Function} callback A callback that will be invoked with the result
* @param {String} outputFormat The image format to use, defaults to 'image/png'
*/
convertImageToBase64(url, callback, outputFormat='image/png') {
let canvas = document.createElement('CANVAS');
let ctx = canvas.getContext('2d');
let img = new Image();
img.crossOrigin = 'Anonymous';
img.onload = () => {
canvas.height = img.height;
canvas.width = img.width;
ctx.drawImage(img, 0, 0);
let dataURL = canvas.toDataURL(outputFormat);
canvas = null;
callback(dataURL);
};
img.src = url;
}
}