Mailspring/internal_packages/composer-spellcheck/lib/spellcheck-composer-extension.es6
Juan Tejada d59d292d2f fix(spellcheck): Minor perf improvements
- When applyign spellcheck mutations to the dom, toggle display property
on the contenteditable to reduce reflows/repaints while mutating
- Debounce provideTextHint to reduce work on each keystroke
- Minor speedup checking existence of keys in spellchecker internal maps
2016-11-16 15:18:11 -08:00

191 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
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;
}
}
}
});
}
export default class SpellcheckComposerExtension extends ComposerExtension {
static onContentChanged({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}) {
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.update(editor);
},
onDidLearn: () => {
SpellcheckComposerExtension.update(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!
}
}