diff --git a/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.es6 b/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.es6 index e4fc4e36c..dd894533e 100644 --- a/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.es6 +++ b/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.es6 @@ -1,6 +1,9 @@ +import _ from 'underscore' import {DOMUtils, ComposerExtension, Spellchecker} from 'nylas-exports'; const recycled = []; +const MAX_MISPELLINGS = 10 +const provideHintText = _.debounce(Spellchecker.handler.provideHintText, 1000) function getSpellingNodeForText(text) { let node = recycled.pop(); @@ -16,13 +19,141 @@ function recycleSpellingNode(node) { recycled.push(node); } +function whileApplyingSelectionChanges(rootNode, cb) { + const selection = document.getSelection(); + const selectionSnapshot = { + anchorNode: selection.anchorNode, + anchorOffset: selection.anchorOffset, + focusNode: selection.focusNode, + focusOffset: selection.focusOffset, + modified: false, + }; + + rootNode.style.display = 'none' + cb(selectionSnapshot); + rootNode.style.display = 'block' + + if (selectionSnapshot.modified) { + selection.setBaseAndExtent(selectionSnapshot.anchorNode, selectionSnapshot.anchorOffset, selectionSnapshot.focusNode, selectionSnapshot.focusOffset); + } +} + +// Removes all of the nodes found in the provided `editor`. +// It normalizes the DOM after removing spelling nodes to ensure that words +// are not split between text nodes. (ie: doesn, 't => doesn't) +function unwrapWords(rootNode) { + whileApplyingSelectionChanges(rootNode, (selectionSnapshot) => { + const spellingNodes = rootNode.querySelectorAll('spelling'); + for (let ii = 0; ii < spellingNodes.length; ii++) { + const node = spellingNodes[ii]; + if (selectionSnapshot.anchorNode === node) { + selectionSnapshot.anchorNode = node.firstChild; + } + if (selectionSnapshot.focusNode === node) { + selectionSnapshot.focusNode = node.firstChild; + } + selectionSnapshot.modified = true; + while (node.firstChild) { + node.parentNode.insertBefore(node.firstChild, node); + } + recycleSpellingNode(node); + node.parentNode.removeChild(node); + } + }); + rootNode.normalize(); +} + +// Traverses all of the text nodes within the provided `editor`. If it finds a +// text node with a misspelled word, it splits it, wraps the misspelled word +// with a node and updates the selection to account for the change. +function wrapMisspelledWords(rootNode) { + whileApplyingSelectionChanges(rootNode, (selectionSnapshot) => { + const treeWalker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, { + acceptNode: (node) => { + // skip the entire subtree inside tags and tags... + if ((node.nodeType === Node.ELEMENT_NODE) && (["CODE", "A", "PRE"].includes(node.tagName))) { + return NodeFilter.FILTER_REJECT; + } + return (node.nodeType === Node.TEXT_NODE) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; + }, + }); + + const nodeList = []; + + while (treeWalker.nextNode()) { + nodeList.push(treeWalker.currentNode); + } + + // Note: As a performance optimization, we stop spellchecking after encountering + // 10 misspelled words. This keeps the runtime of this method bounded! + let nodeMisspellingsFound = 0; + + while (true) { + const node = nodeList.shift(); + if ((node === undefined) || (nodeMisspellingsFound > MAX_MISPELLINGS)) { + break; + } + + const nodeContent = node.textContent; + const nodeWordRegexp = /(\w[\w'’-]*\w|\w)/g; // https://regex101.com/r/bG5yC4/1 + + while (true) { + const match = nodeWordRegexp.exec(nodeContent); + if ((match === null) || (nodeMisspellingsFound > MAX_MISPELLINGS)) { + break; + } + + if (Spellchecker.isMisspelled(match[0])) { + // The insertion point is currently at the end of this misspelled word. + // Do not mark it until the user types a space or leaves. + if ((selectionSnapshot.focusNode === node) && (selectionSnapshot.focusOffset === match.index + match[0].length)) { + continue; + } + + const matchNode = (match.index === 0) ? node : node.splitText(match.index); + const afterMatchNode = matchNode.splitText(match[0].length); + + const spellingSpan = getSpellingNodeForText(match[0]); + matchNode.parentNode.replaceChild(spellingSpan, matchNode); + + for (const prop of ['anchor', 'focus']) { + if (selectionSnapshot[`${prop}Node`] === node) { + if (selectionSnapshot[`${prop}Offset`] > match.index + match[0].length) { + selectionSnapshot[`${prop}Node`] = afterMatchNode; + selectionSnapshot[`${prop}Offset`] -= match.index + match[0].length; + selectionSnapshot.modified = true; + } else if (selectionSnapshot[`${prop}Offset`] >= match.index) { + selectionSnapshot[`${prop}Node`] = spellingSpan.childNodes[0]; + selectionSnapshot[`${prop}Offset`] -= match.index; + selectionSnapshot.modified = true; + } + } + } + + nodeMisspellingsFound += 1; + nodeList.unshift(afterMatchNode); + break; + } + } + } + }); +} + + export default class SpellcheckComposerExtension extends ComposerExtension { static onContentChanged({editor}) { - SpellcheckComposerExtension.update(editor); + const {rootNode} = editor + unwrapWords(rootNode); + provideHintText(rootNode.textContent) + // This should technically be in a .then() clause of the promise returned by + // provideHintText(), but that doesn't work for some reason. provideHintText() + // currently runs fast enough, or wrapMisspelledWords() runs slow enough, + // that just running wrapMisspelledWords() immediately works as intended. + wrapMisspelledWords(rootNode) } - static onShowContextMenu = ({editor, menu}) => { + static onShowContextMenu({editor, menu}) { const selection = editor.currentSelection(); const range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0); const word = range.toString(); @@ -40,144 +171,7 @@ export default class SpellcheckComposerExtension extends ComposerExtension { }); } - static update = (editor) => { - SpellcheckComposerExtension._unwrapWords(editor); - Spellchecker.handler.provideHintText(editor.rootNode.textContent) - // This should technically be in a .then() clause of the promise returned by - // provideHintText(), but that doesn't work for some reason. provideHintText() - // currently runs fast enough, or _wrapMisspelledWords() runs slow enough, - // that just running _wrapMisspelledWords() immediately works as intended. - SpellcheckComposerExtension._wrapMisspelledWords(editor) - } - - // Creates a shallow copy of a selection object where anchorNode / focusNode - // can be changed, and provides it to the callback provided. After the callback - // runs, it applies the new selection if `snapshot.modified` has been set. - - // Note: This is different from ExposedSelection because the nodes are not cloned. - // In the callback functions, we need to check whether the anchor/focus nodes - // are INSIDE the nodes we're adjusting. - static _whileApplyingSelectionChanges = (cb) => { - const selection = document.getSelection(); - const selectionSnapshot = { - anchorNode: selection.anchorNode, - anchorOffset: selection.anchorOffset, - focusNode: selection.focusNode, - focusOffset: selection.focusOffset, - modified: false, - }; - - cb(selectionSnapshot); - - if (selectionSnapshot.modified) { - selection.setBaseAndExtent(selectionSnapshot.anchorNode, selectionSnapshot.anchorOffset, selectionSnapshot.focusNode, selectionSnapshot.focusOffset); - } - } - - // Removes all of the nodes found in the provided `editor`. - // It normalizes the DOM after removing spelling nodes to ensure that words - // are not split between text nodes. (ie: doesn, 't => doesn't) - static _unwrapWords = (editor) => { - SpellcheckComposerExtension._whileApplyingSelectionChanges((selectionSnapshot) => { - const spellingNodes = editor.rootNode.querySelectorAll('spelling'); - for (let ii = 0; ii < spellingNodes.length; ii++) { - const node = spellingNodes[ii]; - if (selectionSnapshot.anchorNode === node) { - selectionSnapshot.anchorNode = node.firstChild; - } - if (selectionSnapshot.focusNode === node) { - selectionSnapshot.focusNode = node.firstChild; - } - selectionSnapshot.modified = true; - while (node.firstChild) { - node.parentNode.insertBefore(node.firstChild, node); - } - recycleSpellingNode(node); - node.parentNode.removeChild(node); - } - }); - - editor.rootNode.normalize(); - } - - - // Traverses all of the text nodes within the provided `editor`. If it finds a - // text node with a misspelled word, it splits it, wraps the misspelled word - // with a node and updates the selection to account for the change. - static _wrapMisspelledWords = (editor) => { - SpellcheckComposerExtension._whileApplyingSelectionChanges((selectionSnapshot) => { - const treeWalker = document.createTreeWalker(editor.rootNode, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, { - acceptNode: (node) => { - // skip the entire subtree inside tags and tags... - if ((node.nodeType === Node.ELEMENT_NODE) && (["CODE", "A", "PRE"].includes(node.tagName))) { - return NodeFilter.FILTER_REJECT; - } - return (node.nodeType === Node.TEXT_NODE) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; - }, - }); - - const nodeList = []; - - while (treeWalker.nextNode()) { - nodeList.push(treeWalker.currentNode); - } - - // Note: As a performance optimization, we stop spellchecking after encountering - // 30 misspelled words. This keeps the runtime of this method bounded! - let nodeMisspellingsFound = 0; - - while (true) { - const node = nodeList.shift(); - if ((node === undefined) || (nodeMisspellingsFound > 15)) { - break; - } - - const nodeContent = node.textContent; - const nodeWordRegexp = /(\w[\w'’-]*\w|\w)/g; // https://regex101.com/r/bG5yC4/1 - - while (true) { - const match = nodeWordRegexp.exec(nodeContent); - if ((match === null) || (nodeMisspellingsFound > 15)) { - break; - } - - if (Spellchecker.isMisspelled(match[0])) { - // The insertion point is currently at the end of this misspelled word. - // Do not mark it until the user types a space or leaves. - if ((selectionSnapshot.focusNode === node) && (selectionSnapshot.focusOffset === match.index + match[0].length)) { - continue; - } - - const matchNode = (match.index === 0) ? node : node.splitText(match.index); - const afterMatchNode = matchNode.splitText(match[0].length); - - const spellingSpan = getSpellingNodeForText(match[0]); - matchNode.parentNode.replaceChild(spellingSpan, matchNode); - - for (const prop of ['anchor', 'focus']) { - if (selectionSnapshot[`${prop}Node`] === node) { - if (selectionSnapshot[`${prop}Offset`] > match.index + match[0].length) { - selectionSnapshot[`${prop}Node`] = afterMatchNode; - selectionSnapshot[`${prop}Offset`] -= match.index + match[0].length; - selectionSnapshot.modified = true; - } else if (selectionSnapshot[`${prop}Offset`] >= match.index) { - selectionSnapshot[`${prop}Node`] = spellingSpan.childNodes[0]; - selectionSnapshot[`${prop}Offset`] -= match.index; - selectionSnapshot.modified = true; - } - } - } - - nodeMisspellingsFound += 1; - nodeList.unshift(afterMatchNode); - break; - } - } - } - }); - } - - static applyTransformsForSending = ({draftBodyRootNode}) => { + static applyTransformsForSending({draftBodyRootNode}) { const spellingEls = draftBodyRootNode.querySelectorAll('spelling'); for (const spellingEl of Array.from(spellingEls)) { // move contents out of the spelling node, remove the node @@ -189,7 +183,8 @@ export default class SpellcheckComposerExtension extends ComposerExtension { } } - static unapplyTransformsForSending = () => { + static unapplyTransformsForSending() { // no need to put spelling nodes back! } + } diff --git a/src/spellchecker.es6 b/src/spellchecker.es6 index 02e8f78c3..51d2b091d 100644 --- a/src/spellchecker.es6 +++ b/src/spellchecker.es6 @@ -71,10 +71,10 @@ class Spellchecker { } isMisspelled = (word) => { - if (word in this._customDict) { - return false + if ({}.hasOwnProperty.call(this._customDict, word)) { + return true } - if (word in this.isMisspelledCache) { + if ({}.hasOwnProperty.call(this.isMisspelledCache, word)) { return this.isMisspelledCache[word] } const misspelled = !this.handler.handleElectronSpellCheck(word);