Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
CodeMirror spell checker with typo correction
usage:
------
// include codemirror.js, addon/mode/overlay.js
// include async typo.js from https://github.com/cfinke/Typo.js/pull/45
// include loadTypo.js from: https://github.com/cfinke/Typo.js/pull/50
// loading typo + dicts takes a while so we start it first
// hosting the dicts on your local domain will give much faster loading time
// english dictionaries taken from https://github.com/cfinke/Typo.js/pull/47
// get other dictionaries with git clone https://chromium.googlesource.com/chromium/deps/hunspell_dictionaries
const aff = 'https://cdn.rawgit.com/kofifus/Typo.js/312bf158a814dda6eac3bd991e3a133c84472fc8/typo/dictionaries/en_US/en_US.aff';
const dic = 'https://cdn.rawgit.com/kofifus/Typo.js/312bf158a814dda6eac3bd991e3a133c84472fc8/typo/dictionaries/en_US/en_US.dic';
let typoLoaded=loadTypo(aff, dic);
// initialize codemirror instances etc
...
// start spellchecking
typoLoaded.then(typo => startSpellCheck(cm, typo));
demo:
-----
https://plnkr.co/edit/0y1wCHXx3k3mZaHFOpHT
.CodeMirror .cm-spell-error {
background-image: url("https://raw.githubusercontent.com/jwulf/typojs-project/master/public/images/red-wavy-underline.gif");
background-position: bottom;
background-repeat: repeat-x;
}
#suggestBox {
display:inline-block; overflow:hidden; border:solid black 1px;
}
#suggestBox > select {
padding:10px; margin:-5px -20px -5px -5px;
}
#suggestBox > select > option:hover {
box-shadow: 0 0 10px 100px #4A8CF7 inset; color: white;
}
"use strict";
function startSpellCheck(cm, typo) {
if (!cm || !typo) return; // sanity
startSpellCheck.ignoreDict = {}; // dictionary of ignored words
// Define what separates a word
var rx_word = '!\'\"#$%&()*+,-./:;<=>?@[\\]^_`{|}~ ';
cm.spellcheckOverlay = {
token: function(stream) {
var ch = stream.peek();
var word = "";
if (rx_word.includes(ch) || ch === '\uE000' || ch === '\uE001') {
stream.next();
return null;
}
while ((ch = stream.peek()) && !rx_word.includes(ch)) {
word += ch;
stream.next();
}
if (!/[a-z]/i.test(word)) return null; // no letters
if (startSpellCheck.ignoreDict[word]) return null;
if (!typo.check(word)) return "spell-error"; // CSS class: cm-spell-error
}
}
cm.addOverlay(cm.spellcheckOverlay);
// initialize the suggestion box
let sbox = getSuggestionBox(typo);
cm.getWrapperElement().oncontextmenu = (e => {
e.preventDefault();
e.stopPropagation();
sbox.suggest(cm, e);
return false;
});
}
function getSuggestionBox(typo) {
function sboxShow(cm, sbox, items, x, y, hourglass) {
let selwidget = sbox.children[0];
var isSafari = navigator.vendor && navigator.vendor.indexOf('Apple') > -1 && navigator.userAgent && !navigator.userAgent.match('CriOS');
let separator=(!isSafari && (hourglass || items.length>0)); // separator line does not work well on safari
let options = '';
items.forEach(s => options += '<option value="' + s + '">' + s + '</option>');
if (hourglass) options += '<option disabled="disabled">&nbsp;&nbsp;&nbsp;&#8987;</option>';
if (separator) options += '<option style="min-height:1px; max-height:1px; padding:0; background-color: #000000;" disabled>&nbsp;</option>';
options += '<option value="##ignoreall##">Ignore&nbsp;All</option>';
let indexInParent=[].slice.call(selwidget.parentElement.children).indexOf(selwidget);
selwidget.innerHTML=options;
selwidget=selwidget.parentElement.children[indexInParent];
let fontSize=window.getComputedStyle(cm.getWrapperElement(), null).getPropertyValue('font-size');
selwidget.style.fontSize=fontSize;
selwidget.size = selwidget.length;
if (separator) selwidget.size--;
selwidget.value = -1;
// position widget inside cm
let cmrect = cm.getWrapperElement().getBoundingClientRect();
sbox.style.left = x + 'px';
sbox.style.top = (y - sbox.offsetHeight / 2) + 'px';
let widgetRect = sbox.getBoundingClientRect();
if (widgetRect.top < cmrect.top) sbox.style.top = (cmrect.top + 2) + 'px';
if (widgetRect.right > cmrect.right) sbox.style.left = (cmrect.right - widgetRect.width - 2) + 'px';
if (widgetRect.bottom > cmrect.bottom) sbox.style.top = (cmrect.bottom - widgetRect.height - 2) + 'px';
}
function sboxHide(sbox) {
sbox.style.top = sbox.style.left = '-1000px';
typo.suggest(); // disable any running suggeations search
}
// create suggestions widget
let sbox = document.getElementById('suggestBox');
if (!sbox) {
sbox = document.createElement('div');
sbox.style.zIndex = 100000;
sbox.id = 'suggestBox';
sbox.style.position = 'fixed';
sboxHide(sbox);
let selwidget = document.createElement('select');
selwidget.multiple = 'yes';
sbox.appendChild(selwidget);
sbox.suggest = ((cm, e) => { // e is the event from cm contextmenu event
if (!e.target.classList.contains('cm-spell-error')) return false; // not on typo
let token = e.target.innerText;
if (!token) return false; // sanity
// save cm instance, token, token coordinates in sbox
sbox.codeMirror = cm;
sbox.token = token;
sbox.screenPos={ x: e.pageX, y: e.pageY }
let tokenRect = e.target.getBoundingClientRect();
let start=cm.coordsChar({left: tokenRect.left+1, top: tokenRect.top+1});
let end=cm.coordsChar({left: tokenRect.right-1, top: tokenRect.top+1});
sbox.cmpos={ line: start.line, start: start.ch, end: end.ch};
// show hourglass
sboxShow(cm, sbox, [], e.pageX, e.pageY, true);
var results = [];
// async
typo.suggest(token, null, all => {
//console.log('done');
sboxShow(cm, sbox, results, e.pageX, e.pageY);
}, next => {
//console.log('found '+next);
results.push(next);
sboxShow(cm, sbox, results, e.pageX, e.pageY, true);
});
// non async
//sboxShow(cm, sbox, typo.suggest(token), e.pageX, e.pageY);
e.preventDefault();
return false;
});
sbox.onmouseout = (e => {
let related=(e.relatedTarget ? e.relatedTarget.tagName : null);
if (related!=='SELECT' && related!=='OPTION') sboxHide(sbox)
});
selwidget.onchange = (e => {
sboxHide(sbox)
let cm = sbox.codeMirror, correction = e.target.value;
if (correction == '##ignoreall##') {
startSpellCheck.ignoreDict[sbox.token] = true;
cm.setOption('maxHighlightLength', (--cm.options.maxHighlightLength) + 1); // ugly hack to rerun overlays
} else {
cm.replaceRange(correction, { line: sbox.cmpos.line, ch: sbox.cmpos.start}, { line: sbox.cmpos.line, ch: sbox.cmpos.end});
cm.focus();
cm.setCursor({line: sbox.cmpos.line, ch: sbox.cmpos.start+correction.length});
}
});
document.body.appendChild(sbox);
}
return sbox;
}
@mihailik

