From 52211095aab71f8b59b093b19ae34c222be9f390 Mon Sep 17 00:00:00 2001 From: Markus Hatvan Date: Thu, 14 Oct 2021 09:32:20 +0200 Subject: chore: convert various JS to TS (#2062) --- src/stores/lib/Reaction.js | 31 -- src/stores/lib/Reaction.ts | 29 ++ src/webview/contextMenu.js | 20 -- src/webview/contextMenu.ts | 29 ++ src/webview/contextMenuBuilder.js | 622 -------------------------------- src/webview/contextMenuBuilder.ts | 721 ++++++++++++++++++++++++++++++++++++++ src/webview/darkmode/custom.js | 22 -- src/webview/darkmode/custom.ts | 22 ++ src/webview/darkmode/ignore.js | 3 - src/webview/darkmode/ignore.ts | 1 + src/webview/find.js | 23 -- src/webview/find.ts | 27 ++ src/webview/notifications.js | 71 ---- src/webview/notifications.ts | 72 ++++ src/webview/screenshare.js | 143 -------- src/webview/screenshare.ts | 143 ++++++++ 16 files changed, 1044 insertions(+), 935 deletions(-) delete mode 100644 src/stores/lib/Reaction.js create mode 100644 src/stores/lib/Reaction.ts delete mode 100644 src/webview/contextMenu.js create mode 100644 src/webview/contextMenu.ts delete mode 100644 src/webview/contextMenuBuilder.js create mode 100644 src/webview/contextMenuBuilder.ts delete mode 100644 src/webview/darkmode/custom.js create mode 100644 src/webview/darkmode/custom.ts delete mode 100644 src/webview/darkmode/ignore.js create mode 100644 src/webview/darkmode/ignore.ts delete mode 100644 src/webview/find.js create mode 100644 src/webview/find.ts delete mode 100644 src/webview/notifications.js create mode 100644 src/webview/notifications.ts delete mode 100644 src/webview/screenshare.js create mode 100644 src/webview/screenshare.ts diff --git a/src/stores/lib/Reaction.js b/src/stores/lib/Reaction.js deleted file mode 100644 index 7e1bc685e..000000000 --- a/src/stores/lib/Reaction.js +++ /dev/null @@ -1,31 +0,0 @@ -import { autorun } from 'mobx'; - -export default class Reaction { - reaction; - - isRunning = false; - - dispose; - - constructor(reaction) { - this.reaction = reaction; - } - - start() { - if (!this.isRunning) { - this.dispose = autorun(this.reaction); - this.isRunning = true; - } - } - - stop() { - if (this.isRunning) { - this.dispose(); - this.isRunning = false; - } - } -} - -export const createReactions = (reactions) => ( - reactions.map((r) => new Reaction(r)) -); diff --git a/src/stores/lib/Reaction.ts b/src/stores/lib/Reaction.ts new file mode 100644 index 000000000..0ca24a6fa --- /dev/null +++ b/src/stores/lib/Reaction.ts @@ -0,0 +1,29 @@ +import { autorun } from 'mobx'; + +export default class Reaction { + reaction; + + isRunning = false; + + dispose; + + constructor(reaction) { + this.reaction = reaction; + } + + start() { + if (!this.isRunning) { + this.dispose = autorun(this.reaction); + this.isRunning = true; + } + } + + stop() { + if (this.isRunning) { + this.dispose?.(); + this.isRunning = false; + } + } +} + +export const createReactions = reactions => reactions.map(r => new Reaction(r)); diff --git a/src/webview/contextMenu.js b/src/webview/contextMenu.js deleted file mode 100644 index 567a2d470..000000000 --- a/src/webview/contextMenu.js +++ /dev/null @@ -1,20 +0,0 @@ -import { getCurrentWebContents } from '@electron/remote'; -import ContextMenuBuilder from './contextMenuBuilder'; - -const webContents = getCurrentWebContents(); - -export default async function setupContextMenu(isSpellcheckEnabled, getDefaultSpellcheckerLanguage, getSpellcheckerLanguage, getSearchEngine, getClipboardNotifications) { - const contextMenuBuilder = new ContextMenuBuilder( - webContents, - ); - - webContents.on('context-menu', (e, props) => { - // TODO?: e.preventDefault(); - contextMenuBuilder.showPopupMenu( - { ...props, searchEngine: getSearchEngine(), clipboardNotifications: getClipboardNotifications() }, - isSpellcheckEnabled(), - getDefaultSpellcheckerLanguage(), - getSpellcheckerLanguage(), - ); - }); -} diff --git a/src/webview/contextMenu.ts b/src/webview/contextMenu.ts new file mode 100644 index 000000000..72f927ef4 --- /dev/null +++ b/src/webview/contextMenu.ts @@ -0,0 +1,29 @@ +import { getCurrentWebContents } from '@electron/remote'; +import { ContextMenuBuilder } from './contextMenuBuilder'; + +const webContents = getCurrentWebContents(); + +export default async function setupContextMenu( + isSpellcheckEnabled: () => void, + getDefaultSpellcheckerLanguage: () => void, + getSpellcheckerLanguage: () => void, + getSearchEngine: () => void, + getClipboardNotifications: () => void, +) { + const contextMenuBuilder = new ContextMenuBuilder(webContents); + + webContents.on('context-menu', (_e, props) => { + // TODO?: e.preventDefault(); + contextMenuBuilder.showPopupMenu( + { + ...props, + searchEngine: getSearchEngine(), + clipboardNotifications: getClipboardNotifications(), + }, + // @ts-expect-error Expected 1 arguments, but got 4. + isSpellcheckEnabled(), + getDefaultSpellcheckerLanguage(), + getSpellcheckerLanguage(), + ); + }); +} diff --git a/src/webview/contextMenuBuilder.js b/src/webview/contextMenuBuilder.js deleted file mode 100644 index 938eade1e..000000000 --- a/src/webview/contextMenuBuilder.js +++ /dev/null @@ -1,622 +0,0 @@ -/** - * Context Menu builder. - * - * Based on "electron-spellchecker"'s ContextMenuBuilder but customized for Ferdi - * and for usage with Electron's build-in spellchecker - * - * Source: https://github.com/electron-userland/electron-spellchecker/blob/master/src/context-menu-builder.js - */ -// eslint-disable-next-line no-unused-vars -import { clipboard, ipcRenderer, nativeImage, WebContents } from 'electron'; -import { Menu, MenuItem } from '@electron/remote'; -import { cmdOrCtrlShortcutKey, isMac } from '../environment'; - -import { SEARCH_ENGINE_NAMES, SEARCH_ENGINE_URLS } from '../config'; -import { openExternalUrl } from '../helpers/url-helpers'; - -const { URL } = require('url'); - -function matchesWord(string) { - const regex = - /[A-Za-z\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]+/g; - - return string.match(regex); -} - -// TODO: Need to externalize for i18n -const contextMenuStringTable = { - lookUpDefinition: ({ word }) => `Look Up "${word}"`, - cut: () => 'Cut', - copy: () => 'Copy', - paste: () => 'Paste', - pasteAndMatchStyle: () => 'Paste and match style', - searchWith: ({ searchEngine }) => `Search with ${searchEngine}`, - openLinkUrl: () => 'Open Link', - openLinkInFerdiUrl: () => 'Open Link in Ferdi', - openInBrowser: () => 'Open in Browser', - copyLinkUrl: () => 'Copy Link', - copyImageUrl: () => 'Copy Image Address', - copyImage: () => 'Copy Image', - downloadImage: () => 'Download Image', - addToDictionary: () => 'Add to Dictionary', - goBack: () => 'Go Back', - goForward: () => 'Go Forward', - copyPageUrl: () => 'Copy Page URL', - goToHomePage: () => 'Go to Home Page', - copyMail: () => 'Copy Email Address', - inspectElement: () => 'Inspect Element', -}; - -/** - * 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. - */ -module.exports = class ContextMenuBuilder { - /** - * Creates an instance of ContextMenuBuilder - * - * @param {WebContents} webContents Current webContents - * @param {Boolean} debugMode If true, display the "Inspect Element" menu item. - * @param {function} processMenu If passed, this method will be passed the menu to change - * it prior to display. Signature: (menu, info) => menu - */ - constructor(webContents, debugMode = false, processMenu = m => m) { - this.debugMode = debugMode; - this.processMenu = processMenu; - this.menu = null; - this.stringTable = { ...contextMenuStringTable }; - this.getWebContents = () => webContents; - } - - /** - * Specify alternate string formatter for each context menu. - * String table consist of string formatter as function instead per each context menu item, - * allows to change string in runtime. All formatters are simply typeof () => string, except - * lookUpDefinition provides word, ({word}) => string. - * - * @param {Object} stringTable The object contains string foramtter function for context menu. - * It is allowed to specify only certain menu string as necessary, which will makes other string - * fall backs to default. - * - */ - setAlternateStringFormatter(stringTable) { - this.stringTable = Object.assign(this.stringTable, stringTable); - } - - /** - * 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) { - const menu = await this.buildMenuForElement(contextInfo); - if (!menu) return; - menu.popup(); - } - - /** - * 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} The newly created `Menu` - */ - async buildMenuForElement(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 this.buildMenuForTextInput(info); - } - - return this.buildMenuForText(info); - } - - /** - * Builds a menu applicable to a text input field. - * - * @return {Menu} The `Menu` - */ - buildMenuForTextInput(menuInfo) { - const menu = new Menu(); - - this.addSpellingItems(menu, menuInfo); - this.addSearchItems(menu, menuInfo); - - this.addCut(menu, menuInfo); - this.addCopy(menu, menuInfo); - this.addPaste(menu, menuInfo); - this.addPastePlain(menu, menuInfo); - this.addInspectElement(menu, menuInfo); - this.processMenu(menu, menuInfo); - - this.copyPageUrl(menu); - this.goToHomePage(menu, menuInfo); - this.openInBrowser(menu, menuInfo); - - return menu; - } - - /** - * Builds a menu applicable to a link element. - * - * @return {Menu} The `Menu` - */ - buildMenuForLink(menuInfo) { - const menu = new Menu(); - const isEmailAddress = menuInfo.linkURL.startsWith('mailto:'); - - const copyLink = new MenuItem({ - label: isEmailAddress - ? this.stringTable.copyMail() - : this.stringTable.copyLinkUrl(), - click: () => { - // Omit the mailto: portion of the link; we just want the address - const url = isEmailAddress ? menuInfo.linkText : menuInfo.linkURL; - clipboard.writeText(url); - this._sendNotificationOnClipboardEvent( - menuInfo.clipboardNotifications, - () => `Link URL copied: ${url}`, - ); - }, - }); - - const openLink = new MenuItem({ - label: this.stringTable.openLinkUrl(), - click: () => { - openExternalUrl(menuInfo.linkURL, true); - }, - }); - - const openInFerdiLink = new MenuItem({ - label: this.stringTable.openLinkInFerdiUrl(), - click: () => { - window.location.href = menuInfo.linkURL; - }, - }); - - menu.append(copyLink); - menu.append(openLink); - menu.append(openInFerdiLink); - - if (this.isSrcUrlValid(menuInfo)) { - this.addSeparator(menu); - this.addImageItems(menu, menuInfo); - } - - this.addInspectElement(menu, menuInfo); - this.processMenu(menu, menuInfo); - - this.addSeparator(menu); - this.goBack(menu); - this.goForward(menu); - this.copyPageUrl(menu); - this.goToHomePage(menu, menuInfo); - this.openInBrowser(menu, menuInfo); - - return menu; - } - - /** - * Builds a menu applicable to a text field. - * - * @return {Menu} The `Menu` - */ - buildMenuForText(menuInfo) { - const menu = new Menu(); - - this.addSearchItems(menu, menuInfo); - this.addCopy(menu, menuInfo); - this.addInspectElement(menu, menuInfo); - this.processMenu(menu, menuInfo); - - this.addSeparator(menu); - this.goBack(menu); - this.goForward(menu); - this.copyPageUrl(menu, menuInfo); - this.goToHomePage(menu, menuInfo); - this.openInBrowser(menu, menuInfo); - - return menu; - } - - /** - * Builds a menu applicable to an image. - * - * @return {Menu} The `Menu` - */ - buildMenuForImage(menuInfo) { - const menu = new Menu(); - - if (this.isSrcUrlValid(menuInfo)) { - this.addImageItems(menu, menuInfo); - } - this.addInspectElement(menu, menuInfo); - this.processMenu(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. - */ - addSpellingItems(menu, menuInfo) { - const webContents = this.getWebContents(); - // Add each spelling suggestion - for (const suggestion of menuInfo.dictionarySuggestions) { - menu.append( - new MenuItem({ - label: suggestion, - // eslint-disable-next-line no-loop-func - click: () => webContents.replaceMisspelling(suggestion), - }), - ); - } - - // Allow users to add the misspelled word to the dictionary - if (menuInfo.misspelledWord) { - menu.append( - new MenuItem({ - label: this.stringTable.addToDictionary(), - click: () => - webContents.session.addWordToSpellCheckerDictionary( - menuInfo.misspelledWord, - ), - }), - ); - } - - return menu; - } - - /** - * Adds search-related menu items. - */ - addSearchItems(menu, menuInfo) { - if (!menuInfo.selectionText || menuInfo.selectionText.length === 0) { - return menu; - } - - const match = matchesWord(menuInfo.selectionText); - if (!match || match.length === 0) { - return menu; - } - - if (isMac) { - const webContents = this.getWebContents(); - - const lookUpDefinition = new MenuItem({ - label: this.stringTable.lookUpDefinition({ - word: menuInfo.selectionText.trim(), - }), - click: () => webContents.showDefinitionForSelection(), - }); - - menu.append(lookUpDefinition); - } - - const search = new MenuItem({ - label: this.stringTable.searchWith({ - searchEngine: SEARCH_ENGINE_NAMES[menuInfo.searchEngine], - }), - click: () => { - const url = SEARCH_ENGINE_URLS[menuInfo.searchEngine]({ - searchTerm: encodeURIComponent(menuInfo.selectionText), - }); - openExternalUrl(url, true); - }, - }); - - menu.append(search); - this.addSeparator(menu); - - return menu; - } - - isSrcUrlValid(menuInfo) { - return menuInfo.srcURL && menuInfo.srcURL.length > 0; - } - - /** - * Adds "Copy Image" and "Copy Image URL" items when `src` is valid. - */ - addImageItems(menu, menuInfo) { - const copyImage = new MenuItem({ - label: this.stringTable.copyImage(), - click: () => { - const result = this.convertImageToBase64(menuInfo.srcURL, dataURL => - clipboard.writeImage(nativeImage.createFromDataURL(dataURL)), - ); - - this._sendNotificationOnClipboardEvent( - menuInfo.clipboardNotifications, - () => `Image copied from URL: ${menuInfo.srcURL}`, - ); - return result; - }, - }); - - menu.append(copyImage); - - const copyImageUrl = new MenuItem({ - label: this.stringTable.copyImageUrl(), - click: () => { - const result = clipboard.writeText(menuInfo.srcURL); - this._sendNotificationOnClipboardEvent( - menuInfo.clipboardNotifications, - () => `Image URL copied: ${menuInfo.srcURL}`, - ); - return result; - }, - }); - - menu.append(copyImageUrl); - - // TODO: This doesn't seem to work on linux, so, limiting to Mac for now - if (isMac && menuInfo.srcURL.startsWith('blob:')) { - const downloadImage = new MenuItem({ - label: this.stringTable.downloadImage(), - click: () => { - const urlWithoutBlob = menuInfo.srcURL.slice(5); - this.convertImageToBase64(menuInfo.srcURL, dataURL => { - const url = new window.URL(urlWithoutBlob); - const fileName = url.pathname.slice(1); - ipcRenderer.send('download-file', { - content: dataURL, - fileOptions: { - name: fileName, - mime: 'image/png', - }, - }); - }); - this._sendNotificationOnClipboardEvent( - menuInfo.clipboardNotifications, - () => `Image downloaded: ${urlWithoutBlob}`, - ); - }, - }); - - menu.append(downloadImage); - } - - return menu; - } - - /** - * Adds the Cut menu item - */ - addCut(menu, menuInfo) { - const webContents = this.getWebContents(); - menu.append( - new MenuItem({ - label: this.stringTable.cut(), - accelerator: `${cmdOrCtrlShortcutKey()}+X`, - enabled: menuInfo.editFlags.canCut, - click: () => webContents.cut(), - }), - ); - - return menu; - } - - /** - * Adds the Copy menu item. - */ - addCopy(menu, menuInfo) { - const webContents = this.getWebContents(); - menu.append( - new MenuItem({ - label: this.stringTable.copy(), - accelerator: `${cmdOrCtrlShortcutKey()}+C`, - enabled: menuInfo.editFlags.canCopy, - click: () => webContents.copy(), - }), - ); - - return menu; - } - - /** - * Adds the Paste menu item. - */ - addPaste(menu, menuInfo) { - const webContents = this.getWebContents(); - menu.append( - new MenuItem({ - label: this.stringTable.paste(), - accelerator: `${cmdOrCtrlShortcutKey()}+V`, - enabled: menuInfo.editFlags.canPaste, - click: () => webContents.paste(), - }), - ); - - return menu; - } - - addPastePlain(menu, menuInfo) { - if ( - menuInfo.editFlags.canPaste && - !menuInfo.linkText && - !menuInfo.hasImageContents - ) { - const webContents = this.getWebContents(); - menu.append( - new MenuItem({ - label: this.stringTable.pasteAndMatchStyle(), - accelerator: `${cmdOrCtrlShortcutKey()}+Shift+V`, - click: () => webContents.pasteAndMatchStyle(), - }), - ); - } - } - - /** - * 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) { - const webContents = this.getWebContents(); - if (!this.debugMode) return menu; - if (needsSeparator) this.addSeparator(menu); - - const inspect = new MenuItem({ - label: this.stringTable.inspectElement(), - click: () => webContents.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'); - const ctx = canvas.getContext('2d'); - // eslint-disable-next-line no-undef - const img = new Image(); - img.crossOrigin = 'Anonymous'; - - img.addEventListener('load', () => { - canvas.height = img.height; - canvas.width = img.width; - ctx?.drawImage(img, 0, 0); - - const dataURL = canvas.toDataURL(outputFormat); - canvas = null; - callback(dataURL); - }); - - img.src = url; - } - - /** - * Adds the 'go back' menu item - */ - goBack(menu) { - const webContents = this.getWebContents(); - menu.append( - new MenuItem({ - label: this.stringTable.goBack(), - accelerator: `${cmdOrCtrlShortcutKey()}+left`, - enabled: webContents.canGoBack(), - click: () => webContents.goBack(), - }), - ); - - return menu; - } - - /** - * Adds the 'go forward' menu item - */ - goForward(menu) { - const webContents = this.getWebContents(); - menu.append( - new MenuItem({ - label: this.stringTable.goForward(), - accelerator: `${cmdOrCtrlShortcutKey()}+right`, - enabled: webContents.canGoForward(), - click: () => webContents.goForward(), - }), - ); - - return menu; - } - - /** - * Adds the 'copy page url' menu item. - */ - copyPageUrl(menu, menuInfo) { - menu.append( - new MenuItem({ - label: this.stringTable.copyPageUrl(), - enabled: true, - click: () => { - clipboard.writeText(window.location.href); - this._sendNotificationOnClipboardEvent( - menuInfo.clipboardNotifications, - () => `Page URL copied: ${window.location.href}`, - ); - }, - }), - ); - - return menu; - } - - /** - * Adds the 'go to home' menu item. - */ - goToHomePage(menu, menuInfo) { - const baseURL = new URL(menuInfo.pageURL); - menu.append( - new MenuItem({ - label: this.stringTable.goToHomePage(), - accelerator: `${cmdOrCtrlShortcutKey()}+Home`, - enabled: true, - click: () => { - // webContents.loadURL(baseURL.origin); - window.location.href = baseURL.origin; - }, - }), - ); - - return menu; - } - - /** - * Adds the 'open in browser' menu item. - */ - openInBrowser(menu, menuInfo) { - menu.append( - new MenuItem({ - label: this.stringTable.openInBrowser(), - enabled: true, - click: () => { - openExternalUrl(menuInfo.pageURL, true); - }, - }), - ); - - return menu; - } - - _sendNotificationOnClipboardEvent(isDisabled, notificationText) { - if (isDisabled) { - return; - } - // eslint-disable-next-line no-new - new window.Notification('Data copied into Clipboard', { - body: notificationText(), - }); - } -}; diff --git a/src/webview/contextMenuBuilder.ts b/src/webview/contextMenuBuilder.ts new file mode 100644 index 000000000..fda5fa8b8 --- /dev/null +++ b/src/webview/contextMenuBuilder.ts @@ -0,0 +1,721 @@ +/** + * Context Menu builder. + * + * Based on "electron-spellchecker"'s ContextMenuBuilder but customized for Ferdi + * and for usage with Electron's build-in spellchecker + * + * Source: https://github.com/electron-userland/electron-spellchecker/blob/master/src/context-menu-builder.js + */ +// eslint-disable-next-line no-unused-vars +import { clipboard, ipcRenderer, nativeImage, WebContents } from 'electron'; +import { Menu, MenuItem } from '@electron/remote'; +import { cmdOrCtrlShortcutKey, isMac } from '../environment'; + +import { SEARCH_ENGINE_NAMES, SEARCH_ENGINE_URLS } from '../config'; +import { openExternalUrl } from '../helpers/url-helpers'; + +function matchesWord(string: string) { + const regex = + /[A-Za-z\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]+/g; + + return string.match(regex); +} + +interface ContextMenuStringTable { + lookUpDefinition: ({ word }: { word: string }) => string; + cut: () => string; + copy: () => string; + paste: () => string; + pasteAndMatchStyle: () => string; + searchWith: ({ searchEngine }: { searchEngine: string }) => string; + openLinkUrl: () => string; + openLinkInFerdiUrl: () => string; + openInBrowser: () => string; + copyLinkUrl: () => string; + copyImageUrl: () => string; + copyImage: () => string; + downloadImage: () => string; + addToDictionary: () => string; + goBack: () => string; + goForward: () => string; + copyPageUrl: () => string; + goToHomePage: () => string; + copyMail: () => string; + inspectElement: () => string; +} + +// TODO: Need to externalize for i18n +const contextMenuStringTable: ContextMenuStringTable = { + lookUpDefinition: ({ word }) => `Look Up "${word}"`, + cut: () => 'Cut', + copy: () => 'Copy', + paste: () => 'Paste', + pasteAndMatchStyle: () => 'Paste and match style', + searchWith: ({ searchEngine }) => `Search with ${searchEngine}`, + openLinkUrl: () => 'Open Link', + openLinkInFerdiUrl: () => 'Open Link in Ferdi', + openInBrowser: () => 'Open in Browser', + copyLinkUrl: () => 'Copy Link', + copyImageUrl: () => 'Copy Image Address', + copyImage: () => 'Copy Image', + downloadImage: () => 'Download Image', + addToDictionary: () => 'Add to Dictionary', + goBack: () => 'Go Back', + goForward: () => 'Go Forward', + copyPageUrl: () => 'Copy Page URL', + goToHomePage: () => 'Go to Home Page', + copyMail: () => 'Copy Email Address', + inspectElement: () => 'Inspect Element', +}; + +/** + * 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 class ContextMenuBuilder { + debugMode: boolean; + + processMenu: ( + menu: Electron.CrossProcessExports.Menu, + ) => Electron.CrossProcessExports.Menu; + + menu: Electron.CrossProcessExports.Menu | null; + + stringTable: ContextMenuStringTable; + + getWebContents: () => Electron.WebContents; + + /** + * Creates an instance of ContextMenuBuilder + * + * @param webContents Current webContents + * @param debugMode If true, display the "Inspect Element" menu item. + * @param processMenu If passed, this method will be passed the menu to change it prior to display. Signature: (menu, info) => menu + */ + constructor( + webContents: WebContents, + debugMode: boolean = false, + processMenu = (menu: Electron.CrossProcessExports.Menu) => menu, + ) { + this.debugMode = debugMode; + this.processMenu = processMenu; + this.menu = null; + this.stringTable = { ...contextMenuStringTable }; + this.getWebContents = () => webContents; + } + + /** + * Specify alternate string formatter for each context menu. + * String table consist of string formatter as function instead per each context menu item, + * allows to change string in runtime. All formatters are simply typeof () => string, except + * lookUpDefinition provides word, ({word}) => string. + * + * @param stringTable The object contains string formatter function for context menu. + * It is allowed to specify only certain menu string as necessary, which will makes other string + * fall backs to default. + */ + setAlternateStringFormatter(stringTable: ContextMenuStringTable) { + this.stringTable = Object.assign(this.stringTable, stringTable); + } + + /** + * 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 contextInfo The object returned from the 'context-menu' Electron event. + */ + async showPopupMenu(contextInfo: Electron.ContextMenuParams): Promise { + const menu = await this.buildMenuForElement(contextInfo); + if (!menu) return; + menu.popup(); + } + + /** + * 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. + */ + async buildMenuForElement( + info: Electron.ContextMenuParams, + ): Promise { + 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 this.buildMenuForTextInput(info); + } + + return this.buildMenuForText(info); + } + + /** + * Builds a menu applicable to a text input field. + * + * @return {Menu} The `Menu` + */ + buildMenuForTextInput( + menuInfo: Electron.ContextMenuParams, + ): Electron.CrossProcessExports.Menu { + const menu = new Menu(); + + this.addSpellingItems(menu, menuInfo); + this.addSearchItems(menu, menuInfo); + + this.addCut(menu, menuInfo); + this.addCopy(menu, menuInfo); + this.addPaste(menu, menuInfo); + this.addPastePlain(menu, menuInfo); + this.addInspectElement(menu, menuInfo); + // @ts-expect-error Expected 1 arguments, but got 2. + this.processMenu(menu, menuInfo); + + // @ts-expect-error Expected 2 arguments, but got 1. + this.copyPageUrl(menu); + this.goToHomePage(menu, menuInfo); + this.openInBrowser(menu, menuInfo); + + return menu; + } + + /** + * Builds a menu applicable to a link element. + * + * @return {Menu} The `Menu` + */ + buildMenuForLink( + menuInfo: Electron.ContextMenuParams, + ): Electron.CrossProcessExports.Menu { + const menu = new Menu(); + const isEmailAddress = menuInfo.linkURL.startsWith('mailto:'); + + const copyLink = new MenuItem({ + label: isEmailAddress + ? this.stringTable.copyMail() + : this.stringTable.copyLinkUrl(), + click: () => { + // Omit the mailto: portion of the link; we just want the address + const url = isEmailAddress ? menuInfo.linkText : menuInfo.linkURL; + clipboard.writeText(url); + this._sendNotificationOnClipboardEvent( + // @ts-expect-error Property 'clipboardNotifications' does not exist on type 'ContextMenuParams'. + menuInfo.clipboardNotifications, + () => `Link URL copied: ${url}`, + ); + }, + }); + + const openLink = new MenuItem({ + label: this.stringTable.openLinkUrl(), + click: () => { + openExternalUrl(menuInfo.linkURL, true); + }, + }); + + const openInFerdiLink = new MenuItem({ + label: this.stringTable.openLinkInFerdiUrl(), + click: () => { + window.location.href = menuInfo.linkURL; + }, + }); + + menu.append(copyLink); + menu.append(openLink); + menu.append(openInFerdiLink); + + if (this.isSrcUrlValid(menuInfo)) { + this.addSeparator(menu); + this.addImageItems(menu, menuInfo); + } + + this.addInspectElement(menu, menuInfo); + // @ts-expect-error Expected 1 arguments, but got 2. + this.processMenu(menu, menuInfo); + + this.addSeparator(menu); + this.goBack(menu); + this.goForward(menu); + // @ts-expect-error Expected 2 arguments, but got 1. + this.copyPageUrl(menu); + this.goToHomePage(menu, menuInfo); + this.openInBrowser(menu, menuInfo); + + return menu; + } + + /** + * Builds a menu applicable to a text field. + */ + buildMenuForText( + menuInfo: Electron.ContextMenuParams, + ): Electron.CrossProcessExports.Menu { + const menu = new Menu(); + + this.addSearchItems(menu, menuInfo); + this.addCopy(menu, menuInfo); + this.addInspectElement(menu, menuInfo); + // @ts-expect-error Expected 1 arguments, but got 2. + this.processMenu(menu, menuInfo); + + this.addSeparator(menu); + this.goBack(menu); + this.goForward(menu); + this.copyPageUrl(menu, menuInfo); + this.goToHomePage(menu, menuInfo); + this.openInBrowser(menu, menuInfo); + + return menu; + } + + /** + * Builds a menu applicable to an image. + * + * @return {Menu} The `Menu` + */ + buildMenuForImage( + menuInfo: Electron.ContextMenuParams, + ): Electron.CrossProcessExports.Menu { + const menu = new Menu(); + + if (this.isSrcUrlValid(menuInfo)) { + this.addImageItems(menu, menuInfo); + } + this.addInspectElement(menu, menuInfo); + // @ts-expect-error Expected 1 arguments, but got 2. + this.processMenu(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. + */ + addSpellingItems( + menu: Electron.CrossProcessExports.Menu, + menuInfo: Electron.ContextMenuParams, + ) { + const webContents = this.getWebContents(); + // Add each spelling suggestion + for (const suggestion of menuInfo.dictionarySuggestions) { + menu.append( + new MenuItem({ + label: suggestion, + // eslint-disable-next-line no-loop-func + click: () => webContents.replaceMisspelling(suggestion), + }), + ); + } + + // Allow users to add the misspelled word to the dictionary + if (menuInfo.misspelledWord) { + menu.append( + new MenuItem({ + label: this.stringTable.addToDictionary(), + click: () => + webContents.session.addWordToSpellCheckerDictionary( + menuInfo.misspelledWord, + ), + }), + ); + } + + return menu; + } + + /** + * Adds search-related menu items. + */ + addSearchItems( + menu: Electron.CrossProcessExports.Menu, + menuInfo: Electron.ContextMenuParams, + ) { + if (!menuInfo.selectionText || menuInfo.selectionText.length === 0) { + return menu; + } + + const match = matchesWord(menuInfo.selectionText); + if (!match || match.length === 0) { + return menu; + } + + if (isMac) { + const webContents = this.getWebContents(); + + const lookUpDefinition = new MenuItem({ + label: this.stringTable.lookUpDefinition({ + word: menuInfo.selectionText.trim(), + }), + click: () => webContents.showDefinitionForSelection(), + }); + + menu.append(lookUpDefinition); + } + + const search = new MenuItem({ + label: this.stringTable.searchWith({ + // @ts-expect-error Property 'searchEngine' does not exist on type 'ContextMenuParams'. + searchEngine: SEARCH_ENGINE_NAMES[menuInfo.searchEngine], + }), + click: () => { + // @ts-expect-error Property 'searchEngine' does not exist on type 'ContextMenuParams'. + const url = SEARCH_ENGINE_URLS[menuInfo.searchEngine]({ + searchTerm: encodeURIComponent(menuInfo.selectionText), + }); + openExternalUrl(url, true); + }, + }); + + menu.append(search); + this.addSeparator(menu); + + return menu; + } + + isSrcUrlValid(menuInfo: Electron.ContextMenuParams) { + return menuInfo.srcURL && menuInfo.srcURL.length > 0; + } + + /** + * Adds "Copy Image" and "Copy Image URL" items when `src` is valid. + */ + addImageItems( + menu: Electron.CrossProcessExports.Menu, + menuInfo: Electron.ContextMenuParams, + ) { + const copyImage = new MenuItem({ + label: this.stringTable.copyImage(), + click: () => { + const result = this.convertImageToBase64( + menuInfo.srcURL, + (dataURL: string) => + clipboard.writeImage(nativeImage.createFromDataURL(dataURL)), + ); + + this._sendNotificationOnClipboardEvent( + // @ts-expect-error Property 'clipboardNotifications' does not exist on type 'ContextMenuParams'. + menuInfo.clipboardNotifications, + () => `Image copied from URL: ${menuInfo.srcURL}`, + ); + return result; + }, + }); + + menu.append(copyImage); + + const copyImageUrl = new MenuItem({ + label: this.stringTable.copyImageUrl(), + click: () => { + const result = clipboard.writeText(menuInfo.srcURL); + this._sendNotificationOnClipboardEvent( + // @ts-expect-error Property 'clipboardNotifications' does not exist on type 'ContextMenuParams'. + menuInfo.clipboardNotifications, + () => `Image URL copied: ${menuInfo.srcURL}`, + ); + return result; + }, + }); + + menu.append(copyImageUrl); + + // TODO: This doesn't seem to work on linux, so, limiting to Mac for now + if (isMac && menuInfo.srcURL.startsWith('blob:')) { + const downloadImage = new MenuItem({ + label: this.stringTable.downloadImage(), + click: () => { + const urlWithoutBlob = menuInfo.srcURL.slice(5); + this.convertImageToBase64(menuInfo.srcURL, (dataURL: any) => { + const url = new window.URL(urlWithoutBlob); + const fileName = url.pathname.slice(1); + ipcRenderer.send('download-file', { + content: dataURL, + fileOptions: { + name: fileName, + mime: 'image/png', + }, + }); + }); + this._sendNotificationOnClipboardEvent( + // @ts-expect-error Property 'clipboardNotifications' does not exist on type 'ContextMenuParams'. + menuInfo.clipboardNotifications, + () => `Image downloaded: ${urlWithoutBlob}`, + ); + }, + }); + + menu.append(downloadImage); + } + + return menu; + } + + /** + * Adds the Cut menu item + */ + addCut( + menu: Electron.CrossProcessExports.Menu, + menuInfo: Electron.ContextMenuParams, + ) { + const webContents = this.getWebContents(); + menu.append( + new MenuItem({ + label: this.stringTable.cut(), + accelerator: `${cmdOrCtrlShortcutKey()}+X`, + enabled: menuInfo.editFlags.canCut, + click: () => webContents.cut(), + }), + ); + + return menu; + } + + /** + * Adds the Copy menu item. + */ + addCopy( + menu: Electron.CrossProcessExports.Menu, + menuInfo: Electron.ContextMenuParams, + ) { + const webContents = this.getWebContents(); + menu.append( + new MenuItem({ + label: this.stringTable.copy(), + accelerator: `${cmdOrCtrlShortcutKey()}+C`, + enabled: menuInfo.editFlags.canCopy, + click: () => webContents.copy(), + }), + ); + + return menu; + } + + /** + * Adds the Paste menu item. + */ + addPaste( + menu: Electron.CrossProcessExports.Menu, + menuInfo: Electron.ContextMenuParams, + ) { + const webContents = this.getWebContents(); + menu.append( + new MenuItem({ + label: this.stringTable.paste(), + accelerator: `${cmdOrCtrlShortcutKey()}+V`, + enabled: menuInfo.editFlags.canPaste, + click: () => webContents.paste(), + }), + ); + + return menu; + } + + addPastePlain( + menu: Electron.CrossProcessExports.Menu, + menuInfo: Electron.ContextMenuParams, + ) { + if ( + menuInfo.editFlags.canPaste && + !menuInfo.linkText && + !menuInfo.hasImageContents + ) { + const webContents = this.getWebContents(); + menu.append( + new MenuItem({ + label: this.stringTable.pasteAndMatchStyle(), + accelerator: `${cmdOrCtrlShortcutKey()}+Shift+V`, + click: () => webContents.pasteAndMatchStyle(), + }), + ); + } + } + + /** + * Adds a separator item. + */ + addSeparator(menu: Electron.CrossProcessExports.Menu) { + menu.append(new MenuItem({ type: 'separator' })); + return menu; + } + + /** + * Adds the "Inspect Element" menu item. + */ + addInspectElement( + menu: Electron.CrossProcessExports.Menu, + menuInfo: Electron.ContextMenuParams, + needsSeparator = true, + ) { + const webContents = this.getWebContents(); + if (!this.debugMode) return menu; + if (needsSeparator) this.addSeparator(menu); + + const inspect = new MenuItem({ + label: this.stringTable.inspectElement(), + click: () => webContents.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: string, + callback: { + (dataURL: any): void; + (dataURL: any): void; + (arg0: string): void; + }, + outputFormat: string = 'image/png', + ) { + let canvas: HTMLCanvasElement | null = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = new Image(); + img.crossOrigin = 'Anonymous'; + + img.addEventListener('load', () => { + if (canvas) { + canvas.height = img.height; + canvas.width = img.width; + ctx?.drawImage(img, 0, 0); + + const dataURL = canvas.toDataURL(outputFormat); + canvas = null; + callback(dataURL); + } + }); + + img.src = url; + } + + /** + * Adds the 'go back' menu item + */ + goBack(menu: Electron.CrossProcessExports.Menu) { + const webContents = this.getWebContents(); + menu.append( + new MenuItem({ + label: this.stringTable.goBack(), + accelerator: `${cmdOrCtrlShortcutKey()}+left`, + enabled: webContents.canGoBack(), + click: () => webContents.goBack(), + }), + ); + + return menu; + } + + /** + * Adds the 'go forward' menu item + */ + goForward(menu: Electron.CrossProcessExports.Menu) { + const webContents = this.getWebContents(); + menu.append( + new MenuItem({ + label: this.stringTable.goForward(), + accelerator: `${cmdOrCtrlShortcutKey()}+right`, + enabled: webContents.canGoForward(), + click: () => webContents.goForward(), + }), + ); + + return menu; + } + + /** + * Adds the 'copy page url' menu item. + */ + copyPageUrl( + menu: Electron.CrossProcessExports.Menu, + menuInfo: Electron.ContextMenuParams, + ) { + menu.append( + new MenuItem({ + label: this.stringTable.copyPageUrl(), + enabled: true, + click: () => { + clipboard.writeText(window.location.href); + this._sendNotificationOnClipboardEvent( + // @ts-expect-error Property 'clipboardNotifications' does not exist on type 'ContextMenuParams'. + menuInfo?.clipboardNotifications, + () => `Page URL copied: ${window.location.href}`, + ); + }, + }), + ); + + return menu; + } + + /** + * Adds the 'go to home' menu item. + */ + goToHomePage( + menu: Electron.CrossProcessExports.Menu, + menuInfo: Electron.ContextMenuParams, + ) { + const baseURL = new window.URL(menuInfo.pageURL); + menu.append( + new MenuItem({ + label: this.stringTable.goToHomePage(), + accelerator: `${cmdOrCtrlShortcutKey()}+Home`, + enabled: true, + click: () => { + // webContents.loadURL(baseURL.origin); + window.location.href = baseURL.origin; + }, + }), + ); + + return menu; + } + + /** + * Adds the 'open in browser' menu item. + */ + openInBrowser( + menu: Electron.CrossProcessExports.Menu, + menuInfo: Electron.ContextMenuParams, + ) { + menu.append( + new MenuItem({ + label: this.stringTable.openInBrowser(), + enabled: true, + click: () => { + openExternalUrl(menuInfo.pageURL, true); + }, + }), + ); + + return menu; + } + + _sendNotificationOnClipboardEvent( + isDisabled: boolean, + notificationText: () => string, + ) { + if (isDisabled) { + return; + } + // eslint-disable-next-line no-new + new window.Notification('Data copied into Clipboard', { + body: notificationText(), + }); + } +} diff --git a/src/webview/darkmode/custom.js b/src/webview/darkmode/custom.js deleted file mode 100644 index f767f5755..000000000 --- a/src/webview/darkmode/custom.js +++ /dev/null @@ -1,22 +0,0 @@ -// CSS for pages that need custom styles to work correctly in darkmode -export default { - 'web.whatsapp.com': ` - div.landing-window > div.landing-main { - background-color: #FFFFFF !important; - } - div.landing-window > div.landing-main * { - color: #212121 !important; - } - `, - 'web.threema.ch': ` - .scan { - background-color: #FFF; - } - .scan * { - color: #212121; - } - .scan input.md-input { - color: #212121; - } - `, -}; diff --git a/src/webview/darkmode/custom.ts b/src/webview/darkmode/custom.ts new file mode 100644 index 000000000..f767f5755 --- /dev/null +++ b/src/webview/darkmode/custom.ts @@ -0,0 +1,22 @@ +// CSS for pages that need custom styles to work correctly in darkmode +export default { + 'web.whatsapp.com': ` + div.landing-window > div.landing-main { + background-color: #FFFFFF !important; + } + div.landing-window > div.landing-main * { + color: #212121 !important; + } + `, + 'web.threema.ch': ` + .scan { + background-color: #FFF; + } + .scan * { + color: #212121; + } + .scan input.md-input { + color: #212121; + } + `, +}; diff --git a/src/webview/darkmode/ignore.js b/src/webview/darkmode/ignore.js deleted file mode 100644 index 110df364f..000000000 --- a/src/webview/darkmode/ignore.js +++ /dev/null @@ -1,3 +0,0 @@ -export default [ - 'discordapp.com', -]; diff --git a/src/webview/darkmode/ignore.ts b/src/webview/darkmode/ignore.ts new file mode 100644 index 000000000..daa25d10c --- /dev/null +++ b/src/webview/darkmode/ignore.ts @@ -0,0 +1 @@ +export default ['discordapp.com']; diff --git a/src/webview/find.js b/src/webview/find.js deleted file mode 100644 index 040811d68..000000000 --- a/src/webview/find.js +++ /dev/null @@ -1,23 +0,0 @@ -import { ipcRenderer } from 'electron'; -import { FindInPage as ElectronFindInPage } from 'electron-find'; - -// Shim to expose webContents functionality to electron-find without @electron/remote -const webContentsShim = { - findInPage: (text, options = {}) => ipcRenderer.sendSync('find-in-page', text, options), - stopFindInPage: (action) => { - ipcRenderer.sendSync('stop-find-in-page', action); - }, - on: (eventName, listener) => { - if (eventName === 'found-in-page') { - ipcRenderer.on('found-in-page', (_, result) => { - listener({ sender: this }, result); - }); - } - }, -}; - -export default class FindInPage extends ElectronFindInPage { - constructor(options = {}) { - super(webContentsShim, options); - } -} diff --git a/src/webview/find.ts b/src/webview/find.ts new file mode 100644 index 000000000..0665d9670 --- /dev/null +++ b/src/webview/find.ts @@ -0,0 +1,27 @@ +import { ipcRenderer } from 'electron'; +import { FindInPage as ElectronFindInPage } from 'electron-find'; + +// Shim to expose webContents functionality to electron-find without @electron/remote +const webContentsShim = { + findInPage: (text: string, options = {}) => + ipcRenderer.sendSync('find-in-page', text, options), + stopFindInPage: (action: any) => { + ipcRenderer.sendSync('stop-find-in-page', action); + }, + on: ( + eventName: string, + listener: (arg0: { sender: undefined }, arg1: any) => void, + ): void => { + if (eventName === 'found-in-page') { + ipcRenderer.on('found-in-page', (_, result) => { + listener({ sender: this }, result); + }); + } + }, +}; + +export default class FindInPage extends ElectronFindInPage { + constructor(options = {}) { + super(webContentsShim, options); + } +} diff --git a/src/webview/notifications.js b/src/webview/notifications.js deleted file mode 100644 index 22960d818..000000000 --- a/src/webview/notifications.js +++ /dev/null @@ -1,71 +0,0 @@ -import { ipcRenderer } from 'electron'; - -import { v1 as uuidV1 } from 'uuid'; - -const debug = require('debug')('Ferdi:Notifications'); - -export class NotificationsHandler { - onNotify = data => data; - - displayNotification(title, options) { - return new Promise(resolve => { - debug('New notification', title, options); - - const notificationId = uuidV1(); - - ipcRenderer.sendToHost( - 'notification', - this.onNotify({ - title, - options, - notificationId, - }), - ); - - ipcRenderer.once(`notification-onclick:${notificationId}`, () => { - resolve(); - }); - }); - } -} - -export const notificationsClassDefinition = `(() => { - class Notification { - static permission = 'granted'; - - constructor(title = '', options = {}) { - this.title = title; - this.options = options; - window.ferdi.displayNotification(title, options) - .then(() => { - if (typeof (this.onClick) === 'function') { - this.onClick(); - } - }); - } - - static requestPermission(cb = null) { - if (!cb) { - return new Promise((resolve) => { - resolve(Notification.permission); - }); - } - - if (typeof (cb) === 'function') { - return cb(Notification.permission); - } - - return Notification.permission; - } - - onNotify(data) { - return data; - } - - onClick() {} - - close() {} - } - - window.Notification = Notification; -})();`; diff --git a/src/webview/notifications.ts b/src/webview/notifications.ts new file mode 100644 index 000000000..73124b9a9 --- /dev/null +++ b/src/webview/notifications.ts @@ -0,0 +1,72 @@ +import { ipcRenderer } from 'electron'; + +import { v1 as uuidV1 } from 'uuid'; + +const debug = require('debug')('Ferdi:Notifications'); + +export class NotificationsHandler { + onNotify = (data: { title: string; options: any; notificationId: string }) => + data; + + displayNotification(title: string, options: any) { + return new Promise(resolve => { + debug('New notification', title, options); + + const notificationId = uuidV1(); + + ipcRenderer.sendToHost( + 'notification', + this.onNotify({ + title, + options, + notificationId, + }), + ); + + ipcRenderer.once(`notification-onclick:${notificationId}`, () => { + resolve(true); + }); + }); + } +} + +export const notificationsClassDefinition = `(() => { + class Notification { + static permission = 'granted'; + + constructor(title = '', options = {}) { + this.title = title; + this.options = options; + window.ferdi.displayNotification(title, options) + .then(() => { + if (typeof (this.onClick) === 'function') { + this.onClick(); + } + }); + } + + static requestPermission(cb = null) { + if (!cb) { + return new Promise((resolve) => { + resolve(Notification.permission); + }); + } + + if (typeof (cb) === 'function') { + return cb(Notification.permission); + } + + return Notification.permission; + } + + onNotify(data) { + return data; + } + + onClick() {} + + close() {} + } + + window.Notification = Notification; +})();`; diff --git a/src/webview/screenshare.js b/src/webview/screenshare.js deleted file mode 100644 index e7e43c04e..000000000 --- a/src/webview/screenshare.js +++ /dev/null @@ -1,143 +0,0 @@ -import { desktopCapturer } from 'electron'; - -const CANCEL_ID = 'desktop-capturer-selection__cancel'; - -export async function getDisplayMediaSelector() { - const sources = await desktopCapturer.getSources({ - types: ['screen', 'window'], - }); - return `
-
    - ${sources - .map( - ({ id, name, thumbnail }) => ` -
  • - -
  • - `, - ) - .join('')} -
  • - -
  • -
-
`; -} - -export const screenShareCss = ` -.desktop-capturer-selection { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100vh; - background: rgba(30,30,30,.75); - color: #fff; - z-index: 10000000; - display: flex; - align-items: center; - justify-content: center; -} -.desktop-capturer-selection__scroller { - width: 100%; - max-height: 100vh; - overflow-y: auto; -} -.desktop-capturer-selection__list { - max-width: calc(100% - 100px); - margin: 50px; - padding: 0; - display: flex; - flex-wrap: wrap; - list-style: none; - overflow: hidden; - justify-content: center; -} -.desktop-capturer-selection__item { - display: flex; - margin: 4px; -} -.desktop-capturer-selection__btn { - display: flex; - flex-direction: column; - align-items: stretch; - width: 145px; - margin: 0; - border: 0; - border-radius: 3px; - padding: 4px; - background: #252626; - text-align: left; - @media (prefers-reduced-motion: no-preference) { - transition: background-color .15s, box-shadow .15s, color .15s; - } - color: #dedede; -} -.desktop-capturer-selection__btn:hover, -.desktop-capturer-selection__btn:focus { - background: rgba(98,100,167,.8); - box-shadow: 0 0 4px rgba(0,0,0,0.45), 0 0 2px rgba(0,0,0,0.25); - color: #fff; -} -.desktop-capturer-selection__thumbnail { - width: 100%; - height: 81px; - object-fit: cover; -} -.desktop-capturer-selection__name { - margin: 6px 0; - white-space: nowrap; - text-overflow: ellipsis; - text-align: center; - overflow: hidden; -} -.desktop-capturer-selection__name--cancel { - margin: auto 0; -} -`; - -export const screenShareJs = ` -window.navigator.mediaDevices.getDisplayMedia = () => new Promise(async (resolve, reject) => { - try { - const selectionElem = document.createElement('div'); - selectionElem.classList = ['desktop-capturer-selection']; - selectionElem.innerHTML = await window.ferdi.getDisplayMediaSelector(); - document.body.appendChild(selectionElem); - - document - .querySelectorAll('.desktop-capturer-selection__btn') - .forEach((button) => { - button.addEventListener('click', async () => { - try { - const id = button.getAttribute('data-id'); - if (id === '${CANCEL_ID}') { - reject(new Error('Cancelled by user')); - } else { - const stream = await window.navigator.mediaDevices.getUserMedia({ - audio: false, - video: { - mandatory: { - chromeMediaSource: 'desktop', - chromeMediaSourceId: id, - }, - }, - }); - resolve(stream); - } - } catch (err) { - reject(err); - } finally { - selectionElem.remove(); - } - }); - }); - } catch (err) { - reject(err); - } -}); -`; diff --git a/src/webview/screenshare.ts b/src/webview/screenshare.ts new file mode 100644 index 000000000..91a1623bb --- /dev/null +++ b/src/webview/screenshare.ts @@ -0,0 +1,143 @@ +import { desktopCapturer } from 'electron'; + +const CANCEL_ID = 'desktop-capturer-selection__cancel'; + +export async function getDisplayMediaSelector() { + const sources = await desktopCapturer.getSources({ + types: ['screen', 'window'], + }); + return `
+
    + ${sources + .map( + ({ id, name, thumbnail }) => ` +
  • + +
  • + `, + ) + .join('')} +
  • + +
  • +
+
`; +} + +export const screenShareCss = ` +.desktop-capturer-selection { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + background: rgba(30,30,30,.75); + color: #fff; + z-index: 10000000; + display: flex; + align-items: center; + justify-content: center; +} +.desktop-capturer-selection__scroller { + width: 100%; + max-height: 100vh; + overflow-y: auto; +} +.desktop-capturer-selection__list { + max-width: calc(100% - 100px); + margin: 50px; + padding: 0; + display: flex; + flex-wrap: wrap; + list-style: none; + overflow: hidden; + justify-content: center; +} +.desktop-capturer-selection__item { + display: flex; + margin: 4px; +} +.desktop-capturer-selection__btn { + display: flex; + flex-direction: column; + align-items: stretch; + width: 145px; + margin: 0; + border: 0; + border-radius: 3px; + padding: 4px; + background: #252626; + text-align: left; + @media (prefers-reduced-motion: no-preference) { + transition: background-color .15s, box-shadow .15s, color .15s; + } + color: #dedede; +} +.desktop-capturer-selection__btn:hover, +.desktop-capturer-selection__btn:focus { + background: rgba(98,100,167,.8); + box-shadow: 0 0 4px rgba(0,0,0,0.45), 0 0 2px rgba(0,0,0,0.25); + color: #fff; +} +.desktop-capturer-selection__thumbnail { + width: 100%; + height: 81px; + object-fit: cover; +} +.desktop-capturer-selection__name { + margin: 6px 0; + white-space: nowrap; + text-overflow: ellipsis; + text-align: center; + overflow: hidden; +} +.desktop-capturer-selection__name--cancel { + margin: auto 0; +} +`; + +export const screenShareJs = ` +window.navigator.mediaDevices.getDisplayMedia = () => new Promise(async (resolve, reject) => { + try { + const selectionElem = document.createElement('div'); + selectionElem.classList = ['desktop-capturer-selection']; + selectionElem.innerHTML = await window.ferdi.getDisplayMediaSelector(); + document.body.appendChild(selectionElem); + + document + .querySelectorAll('.desktop-capturer-selection__btn') + .forEach((button) => { + button.addEventListener('click', async () => { + try { + const id = button.getAttribute('data-id'); + if (id === '${CANCEL_ID}') { + reject(new Error('Cancelled by user')); + } else { + const stream = await window.navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + mandatory: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: id, + }, + }, + }); + resolve(stream); + } + } catch (err) { + reject(err); + } finally { + selectionElem.remove(); + } + }); + }); + } catch (err) { + reject(err); + } +}); +`; -- cgit v1.2.3-70-g09d2