mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-06 08:08:10 +08:00
214 lines
7.1 KiB
JavaScript
214 lines
7.1 KiB
JavaScript
import _ from 'underscore';
|
||
import { DOMUtils, ComposerExtension, Spellchecker } from 'mailspring-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!
|
||
}
|
||
}
|