mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 15:56:10 +08:00
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
This commit is contained in:
parent
a2f73b24c5
commit
d59d292d2f
|
@ -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 <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}) {
|
||||
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 <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)
|
||||
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 <spelling> 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 <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
|
||||
// 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!
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue