1
0
mirror of https://github.com/BoostIo/Boostnote synced 2025-12-16 03:06:27 +00:00

Spellcheck - first try to fix #2176

This commit is contained in:
ehhc
2018-08-22 16:46:09 +02:00
38 changed files with 219619 additions and 26 deletions

View File

@@ -11,9 +11,12 @@ import eventEmitter from 'browser/main/lib/eventEmitter'
import iconv from 'iconv-lite'
import crypto from 'crypto'
import consts from 'browser/lib/consts'
import styles from '../components/CodeEditor.styl'
import fs from 'fs'
const { ipcRenderer } = require('electron')
const { ipcRenderer, remote } = require('electron')
import normalizeEditorFontFamily from 'browser/lib/normalizeEditorFontFamily'
const spellcheck = require('browser/lib/spellcheck')
const buildEditorContextMenu = require('browser/lib/contextMenuBuilder')
CodeMirror.modeURL = '../node_modules/codemirror/mode/%N/%N.js'
@@ -29,7 +32,7 @@ export default class CodeEditor extends React.Component {
leading: false,
trailing: true
})
this.changeHandler = e => this.handleChange(e)
this.changeHandler = (editor, changeObject) => this.handleChange(editor, changeObject)
this.focusHandler = () => {
ipcRenderer.send('editor:focused', true)
}
@@ -60,6 +63,13 @@ export default class CodeEditor extends React.Component {
this.searchState = null
this.formatTable = () => this.handleFormatTable()
this.contextMenuHandler = function (editor, event) {
const menu = buildEditorContextMenu(editor, event)
if (menu != null) {
setTimeout(() => menu.popup(remote.getCurrentWindow()), 30)
}
}
}
handleSearch (msg) {
@@ -202,6 +212,7 @@ export default class CodeEditor extends React.Component {
this.editor.on('blur', this.blurHandler)
this.editor.on('change', this.changeHandler)
this.editor.on('paste', this.pasteHandler)
this.editor.on('contextmenu', this.contextMenuHandler)
eventEmitter.on('top:search', this.searchHandler)
eventEmitter.emit('code:init')
@@ -217,6 +228,9 @@ export default class CodeEditor extends React.Component {
CodeMirror.Vim.map('ZZ', ':q', 'normal')
this.setState({ isReady: true })
this.tableEditor = new TableEditor(new TextEditorInterface(this.editor))
if (this.props.spellCheck) {
this.editor.addPanel(this.createSpellCheckPanel(), {position: 'bottom'})
}
eventEmitter.on('code:format-table', this.formatTable)
}
@@ -305,9 +319,11 @@ export default class CodeEditor extends React.Component {
this.editor.off('paste', this.pasteHandler)
eventEmitter.off('top:search', this.searchHandler)
this.editor.off('scroll', this.scrollHandler)
this.editor.off('contextmenu', this.contextMenuHandler)
const editorTheme = document.getElementById('editorTheme')
editorTheme.removeEventListener('load', this.loadStyleHandler)
spellcheck.setLanguage(null, spellcheck.SPELLCHECK_DISABLED)
eventEmitter.off('code:format-table', this.formatTable)
}
@@ -367,10 +383,11 @@ export default class CodeEditor extends React.Component {
CodeMirror.autoLoadMode(this.editor, syntax.mode)
}
handleChange (e) {
this.value = this.editor.getValue()
handleChange (editor, changeObject) {
spellcheck.handleChange(editor, changeObject)
this.value = editor.getValue()
if (this.props.onChange) {
this.props.onChange(e)
this.props.onChange(editor)
}
}
@@ -589,6 +606,24 @@ export default class CodeEditor extends React.Component {
/>
)
}
createSpellCheckPanel () {
const panel = document.createElement('div')
panel.className = 'panel bottom'
const dropdown = document.createElement('select')
dropdown.title = 'Spellcheck'
dropdown.className = styles['spellcheck-select']
dropdown.addEventListener('change', (e) => spellcheck.setLanguage(this.editor, dropdown.value))
const options = spellcheck.getAvailableDictionaries()
for (const op of options) {
const option = document.createElement('option')
option.value = op.value
option.innerHTML = op.label
dropdown.appendChild(option)
}
panel.appendChild(dropdown)
return panel
}
}
CodeEditor.propTypes = {
@@ -599,7 +634,8 @@ CodeEditor.propTypes = {
className: PropTypes.string,
onBlur: PropTypes.func,
onChange: PropTypes.func,
readOnly: PropTypes.bool
readOnly: PropTypes.bool,
spellCheck: PropTypes.bool
}
CodeEditor.defaultProps = {
@@ -609,5 +645,6 @@ CodeEditor.defaultProps = {
fontSize: 14,
fontFamily: 'Monaco, Consolas',
indentSize: 4,
indentType: 'space'
indentType: 'space',
spellCheck: false
}

View File

@@ -0,0 +1,6 @@
.codeEditor-typo
text-decoration underline wavy red
.spellcheck-select
border: none
text-decoration underline wavy red

View File

@@ -268,6 +268,7 @@ class MarkdownEditor extends React.Component {
fetchUrlTitle={config.editor.fetchUrlTitle}
onChange={(e) => this.handleChange(e)}
onBlur={(e) => this.handleBlur(e)}
spellCheck
/>
<MarkdownPreview styleName={this.state.status === 'PREVIEW'
? 'preview'

View File

@@ -162,6 +162,7 @@ class MarkdownSplitEditor extends React.Component {
noteKey={noteKey}
onChange={this.handleOnChange.bind(this)}
onScroll={this.handleScroll.bind(this)}
spellCheck
/>
<div styleName='slider' style={{left: this.state.codeEditorWidthInPercent + '%'}} onMouseDown={e => this.handleMouseDown(e)} >
<div styleName='slider-hitbox' />

View File

@@ -0,0 +1,65 @@
const {remote} = require('electron')
const {Menu} = remote.require('electron')
const spellcheck = require('./spellcheck')
/**
* Creates the context menu that is shown when there is a right click in the editor of a (not-snippet) note.
* If the word is does not contains a spelling error (determined by the 'error style'), no suggestions for corrections are requested
* => they are not visible in the context menu
* @param editor CodeMirror editor
* @param {MouseEvent} event that has triggered the creation of the context menu
* @returns {Electron.Menu} The created electron context menu
*/
const buildEditorContextMenu = function (editor, event) {
if (editor == null || event == null || event.pageX == null || event.pageY == null) {
return null
}
const cursor = editor.coordsChar({left: event.pageX, top: event.pageY})
const wordRange = editor.findWordAt(cursor)
const word = editor.getRange(wordRange.anchor, wordRange.head)
const existingMarks = editor.findMarks(wordRange.anchor, wordRange.head) || []
let isMisspelled = false
for (const mark of existingMarks) {
if (mark.className === spellcheck.getCSSClassName()) {
isMisspelled = true
break
}
}
let suggestion = []
if (isMisspelled) {
suggestion = spellcheck.getSpellingSuggestion(word)
}
const selection = {
isMisspelled: isMisspelled,
spellingSuggestions: suggestion
}
const template = [{
role: 'cut'
}, {
role: 'copy'
}, {
role: 'paste'
}, {
role: 'selectall'
}]
if (selection.isMisspelled) {
const suggestions = selection.spellingSuggestions
template.unshift.apply(template, suggestions.map(function (suggestion) {
return {
label: suggestion,
click: function (suggestion) {
if (editor != null) {
editor.replaceRange(suggestion.label, wordRange.anchor, wordRange.head)
}
}
}
}).concat({
type: 'separator'
}))
}
return Menu.buildFromTemplate(template)
}
module.exports = buildEditorContextMenu

229
browser/lib/spellcheck.js Normal file
View File

@@ -0,0 +1,229 @@
import styles from '../components/CodeEditor.styl'
import i18n from 'browser/lib/i18n'
const Typo = require('typo-js')
const _ = require('lodash')
const CSS_ERROR_CLASS = 'codeEditor-typo'
const SPELLCHECK_DISABLED = 'NONE'
const DICTIONARY_PATH = '../dictionaries'
const MILLISECONDS_TILL_LIVECHECK = 500
let dictionary = null
let self
function getAvailableDictionaries () {
return [
{label: i18n.__('Disabled'), value: SPELLCHECK_DISABLED},
{label: i18n.__('English'), value: 'en_GB'},
{label: i18n.__('German'), value: 'de_DE'},
{label: i18n.__('French'), value: 'fr_FR'}
]
}
/**
* Only to be used in the tests :)
*/
function setDictionaryForTestsOnly (newDictionary) {
dictionary = newDictionary
}
/**
* @description Initializes the spellcheck. It removes all existing marks of the current editor.
* If a language was given (i.e. lang !== this.SPELLCHECK_DISABLED) it will load the stated dictionary and use it to check the whole document.
* @param {Codemirror} editor CodeMirror-Editor
* @param {String} lang on of the values from getAvailableDictionaries()-Method
*/
function setLanguage (editor, lang) {
self = this
dictionary = null
if (editor == null) {
return
}
const existingMarks = editor.getAllMarks() || []
for (const mark of existingMarks) {
mark.clear()
}
if (lang !== SPELLCHECK_DISABLED) {
dictionary = new Typo(lang, false, false, {
dictionaryPath: DICTIONARY_PATH,
asyncLoad: true,
loadedCallback: () =>
checkWholeDocument(editor)
})
}
}
/**
* Checks the whole content of the editor for typos
* @param {Codemirror} editor CodeMirror-Editor
*/
function checkWholeDocument (editor) {
const lastLine = editor.lineCount() - 1
const textOfLastLine = editor.getLine(lastLine) || ''
const lastChar = textOfLastLine.length
const from = {line: 0, ch: 0}
const to = {line: lastLine, ch: lastChar}
checkMultiLineRange(editor, from, to)
}
/**
* Checks the given range for typos
* @param {Codemirror} editor CodeMirror-Editor
* @param {line, ch} from starting position of the spellcheck
* @param {line, ch} to end position of the spellcheck
*/
function checkMultiLineRange (editor, from, to) {
function sortRange (pos1, pos2) {
if (pos1.line > pos2.line || (pos1.line === pos2.line && pos1.ch > pos2.ch)) {
return {from: pos2, to: pos1}
}
return {from: pos1, to: pos2}
}
const {from: smallerPos, to: higherPos} = sortRange(from, to)
for (let l = smallerPos.line; l <= higherPos.line; l++) {
const line = editor.getLine(l) || ''
let w = 0
if (l === smallerPos.line) {
w = smallerPos.ch
}
let wEnd = line.length
if (l === higherPos.line) {
wEnd = higherPos.ch
}
while (w <= wEnd) {
const wordRange = editor.findWordAt({line: l, ch: w})
self.checkWord(editor, wordRange)
w += (wordRange.head.ch - wordRange.anchor.ch) + 1
}
}
}
/**
* @description Checks whether a certain range of characters in the editor (i.e. a word) contains a typo.
* If so the ranged will be marked with the class CSS_ERROR_CLASS.
* Note: Due to performance considerations, only words with more then 3 signs are checked.
* @param {Codemirror} editor CodeMirror-Editor
* @param wordRange Object specifying the range that should be checked.
* Having the following structure: <code>{anchor: {line: integer, ch: integer}, head: {line: integer, ch: integer}}</code>
*/
function checkWord (editor, wordRange) {
const word = editor.getRange(wordRange.anchor, wordRange.head)
if (word == null || word.length <= 3) {
return
}
if (!dictionary.check(word)) {
editor.markText(wordRange.anchor, wordRange.head, {className: styles[CSS_ERROR_CLASS]})
}
}
/**
* Checks the changes recently made (aka live check)
* @param {Codemirror} editor CodeMirror-Editor
* @param fromChangeObject codeMirror changeObject describing the start of the editing
* @param toChangeObject codeMirror changeObject describing the end of the editing
*/
function checkChangeRange (editor, fromChangeObject, toChangeObject) {
/**
* Calculate the smallest respectively largest position as a start, resp. end, position and return it
* @param start CodeMirror change object
* @param end CodeMirror change object
* @returns {{start: {line: *, ch: *}, end: {line: *, ch: *}}}
*/
function getStartAndEnd (start, end) {
const possiblePositions = [start.from, start.to, end.from, end.to]
let smallest = start.from
let biggest = end.to
for (const currentPos of possiblePositions) {
if (currentPos.line < smallest.line || (currentPos.line === smallest.line && currentPos.ch < smallest.ch)) {
smallest = currentPos
}
if (currentPos.line > biggest.line || (currentPos.line === biggest.line && currentPos.ch > biggest.ch)) {
biggest = currentPos
}
}
return {start: smallest, end: biggest}
}
if (dictionary === null || editor == null) { return }
try {
const {start, end} = getStartAndEnd(fromChangeObject, toChangeObject)
// Expand the range to include words after/before whitespaces
start.ch = Math.max(start.ch - 1, 0)
end.ch = end.ch + 1
// clean existing marks
const existingMarks = editor.findMarks(start, end) || []
for (const mark of existingMarks) {
mark.clear()
}
self.checkMultiLineRange(editor, start, end)
} catch (e) {
console.info('Error during the spell check. It might be due to problems figuring out the range of the new text..', e)
}
}
function saveLiveSpellCheckFrom (changeObject) {
liveSpellCheckFrom = changeObject
}
let liveSpellCheckFrom
const debouncedSpellCheckLeading = _.debounce(saveLiveSpellCheckFrom, MILLISECONDS_TILL_LIVECHECK, {
'leading': true,
'trailing': false
})
const debouncedSpellCheck = _.debounce(checkChangeRange, MILLISECONDS_TILL_LIVECHECK, {
'leading': false,
'trailing': true
})
/**
* Handles a keystroke. Buffers the input and performs a live spell check after a certain time. Uses _debounce from lodash to buffer the input
* @param {Codemirror} editor CodeMirror-Editor
* @param changeObject codeMirror changeObject
*/
function handleChange (editor, changeObject) {
debouncedSpellCheckLeading(changeObject)
debouncedSpellCheck(editor, liveSpellCheckFrom, changeObject)
}
/**
* Returns an array of spelling suggestions for the given (wrong written) word.
* Returns an empty array if the dictionary is null (=> spellcheck is disabled) or the given word was null
* @param word word to be checked
* @returns {String[]} Array of suggestions
*/
function getSpellingSuggestion (word) {
if (dictionary == null || word == null) {
return []
}
return dictionary.suggest(word)
}
/**
* Returns the name of the CSS class used for errors
*/
function getCSSClassName () {
return styles[CSS_ERROR_CLASS]
}
module.exports = {
DICTIONARY_PATH,
CSS_ERROR_CLASS,
SPELLCHECK_DISABLED,
getAvailableDictionaries,
setLanguage,
checkChangeRange,
handleChange,
getSpellingSuggestion,
checkWord,
checkMultiLineRange,
checkWholeDocument,
setDictionaryForTestsOnly,
getCSSClassName
}