mirror of
https://github.com/BoostIo/Boostnote
synced 2025-12-13 01:36:22 +00:00
spellcheck -> context menu with spelling suggestions
This commit is contained in:
@@ -11,8 +11,9 @@ import crypto from 'crypto'
|
|||||||
import consts from 'browser/lib/consts'
|
import consts from 'browser/lib/consts'
|
||||||
import styles from '../components/CodeEditor.styl'
|
import styles from '../components/CodeEditor.styl'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
const {ipcRenderer} = require('electron')
|
const { ipcRenderer, remote } = require('electron')
|
||||||
const spellcheck = require('browser/lib/spellcheck')
|
const spellcheck = require('browser/lib/spellcheck')
|
||||||
|
const buildEditorContextMenu = require('browser/lib/contextMenuBuilder')
|
||||||
|
|
||||||
CodeMirror.modeURL = '../node_modules/codemirror/mode/%N/%N.js'
|
CodeMirror.modeURL = '../node_modules/codemirror/mode/%N/%N.js'
|
||||||
|
|
||||||
@@ -50,6 +51,13 @@ export default class CodeEditor extends React.Component {
|
|||||||
}
|
}
|
||||||
this.searchHandler = (e, msg) => this.handleSearch(msg)
|
this.searchHandler = (e, msg) => this.handleSearch(msg)
|
||||||
this.searchState = null
|
this.searchState = null
|
||||||
|
|
||||||
|
this.contextMenuHandler = function (editor, event) {
|
||||||
|
const menu = buildEditorContextMenu(editor, event)
|
||||||
|
if (menu != null) {
|
||||||
|
setTimeout(() => menu.popup(remote.getCurrentWindow()), 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSearch (msg) {
|
handleSearch (msg) {
|
||||||
@@ -170,6 +178,7 @@ export default class CodeEditor extends React.Component {
|
|||||||
this.editor.on('blur', this.blurHandler)
|
this.editor.on('blur', this.blurHandler)
|
||||||
this.editor.on('change', this.changeHandler)
|
this.editor.on('change', this.changeHandler)
|
||||||
this.editor.on('paste', this.pasteHandler)
|
this.editor.on('paste', this.pasteHandler)
|
||||||
|
this.editor.on('contextmenu', this.contextMenuHandler)
|
||||||
eventEmitter.on('top:search', this.searchHandler)
|
eventEmitter.on('top:search', this.searchHandler)
|
||||||
|
|
||||||
eventEmitter.emit('code:init')
|
eventEmitter.emit('code:init')
|
||||||
@@ -267,8 +276,10 @@ export default class CodeEditor extends React.Component {
|
|||||||
this.editor.off('paste', this.pasteHandler)
|
this.editor.off('paste', this.pasteHandler)
|
||||||
eventEmitter.off('top:search', this.searchHandler)
|
eventEmitter.off('top:search', this.searchHandler)
|
||||||
this.editor.off('scroll', this.scrollHandler)
|
this.editor.off('scroll', this.scrollHandler)
|
||||||
|
this.editor.off('contextmenu', this.contextMenuHandler)
|
||||||
const editorTheme = document.getElementById('editorTheme')
|
const editorTheme = document.getElementById('editorTheme')
|
||||||
editorTheme.removeEventListener('load', this.loadStyleHandler)
|
editorTheme.removeEventListener('load', this.loadStyleHandler)
|
||||||
|
spellcheck.setLanguage(null, spellcheck.SPELLCHECK_DISABLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate (prevProps, prevState) {
|
componentDidUpdate (prevProps, prevState) {
|
||||||
@@ -530,7 +541,7 @@ export default class CodeEditor extends React.Component {
|
|||||||
const dropdown = document.createElement('select')
|
const dropdown = document.createElement('select')
|
||||||
dropdown.title = 'Spellcheck'
|
dropdown.title = 'Spellcheck'
|
||||||
dropdown.className = styles['spellcheck-select']
|
dropdown.className = styles['spellcheck-select']
|
||||||
dropdown.addEventListener('change', (e) => spellcheck.initialize(this.editor, dropdown.value))
|
dropdown.addEventListener('change', (e) => spellcheck.setLanguage(this.editor, dropdown.value))
|
||||||
const options = spellcheck.getAvailableDictionaries()
|
const options = spellcheck.getAvailableDictionaries()
|
||||||
for (const op of options) {
|
for (const op of options) {
|
||||||
const option = document.createElement('option')
|
const option = document.createElement('option')
|
||||||
|
|||||||
65
browser/lib/contextMenuBuilder.js
Normal file
65
browser/lib/contextMenuBuilder.js
Normal 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
|
||||||
@@ -30,11 +30,13 @@ function setDictionaryForTestsOnly (newDictionary) {
|
|||||||
* @param {Codemirror} editor CodeMirror-Editor
|
* @param {Codemirror} editor CodeMirror-Editor
|
||||||
* @param {String} lang on of the values from getAvailableDictionaries()-Method
|
* @param {String} lang on of the values from getAvailableDictionaries()-Method
|
||||||
*/
|
*/
|
||||||
function initialize (editor, lang) {
|
function setLanguage (editor, lang) {
|
||||||
dictionary = null
|
dictionary = null
|
||||||
const existingMarks = editor.getAllMarks() || []
|
if (editor != null) {
|
||||||
for (const mark of existingMarks) {
|
const existingMarks = editor.getAllMarks() || []
|
||||||
mark.clear()
|
for (const mark of existingMarks) {
|
||||||
|
mark.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (lang !== SPELLCHECK_DISABLED) {
|
if (lang !== SPELLCHECK_DISABLED) {
|
||||||
dictionary = new Typo(lang, false, false, {
|
dictionary = new Typo(lang, false, false, {
|
||||||
@@ -170,15 +172,37 @@ function liveSpellcheck (editor, 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 = {
|
module.exports = {
|
||||||
DICTIONARY_PATH,
|
DICTIONARY_PATH,
|
||||||
CSS_ERROR_CLASS,
|
CSS_ERROR_CLASS,
|
||||||
SPELLCHECK_DISABLED,
|
SPELLCHECK_DISABLED,
|
||||||
getAvailableDictionaries,
|
getAvailableDictionaries,
|
||||||
initialize,
|
setLanguage,
|
||||||
liveSpellcheck,
|
liveSpellcheck,
|
||||||
|
getSpellingSuggestion,
|
||||||
checkWord,
|
checkWord,
|
||||||
checkMultiLineRange,
|
checkMultiLineRange,
|
||||||
checkWholeDocument,
|
checkWholeDocument,
|
||||||
setDictionaryForTestsOnly
|
setDictionaryForTestsOnly,
|
||||||
|
getCSSClassName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ function createAttachmentDestinationFolder (destinationStoragePath, noteKey) {
|
|||||||
* @param noteKey Key of the current note
|
* @param noteKey Key of the current note
|
||||||
*/
|
*/
|
||||||
function migrateAttachments (renderedHTML, storagePath, noteKey) {
|
function migrateAttachments (renderedHTML, storagePath, noteKey) {
|
||||||
|
if (renderedHTML == null || storagePath == null || noteKey == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (sander.existsSync(path.join(storagePath, 'images'))) {
|
if (sander.existsSync(path.join(storagePath, 'images'))) {
|
||||||
const attachments = getAttachmentsInContent(renderedHTML) || []
|
const attachments = getAttachmentsInContent(renderedHTML) || []
|
||||||
if (attachments !== []) {
|
if (attachments !== []) {
|
||||||
|
|||||||
126
tests/lib/contextMenuBuilder.test.js
Normal file
126
tests/lib/contextMenuBuilder.test.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
let menuBuilderParameter
|
||||||
|
jest.mock('electron', () => {
|
||||||
|
return {remote: {require: jest.fn(() => { return {Menu: {buildFromTemplate: jest.fn((param) => { menuBuilderParameter = param })}} })}}
|
||||||
|
})
|
||||||
|
|
||||||
|
const spellcheck = require('browser/lib/spellcheck')
|
||||||
|
const buildEditorContextMenu = require('browser/lib/contextMenuBuilder')
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
menuBuilderParameter = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should make sure that no context menu is build if the passed editor instance was null', function () {
|
||||||
|
const event = {
|
||||||
|
pageX: 12,
|
||||||
|
pageY: 12
|
||||||
|
}
|
||||||
|
buildEditorContextMenu(null, event)
|
||||||
|
expect(menuBuilderParameter).toEqual(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should make sure that word suggestions are only requested if the word contained a typo', function () {
|
||||||
|
spellcheck.getSpellingSuggestion = jest.fn()
|
||||||
|
const editor = jest.fn()
|
||||||
|
editor.coordsChar = jest.fn()
|
||||||
|
editor.findWordAt = jest.fn(() => { return {anchor: {}, head: {}} })
|
||||||
|
editor.getRange = jest.fn()
|
||||||
|
editor.findMarks = jest.fn(() => [])
|
||||||
|
const event = {
|
||||||
|
pageX: 12,
|
||||||
|
pageY: 12
|
||||||
|
}
|
||||||
|
const expectedMenuParameter = [ { role: 'cut' },
|
||||||
|
{ role: 'copy' },
|
||||||
|
{ role: 'paste' },
|
||||||
|
{ role: 'selectall' } ]
|
||||||
|
buildEditorContextMenu(editor, event)
|
||||||
|
expect(menuBuilderParameter).toEqual(expectedMenuParameter)
|
||||||
|
expect(spellcheck.getSpellingSuggestion).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should make sure that word suggestions are only requested if the word contained a typo and no other mark', function () {
|
||||||
|
spellcheck.getSpellingSuggestion = jest.fn()
|
||||||
|
spellcheck.getCSSClassName = jest.fn(() => 'dummyErrorClassName')
|
||||||
|
const editor = jest.fn()
|
||||||
|
editor.coordsChar = jest.fn()
|
||||||
|
editor.findWordAt = jest.fn(() => { return {anchor: {}, head: {}} })
|
||||||
|
editor.getRange = jest.fn()
|
||||||
|
const dummyMarks = [
|
||||||
|
{className: 'someStupidClassName'}
|
||||||
|
]
|
||||||
|
editor.findMarks = jest.fn(() => dummyMarks)
|
||||||
|
const event = {
|
||||||
|
pageX: 12,
|
||||||
|
pageY: 12
|
||||||
|
}
|
||||||
|
const expectedMenuParameter = [ { role: 'cut' },
|
||||||
|
{ role: 'copy' },
|
||||||
|
{ role: 'paste' },
|
||||||
|
{ role: 'selectall' } ]
|
||||||
|
buildEditorContextMenu(editor, event)
|
||||||
|
expect(menuBuilderParameter).toEqual(expectedMenuParameter)
|
||||||
|
expect(spellcheck.getSpellingSuggestion).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should make sure that word suggestions calls the right editor functions', function () {
|
||||||
|
spellcheck.getSpellingSuggestion = jest.fn()
|
||||||
|
spellcheck.getCSSClassName = jest.fn(() => 'dummyErrorClassName')
|
||||||
|
const dummyCursor = {dummy: 'dummy'}
|
||||||
|
const dummyRange = {anchor: {test: 'test'}, head: {test2: 'test2'}}
|
||||||
|
const editor = jest.fn()
|
||||||
|
editor.coordsChar = jest.fn(() => dummyCursor)
|
||||||
|
editor.findWordAt = jest.fn(() => dummyRange)
|
||||||
|
editor.getRange = jest.fn()
|
||||||
|
const dummyMarks = [
|
||||||
|
{className: 'someStupidClassName'}
|
||||||
|
]
|
||||||
|
editor.findMarks = jest.fn(() => dummyMarks)
|
||||||
|
const event = {
|
||||||
|
pageX: 12,
|
||||||
|
pageY: 21
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedCoordsCharCall = {left: event.pageX, top: event.pageY}
|
||||||
|
|
||||||
|
buildEditorContextMenu(editor, event)
|
||||||
|
|
||||||
|
expect(editor.coordsChar).toHaveBeenCalledWith(expectedCoordsCharCall)
|
||||||
|
expect(editor.findWordAt).toHaveBeenCalledWith(dummyCursor)
|
||||||
|
expect(editor.getRange).toHaveBeenCalledWith(dummyRange.anchor, dummyRange.head)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should make sure that word suggestions creates a correct menu if there was an error', function () {
|
||||||
|
const suggestions = ['test1', 'test2', 'Pustekuchen']
|
||||||
|
const errorClassName = 'errorCSS'
|
||||||
|
const wordToCorrect = 'pustekuchen'
|
||||||
|
const dummyMarks = [
|
||||||
|
{className: errorClassName}
|
||||||
|
]
|
||||||
|
spellcheck.getSpellingSuggestion = jest.fn(() => suggestions)
|
||||||
|
spellcheck.getCSSClassName = jest.fn(() => errorClassName)
|
||||||
|
const editor = jest.fn()
|
||||||
|
editor.coordsChar = jest.fn()
|
||||||
|
editor.findWordAt = jest.fn(() => { return {anchor: {}, head: {}} })
|
||||||
|
editor.getRange = jest.fn(() => wordToCorrect)
|
||||||
|
editor.findMarks = jest.fn(() => [])
|
||||||
|
|
||||||
|
editor.findMarks = jest.fn(() => dummyMarks)
|
||||||
|
const event = {
|
||||||
|
pageX: 12,
|
||||||
|
pageY: 12
|
||||||
|
}
|
||||||
|
buildEditorContextMenu(editor, event)
|
||||||
|
expect(menuBuilderParameter[0].label).toEqual(suggestions[0])
|
||||||
|
expect(menuBuilderParameter[0].click).not.toBeNull()
|
||||||
|
expect(menuBuilderParameter[1].label).toEqual(suggestions[1])
|
||||||
|
expect(menuBuilderParameter[1].click).not.toBeNull()
|
||||||
|
expect(menuBuilderParameter[2].label).toEqual(suggestions[2])
|
||||||
|
expect(menuBuilderParameter[2].click).not.toBeNull()
|
||||||
|
expect(menuBuilderParameter[3].type).toEqual('separator')
|
||||||
|
expect(menuBuilderParameter[4].role).toEqual('cut')
|
||||||
|
expect(menuBuilderParameter[5].role).toEqual('copy')
|
||||||
|
expect(menuBuilderParameter[6].role).toEqual('paste')
|
||||||
|
expect(menuBuilderParameter[7].role).toEqual('selectall')
|
||||||
|
expect(spellcheck.getSpellingSuggestion).toHaveBeenCalledWith(wordToCorrect)
|
||||||
|
})
|
||||||
@@ -42,7 +42,7 @@ it('should test that checkWord should marks words that contain a typo', function
|
|||||||
expect(editor.markText).toHaveBeenCalledWith(range.anchor, range.head, {'className': systemUnderTest.CSS_ERROR_CLASS})
|
expect(editor.markText).toHaveBeenCalledWith(range.anchor, range.head, {'className': systemUnderTest.CSS_ERROR_CLASS})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should test that initialize clears all marks', function () {
|
it('should test that setLanguage clears all marks', function () {
|
||||||
const dummyMarks = [
|
const dummyMarks = [
|
||||||
{clear: jest.fn()},
|
{clear: jest.fn()},
|
||||||
{clear: jest.fn()},
|
{clear: jest.fn()},
|
||||||
@@ -51,7 +51,7 @@ it('should test that initialize clears all marks', function () {
|
|||||||
const editor = jest.fn()
|
const editor = jest.fn()
|
||||||
editor.getAllMarks = jest.fn(() => dummyMarks)
|
editor.getAllMarks = jest.fn(() => dummyMarks)
|
||||||
|
|
||||||
systemUnderTest.initialize(editor, systemUnderTest.SPELLCHECK_DISABLED)
|
systemUnderTest.setLanguage(editor, systemUnderTest.SPELLCHECK_DISABLED)
|
||||||
|
|
||||||
expect(editor.getAllMarks).toHaveBeenCalled()
|
expect(editor.getAllMarks).toHaveBeenCalled()
|
||||||
for (const dummyMark of dummyMarks) {
|
for (const dummyMark of dummyMarks) {
|
||||||
@@ -59,26 +59,26 @@ it('should test that initialize clears all marks', function () {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should test that initialize with DISABLED as a lang argument should not load any dictionary and not check the whole document', function () {
|
it('should test that setLanguage with DISABLED as a lang argument should not load any dictionary and not check the whole document', function () {
|
||||||
const editor = jest.fn()
|
const editor = jest.fn()
|
||||||
editor.getAllMarks = jest.fn(() => [])
|
editor.getAllMarks = jest.fn(() => [])
|
||||||
const checkWholeDocumentSpy = jest.spyOn(systemUnderTest, 'checkWholeDocument').mockImplementation()
|
const checkWholeDocumentSpy = jest.spyOn(systemUnderTest, 'checkWholeDocument').mockImplementation()
|
||||||
|
|
||||||
systemUnderTest.initialize(editor, systemUnderTest.SPELLCHECK_DISABLED)
|
systemUnderTest.setLanguage(editor, systemUnderTest.SPELLCHECK_DISABLED)
|
||||||
|
|
||||||
expect(Typo).not.toHaveBeenCalled()
|
expect(Typo).not.toHaveBeenCalled()
|
||||||
expect(checkWholeDocumentSpy).not.toHaveBeenCalled()
|
expect(checkWholeDocumentSpy).not.toHaveBeenCalled()
|
||||||
checkWholeDocumentSpy.mockRestore()
|
checkWholeDocumentSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should test that initialize loads the correct dictionary', function () {
|
it('should test that setLanguage loads the correct dictionary', function () {
|
||||||
const editor = jest.fn()
|
const editor = jest.fn()
|
||||||
editor.getAllMarks = jest.fn(() => [])
|
editor.getAllMarks = jest.fn(() => [])
|
||||||
const lang = 'de_DE'
|
const lang = 'de_DE'
|
||||||
const checkWholeDocumentSpy = jest.spyOn(systemUnderTest, 'checkWholeDocument').mockImplementation()
|
const checkWholeDocumentSpy = jest.spyOn(systemUnderTest, 'checkWholeDocument').mockImplementation()
|
||||||
|
|
||||||
expect(Typo).not.toHaveBeenCalled()
|
expect(Typo).not.toHaveBeenCalled()
|
||||||
systemUnderTest.initialize(editor, lang)
|
systemUnderTest.setLanguage(editor, lang)
|
||||||
|
|
||||||
expect(Typo).toHaveBeenCalledWith(lang, false, false, expect.anything())
|
expect(Typo).toHaveBeenCalledWith(lang, false, false, expect.anything())
|
||||||
expect(Typo.mock.calls[0][3].dictionaryPath).toEqual(systemUnderTest.DICTIONARY_PATH)
|
expect(Typo.mock.calls[0][3].dictionaryPath).toEqual(systemUnderTest.DICTIONARY_PATH)
|
||||||
|
|||||||
Reference in New Issue
Block a user