Mailspring/packages/client-app/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.es6

204 lines
7 KiB
Plaintext
Raw Normal View History

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 <spelling> 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 <spelling> 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 <code> tags and <a> 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!
}
}