import _ from 'underscore' import {DOMUtils, ComposerExtension, Spellchecker} from 'nylas-exports'; const recycled = []; const MAX_MISPELLINGS = 10 function getSpellingNodeForText(text) { let node = recycled.pop(); if (!node) { node = document.createElement('spelling'); node.classList.add('misspelled'); } node.textContent = text; return node; } 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; } } } }); } let currentlyRunningSpellChecker = false; const runSpellChecker = _.debounce((rootNode) => { currentlyRunningSpellChecker = true; unwrapWords(rootNode); Spellchecker.handler.provideHintText(rootNode.textContent).then(() => { wrapMisspelledWords(rootNode) // We defer here so that when the MutationObserver fires the // SpellcheckComposerExtension.onContentChanged callback we will properly // observe that we just ran the spellchecker and won't schedule another // spellcheck pass (which would cause an infinite loop of spellchecking // once every second) _.defer(() => { currentlyRunningSpellChecker = false; }); }) }, 1000) export default class SpellcheckComposerExtension extends ComposerExtension { static onContentChanged({editor}) { const {rootNode} = editor if (!currentlyRunningSpellChecker) { runSpellChecker(rootNode); } } static onShowContextMenu({editor, menu}) { const selection = editor.currentSelection(); const range = DOMUtils.Mutating.getRangeAtAndSelectWord(selection, 0); const word = range.toString(); Spellchecker.appendSpellingItemsToMenu({ menu, word, onCorrect: (correction) => { DOMUtils.Mutating.applyTextInRange(range, selection, correction); SpellcheckComposerExtension.onContentChanged({editor}); }, onDidLearn: () => { SpellcheckComposerExtension.onContentChanged({editor}); }, }); } 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 const parent = spellingEl.parentNode; while (spellingEl.firstChild) { parent.insertBefore(spellingEl.firstChild, spellingEl); } parent.removeChild(spellingEl); } } static unapplyTransformsForSending() { // no need to put spelling nodes back! } }