diff --git a/app/extensions/brave/brave-default.js b/app/extensions/brave/brave-default.js index 7791b70bedc..603efadace3 100644 --- a/app/extensions/brave/brave-default.js +++ b/app/extensions/brave/brave-default.js @@ -243,6 +243,12 @@ if (typeof KeyEvent === 'undefined') { xhttp.send() } + ipcRenderer.on('init-spell-check', function (e, lang) { + chrome.webFrame.setSpellCheckProvider(lang, true, { + spellCheck: (word) => !ipcRenderer.sendSync('is-misspelled', word) + }) + }) + // Fires when the browser has ad replacement information to give ipcRenderer.on('set-ad-div-candidates', function (e, adDivCandidates, placeholderUrl) { // Keep a lookup for skipped common elements @@ -538,8 +544,8 @@ if (typeof KeyEvent === 'undefined') { return passwordNodes } - function hasSelection (node) { - return window.getSelection().toString().length > 0 + function getSelection () { + return window.getSelection().toString() } /** @@ -563,6 +569,10 @@ if (typeof KeyEvent === 'undefined') { return window.navigator.platform.includes('Mac') } + function hasWhitespace (text) { + return /\s/g.test(text); + } + document.addEventListener('contextmenu', (e/*: Event*/) => { window.setTimeout(() => { if (!(e instanceof MouseEvent)) { @@ -593,12 +603,26 @@ if (typeof KeyEvent === 'undefined') { maybeLink = maybeLink.parentNode } + const selection = getSelection() + let suggestions = [] + let isMisspelled = false + if (selection.length > 0 && !hasWhitespace(selection)) { + // This is not very taxing, it only happens once on right click and only + // if it is on one word, and the check and result set are returned very fast. + const info = ipcRenderer.sendSync('get-misspelling-info', selection) + suggestions = info.suggestions + isMisspelled = info.isMisspelled + } + var nodeProps = { name: name, href: href, isContentEditable: e.target.isContentEditable || false, src: e.target.getAttribute ? e.target.getAttribute('src') : undefined, - hasSelection: hasSelection(e.target), + selection, + suggestions, + isMisspelled, + hasSelection: selection.length > 0, offsetX: e.pageX, offsetY: e.pageY } diff --git a/app/extensions/brave/locales/en-US/menu.properties b/app/extensions/brave/locales/en-US/menu.properties index e1d42e1522a..465a7fb7414 100644 --- a/app/extensions/brave/locales/en-US/menu.properties +++ b/app/extensions/brave/locales/en-US/menu.properties @@ -118,3 +118,5 @@ inspectElement=Inspect Element downloadsManager=Downloads Manager... zoom=Zoom new=New +learnSpelling=Learn Spelling +ignoreSpelling=Ignore Spelling diff --git a/app/index.js b/app/index.js index c7f82b0d2d7..8f07c4d14d6 100644 --- a/app/index.js +++ b/app/index.js @@ -41,6 +41,7 @@ const CryptoUtil = require('../js/lib/cryptoUtil') const keytar = require('keytar') const settings = require('../js/constants/settings') const siteSettings = require('../js/state/siteSettings') +const spellCheck = require('./spellCheck') // Used to collect the per window state when shutting down the application let perWindowState = [] @@ -353,6 +354,7 @@ app.on('ready', () => { AdBlock.init() SiteHacks.init() NoScript.init() + spellCheck.init() ipcMain.on(messages.UPDATE_REQUESTED, () => { Updater.updateNowRequested() diff --git a/app/locale.js b/app/locale.js index dfeda21604c..c27b404897d 100644 --- a/app/locale.js +++ b/app/locale.js @@ -132,6 +132,8 @@ var rendererIdentifiers = function () { 'blockPopups', 'noScript', 'httpsEverywhere', + 'learnSpelling', + 'ignoreSpelling', // Other identifiers 'urlCopied' ] diff --git a/app/sessionStore.js b/app/sessionStore.js index a1df0980837..3016d57257c 100644 --- a/app/sessionStore.js +++ b/app/sessionStore.js @@ -305,6 +305,10 @@ module.exports.defaultAppState = () => { siteSettings: {}, passwords: [], notifications: [], - temporarySiteSettings: {} + temporarySiteSettings: {}, + dictionary: { + addedWords: [], + ignoredWords: [] + } } } diff --git a/app/spellCheck.js b/app/spellCheck.js new file mode 100644 index 00000000000..0841cd35005 --- /dev/null +++ b/app/spellCheck.js @@ -0,0 +1,62 @@ +// @flow +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const spellchecker = require('spellchecker') +const messages = require('../js/constants/messages') +const electron = require('electron') +const ipcMain = electron.ipcMain +const app = electron.app +const appStore = require('../js/stores/appStore') +const appActions = require('../js/actions/appActions') + +// Stores a reference to the last added immutable words +let lastAddedWords + +const isMisspelled = (word) => + !appStore.getState().getIn(['dictionary', 'ignoredWords']).includes(word) && + !appStore.getState().getIn(['dictionary', 'addedWords']).includes(word) && + spellchecker.isMisspelled(word) + +module.exports.init = () => { + ipcMain.on(messages.IS_MISSPELLED, (e, word) => { + e.returnValue = isMisspelled(word) + }) + ipcMain.on(messages.GET_MISSPELLING_INFO, (e, word) => { + const misspelled = isMisspelled(word) + e.returnValue = { + isMisspelled: misspelled, + suggestions: !misspelled ? [] : spellchecker.getCorrectionsForMisspelling(word) + } + }) + + appStore.addChangeListener(() => { + let addedWords = appStore.getState().getIn(['dictionary', 'addedWords']) + if (lastAddedWords !== addedWords) { + if (lastAddedWords) { + addedWords = addedWords.splice(lastAddedWords.size) + } + addedWords.forEach(spellchecker.add.bind(spellchecker)) + lastAddedWords = appStore.getState().getIn(['dictionary', 'addedWords']) + } + }) + + const availableDictionaries = spellchecker.getAvailableDictionaries() + let dict = app.getLocale().replace('-', '_') + let dictionaryLocale + if (availableDictionaries.includes(dict)) { + dictionaryLocale = dict + } else { + dict = app.getLocale().split('-')[0] + if (availableDictionaries.includes(dict)) { + dictionaryLocale = dict + } + } + + if (dictionaryLocale) { + appActions.setDictionary(dictionaryLocale) + } +} diff --git a/docs/appActions.md b/docs/appActions.md index c0c5a1750c2..a15f21bb3e6 100644 --- a/docs/appActions.md +++ b/docs/appActions.md @@ -254,6 +254,28 @@ Hides a message box in the notification bar +### addWord(word, learn) + +Adds a word to the dictionary + +**Parameters** + +**word**: `string`, The word to add + +**learn**: `boolean`, true if the word should be learned, false if ignored + + + +### setDictionary(locale) + +Adds a word to the dictionary + +**Parameters** + +**locale**: `string`, The locale to set for the dictionary + + + * * * diff --git a/docs/state.md b/docs/state.md index db6783ff89c..70e4cff2b4e 100644 --- a/docs/state.md +++ b/docs/state.md @@ -130,7 +130,12 @@ AppStore 'privacy.block-canvas-fingerprinting': boolean, // Canvas fingerprinting defense 'security.passwords.manager-enabled': boolean, // whether to use default password manager 'general.downloads.defaultSavePath': string, // The default path to store files, this will be updated on each save until another pref is added to control that. - }] + }], + dictionary: { + locale: string, // en_US, en, or any other locale string + ignoredWords: Array, // List of words to ignore + addedWords: Array // List of words to add to the dictionary + } } ``` diff --git a/docs/windowActions.md b/docs/windowActions.md index 34894c3f8bd..880968fc7ca 100644 --- a/docs/windowActions.md +++ b/docs/windowActions.md @@ -617,18 +617,6 @@ Sets whether the noscript icon is visible. -### inspectElement(x, y) - -Inspect the element for the active webview at the x, y content position - -**Parameters** - -**x**: `number`, horizontal position of the element to inspect - -**y**: `number`, vertical position of the element to inspect - - - * * * diff --git a/js/actions/appActions.js b/js/actions/appActions.js index 960fdd60a76..5e9039bb042 100644 --- a/js/actions/appActions.js +++ b/js/actions/appActions.js @@ -294,6 +294,30 @@ const appActions = { actionType: AppConstants.APP_HIDE_MESSAGE_BOX, message }) + }, + + /** + * Adds a word to the dictionary + * @param {string} word - The word to add + * @param {boolean} learn - true if the word should be learned, false if ignored + */ + addWord: function (word, learn) { + AppDispatcher.dispatch({ + actionType: AppConstants.APP_ADD_WORD, + word, + learn + }) + }, + + /** + * Adds a word to the dictionary + * @param {string} locale - The locale to set for the dictionary + */ + setDictionary: function (locale) { + AppDispatcher.dispatch({ + actionType: AppConstants.APP_SET_DICTIONARY, + locale + }) } } diff --git a/js/actions/webviewActions.js b/js/actions/webviewActions.js new file mode 100644 index 00000000000..875641e8cdd --- /dev/null +++ b/js/actions/webviewActions.js @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict' + +const getWebview = () => + document.querySelector('.frameWrapper.isActive webview') + +const webviewActions = { + /** + * Puts the webview in focus + */ + setWebviewFocused: function () { + const webview = getWebview() + if (webview) { + webview.focus() + } + }, + + /** + * Inspect the element for the active webview at the x, y content position + * @param {number} x - horizontal position of the element to inspect + * @param {number} y - vertical position of the element to inspect + */ + inspectElement: function (x, y) { + const webview = getWebview() + if (webview) { + webview.inspectElement(x, y) + } + }, + + /** + * Repalces the selected text in an editable + * @param {string} text - The text to replace with + */ + replaceMisspelling: function (text) { + const webview = getWebview() + if (webview) { + webview.replaceMisspelling(text) + } + } +} + +module.exports = webviewActions diff --git a/js/actions/windowActions.js b/js/actions/windowActions.js index ea2d25cce79..6553668c12d 100644 --- a/js/actions/windowActions.js +++ b/js/actions/windowActions.js @@ -279,13 +279,6 @@ const windowActions = { }) }, - setWebviewFocused: function () { - const webview = document.querySelector('.frameWrapper.isActive webview') - if (webview) { - webview.focus() - } - }, - /** * Dispatches a message to the store to create a new frame * @@ -863,18 +856,6 @@ const windowActions = { actionType: WindowConstants.WINDOW_SET_NOSCRIPT_VISIBLE, isVisible }) - }, - - /** - * Inspect the element for the active webview at the x, y content position - * @param {number} x - horizontal position of the element to inspect - * @param {number} y - vertical position of the element to inspect - */ - inspectElement: function (x, y) { - const webview = document.querySelector('.frameWrapper.isActive webview') - if (webview) { - webview.inspectElement(x, y) - } } } diff --git a/js/components/frame.js b/js/components/frame.js index a088b0bb52c..c88ef8dda74 100644 --- a/js/components/frame.js +++ b/js/components/frame.js @@ -368,6 +368,9 @@ class Frame extends ImmutableComponent { if (this.props.enableAds) { this.insertAds(this.webview.getURL()) } + if (this.props.dictionaryLocale) { + this.initSpellCheck() + } this.webview.send(messages.POST_PAGE_LOAD_RUN) if (getSetting(settings.PASSWORD_MANAGER_ENABLED)) { this.webview.send(messages.AUTOFILL_PASSWORD) @@ -487,6 +490,10 @@ class Frame extends ImmutableComponent { this.webview.send(messages.SET_AD_DIV_CANDIDATES, adDivCandidates, config.vault.replacementUrl) } + initSpellCheck () { + this.webview.send(messages.INIT_SPELL_CHECK, this.props.dictionaryLocale) + } + goBack () { this.webview.goBack() } diff --git a/js/components/main.js b/js/components/main.js index a5b0ac41d49..a6129c43505 100644 --- a/js/components/main.js +++ b/js/components/main.js @@ -11,6 +11,7 @@ const remote = electron.remote // Actions const windowActions = require('../actions/windowActions') +const webviewActions = require('../actions/webviewActions') const loadOpenSearch = require('../lib/openSearch').loadOpenSearch const contextMenus = require('../contextMenus') const getSetting = require('../settings').getSetting @@ -260,7 +261,7 @@ class Main extends ImmutableComponent { window.addEventListener('focus', () => { // For whatever reason other elements are preserved but webviews are not. if (document.activeElement && document.activeElement.tagName === 'BODY') { - windowActions.setWebviewFocused() + webviewActions.setWebviewFocused() } }) const activeFrame = FrameStateUtil.getActiveFrame(self.props.windowState) @@ -580,6 +581,7 @@ class Main extends ImmutableComponent { .filter((site) => site.get('tags') .includes(siteTags.BOOKMARK_FOLDER)) || new Immutable.Map() : null} + dictionaryLocale={this.props.appState.getIn(['dictionary', 'locale'])} passwords={this.props.appState.get('passwords')} siteSettings={this.props.appState.get('siteSettings')} temporarySiteSettings={this.props.appState.get('temporarySiteSettings')} diff --git a/js/constants/appConstants.js b/js/constants/appConstants.js index dbb1cd1b3df..b69b419672a 100644 --- a/js/constants/appConstants.js +++ b/js/constants/appConstants.js @@ -27,7 +27,9 @@ const AppConstants = { APP_CHANGE_SETTING: _, APP_CHANGE_SITE_SETTING: _, APP_SHOW_MESSAGE_BOX: _, /** @param {Object} detail */ - APP_HIDE_MESSAGE_BOX: _ /** @param {string} message */ + APP_HIDE_MESSAGE_BOX: _, /** @param {string} message */ + APP_ADD_WORD: _, /** @param {string} word, @param {boolean} learn */ + APP_SET_DICTIONARY: _ /** @param {string} locale */ } module.exports = mapValuesByKeys(AppConstants) diff --git a/js/constants/messages.js b/js/constants/messages.js index 08991bfd46c..a2543bfda03 100644 --- a/js/constants/messages.js +++ b/js/constants/messages.js @@ -57,6 +57,7 @@ const messages = { APP_INITIALIZED: _, // Webview page messages SET_AD_DIV_CANDIDATES: _, /** @arg {Array} adDivCandidates, @arg {string} placeholderUrl */ + INIT_SPELL_CHECK: _, /** @arg {string} lang */ CONTEXT_MENU_OPENED: _, /** @arg {Object} nodeProps properties of node being clicked */ LINK_HOVERED: _, /** @arg {string} href of hovered link */ APP_STATE_CHANGE: _, @@ -74,6 +75,8 @@ const messages = { GET_PASSWORDS: _, /** @arg {string} formOrigin, @arg {string} action */ GOT_PASSWORD: _, /** @arg {string} username, @arg {string} password, @arg {string} origin, @arg {string} action, @arg {boolean} isUnique */ SAVE_PASSWORD: _, /** @arg {string} username, @arg {string} password, @arg {string} formOrigin, @arg {string} action */ + IS_MISSPELLED: _, /** @arg {string} word, the word to check */ + GET_MISSPELLING_INFO: _, /** @arg {string} word, the word to lookup */ SHOW_USERNAME_LIST: _, /** @arg {string} formOrigin, @arg {string} action, @arg {Object} boundingRect, @arg {string} usernameValue */ FILL_PASSWORD: _, /** @arg {string} username, @arg {string} password, @arg {string} origin, @arg {string} action */ PASSWORD_DETAILS_UPDATED: _, /** @arg {Object} passwords app state */ diff --git a/js/contextMenus.js b/js/contextMenus.js index 4036fb18e78..e71a0fea57f 100644 --- a/js/contextMenus.js +++ b/js/contextMenus.js @@ -10,6 +10,7 @@ const clipboard = electron.clipboard const messages = require('./constants/messages') const WindowStore = require('./stores/windowStore') const windowActions = require('./actions/windowActions') +const webviewActions = require('./actions/webviewActions') const bookmarkActions = require('./actions/bookmarkActions') const downloadActions = require('./actions/downloadActions') const appActions = require('./actions/appActions') @@ -389,7 +390,45 @@ function tabTemplateInit (frameProps) { return items } -function getEditableItems (hasSelection) { +function getMisspelledSuggestions (selection, isMisspelled, suggestions) { + const hasSelection = selection.length > 0 + const items = [] + if (hasSelection) { + if (suggestions.length > 0) { + // Map the first 3 suggestions to menu items that allows click + // to replace the text. + items.push(...suggestions.slice(0, 3).map((suggestion) => { + return { + label: suggestion, + click: () => { + webviewActions.replaceMisspelling(suggestion) + } + } + }), CommonMenu.separatorMenuItem) + } + if (isMisspelled) { + items.push({ + label: locale.translation('learnSpelling'), + click: () => { + appActions.addWord(selection, true) + // This is needed so the underline goes away + webviewActions.replaceMisspelling(selection) + } + }, { + label: locale.translation('ignoreSpelling'), + click: () => { + appActions.addWord(selection, false) + // This is needed so the underline goes away + webviewActions.replaceMisspelling(selection) + } + }, CommonMenu.separatorMenuItem) + } + } + return items +} + +function getEditableItems (selection) { + const hasSelection = selection.length > 0 const items = [] if (hasSelection) { items.push({ @@ -607,8 +646,9 @@ function mainTemplateInit (nodeProps, frame) { } if (nodeName === 'TEXTAREA' || nodeName === 'INPUT' || nodeProps.isContentEditable) { - const editableItems = getEditableItems(nodeProps.hasSelection) - template.push({ + const misspelledSuggestions = getMisspelledSuggestions(nodeProps.selection, nodeProps.isMisspelled, nodeProps.suggestions) + const editableItems = getEditableItems(nodeProps.selection) + template.push(...misspelledSuggestions, { label: locale.translation('undo'), accelerator: 'CmdOrCtrl+Z', role: 'undo' @@ -701,7 +741,7 @@ function mainTemplateInit (nodeProps, frame) { template.push({ label: locale.translation('inspectElement'), click: (item, focusedWindow) => { - windowActions.inspectElement(nodeProps.offsetX, nodeProps.offsetY) + webviewActions.inspectElement(nodeProps.offsetX, nodeProps.offsetY) } }) diff --git a/js/stores/appStore.js b/js/stores/appStore.js index c08b2ea10f4..c8d8f9ca14c 100644 --- a/js/stores/appStore.js +++ b/js/stores/appStore.js @@ -406,6 +406,20 @@ const handleAppAction = (action) => { return notification.get('message') === action.message })) break + case AppConstants.APP_ADD_WORD: + let listType = 'ignoredWords' + if (action.learn) { + listType = 'addedWords' + } + const path = ['dictionary', listType] + let wordList = appState.getIn(path) + if (!wordList.includes(action.word)) { + appState = appState.setIn(path, wordList.push(action.word)) + } + break + case AppConstants.APP_SET_DICTIONARY: + appState = appState.setIn(['dictionary', 'locale'], action.locale) + break default: } diff --git a/package.json b/package.json index 8f2640d3d75..b9150795aae 100644 --- a/package.json +++ b/package.json @@ -74,10 +74,11 @@ "immutable": "^3.7.5", "immutablediff": "^0.4.2", "immutablepatch": "^0.2.2", - "lru_cache": "^1.0.0", "keytar": "^3.0.0", + "lru_cache": "^1.0.0", "react": "^15.0.1", "react-dom": "^15.0.1", + "spellchecker": "^3.3.1", "tracking-protection": "1.1.x", "url-loader": "^0.5.7", "l20n": "^3.5.1", diff --git a/tools/rebuildNativeModules.js b/tools/rebuildNativeModules.js index bfc8a9e768a..7d8d24ac23b 100644 --- a/tools/rebuildNativeModules.js +++ b/tools/rebuildNativeModules.js @@ -20,6 +20,8 @@ var cmds = [ 'cd ../../node_modules/keytar', rebuildCmd, 'cd ../../node_modules/lru_cache', + rebuildCmd, + 'cd ./node_modules/spellchecker', rebuildCmd ] diff --git a/webpack.config.js b/webpack.config.js index c3437933c82..914257a8908 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -50,6 +50,7 @@ function config () { plugins: [ new WebpackNotifierPlugin({title: 'Brave-' + env}), new webpack.IgnorePlugin(/^\.\/stores\/appStore$/), + new webpack.IgnorePlugin(/^spellchecker/), new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify(env),