-
-
Save kofifus/4b2f79cadc871a29439d919692099406 to your computer and use it in GitHub Desktop.
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"> ⌛</option>'; | |
if (separator) options += '<option style="min-height:1px; max-height:1px; padding:0; background-color: #000000;" disabled> </option>'; | |
options += '<option value="##ignoreall##">Ignore 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; | |
} |
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!
Thanks :) I wrote this a few years back for a project that didn't take off, be aware there may be better solutions now ...
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
Sorry can't help .. good luck :)
Here's the same one on CodePen (if you can't reach plnkr.com)
https://codepen.io/anon/pen/veGzWN?editors=1000