diff --git a/browser/components/CodeEditor.js b/browser/components/CodeEditor.js index 5b711de5..25ffcc06 100644 --- a/browser/components/CodeEditor.js +++ b/browser/components/CodeEditor.js @@ -11,8 +11,9 @@ 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') const spellcheck = require('browser/lib/spellcheck') +const buildEditorContextMenu = require('browser/lib/contextMenuBuilder') 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.searchState = null + + this.contextMenuHandler = function (editor, event) { + const menu = buildEditorContextMenu(editor, event) + if (menu != null) { + setTimeout(() => menu.popup(remote.getCurrentWindow()), 30) + } + } } handleSearch (msg) { @@ -170,6 +178,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') @@ -267,8 +276,10 @@ 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) } componentDidUpdate (prevProps, prevState) { @@ -530,7 +541,7 @@ export default class CodeEditor extends React.Component { const dropdown = document.createElement('select') dropdown.title = 'Spellcheck' 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() for (const op of options) { const option = document.createElement('option') diff --git a/browser/lib/contextMenuBuilder.js b/browser/lib/contextMenuBuilder.js new file mode 100644 index 00000000..5a4e5add --- /dev/null +++ b/browser/lib/contextMenuBuilder.js @@ -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 diff --git a/browser/lib/spellcheck.js b/browser/lib/spellcheck.js index 67ef69bb..e454d5f6 100644 --- a/browser/lib/spellcheck.js +++ b/browser/lib/spellcheck.js @@ -30,11 +30,13 @@ function setDictionaryForTestsOnly (newDictionary) { * @param {Codemirror} editor CodeMirror-Editor * @param {String} lang on of the values from getAvailableDictionaries()-Method */ -function initialize (editor, lang) { +function setLanguage (editor, lang) { dictionary = null - const existingMarks = editor.getAllMarks() || [] - for (const mark of existingMarks) { - mark.clear() + if (editor != null) { + const existingMarks = editor.getAllMarks() || [] + for (const mark of existingMarks) { + mark.clear() + } } if (lang !== SPELLCHECK_DISABLED) { 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 = { DICTIONARY_PATH, CSS_ERROR_CLASS, SPELLCHECK_DISABLED, getAvailableDictionaries, - initialize, + setLanguage, liveSpellcheck, + getSpellingSuggestion, checkWord, checkMultiLineRange, checkWholeDocument, - setDictionaryForTestsOnly + setDictionaryForTestsOnly, + getCSSClassName } diff --git a/browser/main/lib/dataApi/attachmentManagement.js b/browser/main/lib/dataApi/attachmentManagement.js index aec59927..f6c26a0d 100644 --- a/browser/main/lib/dataApi/attachmentManagement.js +++ b/browser/main/lib/dataApi/attachmentManagement.js @@ -81,6 +81,9 @@ function createAttachmentDestinationFolder (destinationStoragePath, noteKey) { * @param noteKey Key of the current note */ function migrateAttachments (renderedHTML, storagePath, noteKey) { + if (renderedHTML == null || storagePath == null || noteKey == null) { + return + } if (sander.existsSync(path.join(storagePath, 'images'))) { const attachments = getAttachmentsInContent(renderedHTML) || [] if (attachments !== []) { diff --git a/tests/lib/contextMenuBuilder.test.js b/tests/lib/contextMenuBuilder.test.js new file mode 100644 index 00000000..12ed2c32 --- /dev/null +++ b/tests/lib/contextMenuBuilder.test.js @@ -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) +}) diff --git a/tests/lib/spellcheck.test.js b/tests/lib/spellcheck.test.js index 4a8068eb..a314a2e0 100644 --- a/tests/lib/spellcheck.test.js +++ b/tests/lib/spellcheck.test.js @@ -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}) }) -it('should test that initialize clears all marks', function () { +it('should test that setLanguage clears all marks', function () { const dummyMarks = [ {clear: jest.fn()}, {clear: jest.fn()}, @@ -51,7 +51,7 @@ it('should test that initialize clears all marks', function () { const editor = jest.fn() editor.getAllMarks = jest.fn(() => dummyMarks) - systemUnderTest.initialize(editor, systemUnderTest.SPELLCHECK_DISABLED) + systemUnderTest.setLanguage(editor, systemUnderTest.SPELLCHECK_DISABLED) expect(editor.getAllMarks).toHaveBeenCalled() 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() editor.getAllMarks = jest.fn(() => []) const checkWholeDocumentSpy = jest.spyOn(systemUnderTest, 'checkWholeDocument').mockImplementation() - systemUnderTest.initialize(editor, systemUnderTest.SPELLCHECK_DISABLED) + systemUnderTest.setLanguage(editor, systemUnderTest.SPELLCHECK_DISABLED) expect(Typo).not.toHaveBeenCalled() expect(checkWholeDocumentSpy).not.toHaveBeenCalled() 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() editor.getAllMarks = jest.fn(() => []) const lang = 'de_DE' const checkWholeDocumentSpy = jest.spyOn(systemUnderTest, 'checkWholeDocument').mockImplementation() expect(Typo).not.toHaveBeenCalled() - systemUnderTest.initialize(editor, lang) + systemUnderTest.setLanguage(editor, lang) expect(Typo).toHaveBeenCalledWith(lang, false, false, expect.anything()) expect(Typo.mock.calls[0][3].dictionaryPath).toEqual(systemUnderTest.DICTIONARY_PATH)