mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-25 09:46:07 +08:00
9acb8b48a8
Summary: Previously we were only debouncing one function in onContentChanged. This would cause us to do very expensive things on every keypress in the things that weren't debounced. This diff changes things so that we debounce the entire process of running the spellchecker. Test Plan: Run locally, make sure spellchecker doesn't run on every keypress. Misspell lots of things, type really quickly, paste large chunks of text into the composer. Reviewers: evan, spang, juan Reviewed By: juan Differential Revision: https://phab.nylas.com/D3791
204 lines
7 KiB
JavaScript
204 lines
7 KiB
JavaScript
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!
|
||
}
|
||
|
||
}
|