Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@kofifus
Last active September 23, 2022 17:12
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save kofifus/4b2f79cadc871a29439d919692099406 to your computer and use it in GitHub Desktop.
Save kofifus/4b2f79cadc871a29439d919692099406 to your computer and use it in GitHub Desktop.
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;
}
@kofifus
Copy link
Author

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
Copy link

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
Copy link
Author

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