This comment has been minimized.

Copy link

@mihailik mihailik commented Sep 20, 2017

Here's the same one on CodePen (if you can't reach plnkr.com)

https://codepen.io/anon/pen/veGzWN?editors=1000

image

@hriverahdez

This comment has been minimized.

Copy link

@hriverahdez hriverahdez commented Aug 11, 2020

Wow! I've been looking on how to do this for a while now. I'm not knowledgeable enough with codemirror so this it super helpful. Thanks a lot!

@kofifus

This comment has been minimized.

Copy link
Owner Author

@kofifus kofifus commented Aug 12, 2020

Thanks :) I wrote this a few years back for a project that didn't take off, be aware there may be better solutions now ...

@hriverahdez

This comment has been minimized.

Copy link

@hriverahdez hriverahdez commented Aug 12, 2020

Yes that could be, the internet is a big place 😅 However, I had not find anything up until now.

By the way, quick question if you don't mind me asking. I was testing your example and it works seamlessly on Chrome. In Firefox however, I can't get the context menu to show. The problem lies in line 95. For some reason in Firefox the event target is not the inner span that is being right-clicked on (and the one that get the class assigned to it from the overlay) but the textarea itself. Therefore the classList is empty. I don't know why this happens.

Do you know if this is some browser specific behaviour or do you suspect it could be due to some codemirror internals?

Anyway, thanks for your reply

@kofifus

This comment has been minimized.

Copy link
Owner Author

@kofifus kofifus commented Aug 12, 2020

Sorry can't help .. good luck :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment