diff --git a/browser/components/CodeEditor.js b/browser/components/CodeEditor.js index 7719ed90..35a71ec7 100644 --- a/browser/components/CodeEditor.js +++ b/browser/components/CodeEditor.js @@ -38,6 +38,7 @@ export default class CodeEditor extends React.Component { trailing: true }) this.changeHandler = (editor, changeObject) => this.handleChange(editor, changeObject) + this.highlightHandler = (editor, changeObject) => this.handleHighlight(editor, changeObject) this.focusHandler = () => { ipcRenderer.send('editor:focused', true) } @@ -235,6 +236,7 @@ export default class CodeEditor extends React.Component { this.editor = CodeMirror(this.refs.root, { rulers: buildCMRulers(rulers, enableRulers), value: this.props.value, + linesHighlighted: this.props.linesHighlighted, lineNumbers: this.props.displayLineNumbers, lineWrapping: true, theme: this.props.theme, @@ -261,6 +263,7 @@ export default class CodeEditor extends React.Component { this.editor.on('focus', this.focusHandler) this.editor.on('blur', this.blurHandler) this.editor.on('change', this.changeHandler) + this.editor.on('gutterClick', this.highlightHandler) this.editor.on('paste', this.pasteHandler) if (this.props.switchPreview !== 'RIGHTCLICK') { this.editor.on('contextmenu', this.contextMenuHandler) @@ -339,6 +342,8 @@ export default class CodeEditor extends React.Component { this.setState({ clientWidth: this.refs.root.clientWidth }) + + this.initialHighlighting() } expandSnippet (line, cursor, cm, snippets) { @@ -537,12 +542,96 @@ export default class CodeEditor extends React.Component { handleChange (editor, changeObject) { spellcheck.handleChange(editor, changeObject) + + this.updateHighlight(editor, changeObject) + this.value = editor.getValue() if (this.props.onChange) { this.props.onChange(editor) } } + incrementLines (start, linesAdded, linesRemoved, editor) { + let highlightedLines = editor.options.linesHighlighted + + const totalHighlightedLines = highlightedLines.length + + let offset = linesAdded - linesRemoved + + // Store new items to be added as we're changing the lines + let newLines = [] + + let i = totalHighlightedLines + + while (i--) { + const lineNumber = highlightedLines[i] + + // Interval that will need to be updated + // Between start and (start + offset) remove highlight + if (lineNumber >= start) { + highlightedLines.splice(highlightedLines.indexOf(lineNumber), 1) + + // Lines that need to be relocated + if (lineNumber >= (start + linesRemoved)) { + newLines.push(lineNumber + offset) + } + } + } + + // Adding relocated lines + highlightedLines.push(...newLines) + + if (this.props.onChange) { + this.props.onChange(editor) + } + } + + handleHighlight (editor, changeObject) { + const lines = editor.options.linesHighlighted + + if (!lines.includes(changeObject)) { + lines.push(changeObject) + editor.addLineClass(changeObject, 'text', 'CodeMirror-activeline-background') + } else { + lines.splice(lines.indexOf(changeObject), 1) + editor.removeLineClass(changeObject, 'text', 'CodeMirror-activeline-background') + } + if (this.props.onChange) { + this.props.onChange(editor) + } + } + + updateHighlight (editor, changeObject) { + const linesAdded = changeObject.text.length - 1 + const linesRemoved = changeObject.removed.length - 1 + + // If no lines added or removed return + if (linesAdded === 0 && linesRemoved === 0) { + return + } + + let start = changeObject.from.line + + switch (changeObject.origin) { + case '+insert", "undo': + start += 1 + break + + case 'paste': + case '+delete': + case '+input': + if (changeObject.to.ch !== 0 || changeObject.from.ch !== 0) { + start += 1 + } + break + + default: + return + } + + this.incrementLines(start, linesAdded, linesRemoved, editor) + } + moveCursorTo (row, col) {} scrollToLine (event, num) { @@ -567,6 +656,7 @@ export default class CodeEditor extends React.Component { this.value = this.props.value this.editor.setValue(this.props.value) this.editor.clearHistory() + this.restartHighlighting() this.editor.on('change', this.changeHandler) this.editor.refresh() } @@ -758,6 +848,29 @@ export default class CodeEditor extends React.Component { }) } + initialHighlighting () { + if (this.editor.options.linesHighlighted == null) { + return + } + + const totalHighlightedLines = this.editor.options.linesHighlighted.length + const totalAvailableLines = this.editor.lineCount() + + for (let i = 0; i < totalHighlightedLines; i++) { + const lineNumber = this.editor.options.linesHighlighted[i] + if (lineNumber > totalAvailableLines) { + // make sure that we skip the invalid lines althrough this case should not be happened. + continue + } + this.editor.addLineClass(lineNumber, 'text', 'CodeMirror-activeline-background') + } + } + + restartHighlighting () { + this.editor.options.linesHighlighted = this.props.linesHighlighted + this.initialHighlighting() + } + mapImageResponse (response, pastedTxt) { return new Promise((resolve, reject) => { try { diff --git a/browser/components/MarkdownEditor.js b/browser/components/MarkdownEditor.js index d3270c18..00a93b21 100644 --- a/browser/components/MarkdownEditor.js +++ b/browser/components/MarkdownEditor.js @@ -232,7 +232,7 @@ class MarkdownEditor extends React.Component { } render () { - const {className, value, config, storageKey, noteKey} = this.props + const {className, value, config, storageKey, noteKey, linesHighlighted} = this.props let editorFontSize = parseInt(config.editor.fontSize, 10) if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 @@ -275,6 +275,7 @@ class MarkdownEditor extends React.Component { noteKey={noteKey} fetchUrlTitle={config.editor.fetchUrlTitle} enableTableEditor={config.editor.enableTableEditor} + linesHighlighted={linesHighlighted} onChange={(e) => this.handleChange(e)} onBlur={(e) => this.handleBlur(e)} spellCheck={config.editor.spellcheck} diff --git a/browser/components/MarkdownSplitEditor.js b/browser/components/MarkdownSplitEditor.js index bd79bc24..f8f8b366 100644 --- a/browser/components/MarkdownSplitEditor.js +++ b/browser/components/MarkdownSplitEditor.js @@ -24,9 +24,9 @@ class MarkdownSplitEditor extends React.Component { this.refs.code.setValue(value) } - handleOnChange () { + handleOnChange (e) { this.value = this.refs.code.value - this.props.onChange() + this.props.onChange(e) } handleScroll (e) { @@ -136,7 +136,7 @@ class MarkdownSplitEditor extends React.Component { } render () { - const {config, value, storageKey, noteKey} = this.props + const {config, value, storageKey, noteKey, linesHighlighted} = this.props const storage = findStorage(storageKey) let editorFontSize = parseInt(config.editor.fontSize, 10) if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 @@ -169,7 +169,8 @@ class MarkdownSplitEditor extends React.Component { enableTableEditor={config.editor.enableTableEditor} storageKey={storageKey} noteKey={noteKey} - onChange={this.handleOnChange.bind(this)} + linesHighlighted={linesHighlighted} + onChange={(e) => this.handleOnChange(e)} onScroll={this.handleScroll.bind(this)} spellCheck={config.editor.spellcheck} enableSmartPaste={config.editor.enableSmartPaste} diff --git a/browser/lib/newNote.js b/browser/lib/newNote.js index 0b64d0e1..9511f847 100644 --- a/browser/lib/newNote.js +++ b/browser/lib/newNote.js @@ -18,7 +18,8 @@ export function createMarkdownNote (storage, folder, dispatch, location, params, folder: folder, title: '', tags, - content: '' + content: '', + linesHighlighted: [] }) .then(note => { const noteHash = note.key @@ -56,7 +57,8 @@ export function createSnippetNote (storage, folder, dispatch, location, params, { name: '', mode: config.editor.snippetDefaultLanguage || 'text', - content: '' + content: '', + linesHighlighted: [] } ] }) diff --git a/browser/main/Detail/MarkdownNoteDetail.js b/browser/main/Detail/MarkdownNoteDetail.js index b4e7a5b3..bc6cd499 100755 --- a/browser/main/Detail/MarkdownNoteDetail.js +++ b/browser/main/Detail/MarkdownNoteDetail.js @@ -39,12 +39,14 @@ class MarkdownNoteDetail extends React.Component { isMovingNote: false, note: Object.assign({ title: '', - content: '' + content: '', + linesHighlighted: [] }, props.note), isLockButtonShown: false, isLocked: false, editorType: props.config.editor.type } + this.dispatchTimer = null this.toggleLockButton = this.handleToggleLockButton.bind(this) @@ -71,7 +73,7 @@ class MarkdownNoteDetail extends React.Component { if (!this.state.isMovingNote && (isNewNote || hasDeletedTags)) { if (this.saveQueue != null) this.saveNow() this.setState({ - note: Object.assign({}, nextProps.note) + note: Object.assign({linesHighlighted: []}, nextProps.note) }, () => { this.refs.content.reload() if (this.refs.tags) this.refs.tags.reset() @@ -361,6 +363,7 @@ class MarkdownNoteDetail extends React.Component { value={note.content} storageKey={note.storage} noteKey={note.key} + linesHighlighted={note.linesHighlighted} onChange={this.handleUpdateContent.bind(this)} ignorePreviewPointerEvents={ignorePreviewPointerEvents} /> @@ -371,6 +374,7 @@ class MarkdownNoteDetail extends React.Component { value={note.content} storageKey={note.storage} noteKey={note.key} + linesHighlighted={note.linesHighlighted} onChange={this.handleUpdateContent.bind(this)} ignorePreviewPointerEvents={ignorePreviewPointerEvents} /> diff --git a/browser/main/Detail/SnippetNoteDetail.js b/browser/main/Detail/SnippetNoteDetail.js index 887e5237..ebe61ba9 100644 --- a/browser/main/Detail/SnippetNoteDetail.js +++ b/browser/main/Detail/SnippetNoteDetail.js @@ -48,7 +48,7 @@ class SnippetNoteDetail extends React.Component { note: Object.assign({ description: '' }, props.note, { - snippets: props.note.snippets.map((snippet) => Object.assign({}, snippet)) + snippets: props.note.snippets.map((snippet) => Object.assign({linesHighlighted: []}, snippet)) }) } @@ -76,8 +76,9 @@ class SnippetNoteDetail extends React.Component { const nextNote = Object.assign({ description: '' }, nextProps.note, { - snippets: nextProps.note.snippets.map((snippet) => Object.assign({}, snippet)) + snippets: nextProps.note.snippets.map((snippet) => Object.assign({linesHighlighted: []}, snippet)) }) + this.setState({ snippetIndex: 0, note: nextNote @@ -410,6 +411,8 @@ class SnippetNoteDetail extends React.Component { return (e) => { const snippets = this.state.note.snippets.slice() snippets[index].content = this.refs['code-' + index].value + snippets[index].linesHighlighted = e.options.linesHighlighted + this.setState(state => ({note: Object.assign(state.note, {snippets: snippets})})) this.setState(state => ({ note: state.note @@ -602,7 +605,8 @@ class SnippetNoteDetail extends React.Component { note.snippets = note.snippets.concat([{ name: '', mode: config.editor.snippetDefaultLanguage || 'text', - content: '' + content: '', + linesHighlighted: [] }]) const snippetIndex = note.snippets.length - 1 @@ -692,10 +696,8 @@ class SnippetNoteDetail extends React.Component { const viewList = note.snippets.map((snippet, index) => { const isActive = this.state.snippetIndex === index - let syntax = CodeMirror.findModeByName(convertModeName(snippet.mode)) if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') - return
this.handleCodeChange(index)(e)} ref={'code-' + index} ignorePreviewPointerEvents={this.props.ignorePreviewPointerEvents} @@ -712,6 +715,7 @@ class SnippetNoteDetail extends React.Component { : \n\n

Enjoy Boostnote!

\n\n" + content: "\n\n

Enjoy Boostnote!

\n\n", + linesHighlighted: [] }, { name: 'example.js', mode: 'javascript', - content: "var boostnote = document.getElementById('enjoy').innerHTML\n\nconsole.log(boostnote)" + content: "var boostnote = document.getElementById('enjoy').innerHTML\n\nconsole.log(boostnote)", + linesHighlighted: [] } ] }) diff --git a/browser/main/NoteList/index.js b/browser/main/NoteList/index.js index d1c8d14a..81474c1e 100644 --- a/browser/main/NoteList/index.js +++ b/browser/main/NoteList/index.js @@ -711,7 +711,8 @@ class NoteList extends React.Component { type: firstNote.type, folder: folder.key, title: firstNote.title + ' ' + i18n.__('copy'), - content: firstNote.content + content: firstNote.content, + linesHighlighted: firstNote.linesHighlighted }) .then((note) => { attachmentManagement.cloneAttachments(firstNote, note) diff --git a/browser/main/lib/dataApi/createNote.js b/browser/main/lib/dataApi/createNote.js index e5d44489..5bfa2457 100644 --- a/browser/main/lib/dataApi/createNote.js +++ b/browser/main/lib/dataApi/createNote.js @@ -16,6 +16,7 @@ function validateInput (input) { switch (input.type) { case 'MARKDOWN_NOTE': if (!_.isString(input.content)) input.content = '' + if (!_.isArray(input.linesHighlighted)) input.linesHighlighted = [] break case 'SNIPPET_NOTE': if (!_.isString(input.description)) input.description = '' @@ -23,7 +24,8 @@ function validateInput (input) { input.snippets = [{ name: '', mode: 'text', - content: '' + content: '', + linesHighlighted: [] }] } break diff --git a/browser/main/lib/dataApi/createSnippet.js b/browser/main/lib/dataApi/createSnippet.js index 5d189217..2e585c9f 100644 --- a/browser/main/lib/dataApi/createSnippet.js +++ b/browser/main/lib/dataApi/createSnippet.js @@ -9,7 +9,8 @@ function createSnippet (snippetFile) { id: crypto.randomBytes(16).toString('hex'), name: 'Unnamed snippet', prefix: [], - content: '' + content: '', + linesHighlighted: [] } fetchSnippet(null, snippetFile).then((snippets) => { snippets.push(newSnippet) diff --git a/browser/main/lib/dataApi/migrateFromV5Storage.js b/browser/main/lib/dataApi/migrateFromV5Storage.js index b11e66e9..78d78746 100644 --- a/browser/main/lib/dataApi/migrateFromV5Storage.js +++ b/browser/main/lib/dataApi/migrateFromV5Storage.js @@ -69,7 +69,8 @@ function importAll (storage, data) { isStarred: false, title: article.title, content: '# ' + article.title + '\n\n' + article.content, - key: noteKey + key: noteKey, + linesHighlighted: article.linesHighlighted } notes.push(newNote) } else { @@ -87,7 +88,8 @@ function importAll (storage, data) { snippets: [{ name: article.mode, mode: article.mode, - content: article.content + content: article.content, + linesHighlighted: article.linesHighlighted }] } notes.push(newNote) diff --git a/browser/main/lib/dataApi/updateNote.js b/browser/main/lib/dataApi/updateNote.js index 147fbc06..ce9fabcf 100644 --- a/browser/main/lib/dataApi/updateNote.js +++ b/browser/main/lib/dataApi/updateNote.js @@ -39,6 +39,9 @@ function validateInput (input) { if (input.content != null) { if (!_.isString(input.content)) validatedInput.content = '' else validatedInput.content = input.content + + if (!_.isArray(input.linesHighlighted)) validatedInput.linesHighlighted = [] + else validatedInput.linesHighlighted = input.linesHighlighted } return validatedInput case 'SNIPPET_NOTE': @@ -51,7 +54,8 @@ function validateInput (input) { validatedInput.snippets = [{ name: '', mode: 'text', - content: '' + content: '', + linesHighlighted: [] }] } else { validatedInput.snippets = input.snippets @@ -96,12 +100,14 @@ function updateNote (storageKey, noteKey, input) { snippets: [{ name: '', mode: 'text', - content: '' + content: '', + linesHighlighted: [] }] } : { type: 'MARKDOWN_NOTE', - content: '' + content: '', + linesHighlighted: [] } noteData.title = '' if (storage.folders.length === 0) throw new Error('Failed to restore note: No folder exists.') diff --git a/browser/main/lib/dataApi/updateSnippet.js b/browser/main/lib/dataApi/updateSnippet.js index f2310b8e..f132d83f 100644 --- a/browser/main/lib/dataApi/updateSnippet.js +++ b/browser/main/lib/dataApi/updateSnippet.js @@ -12,7 +12,8 @@ function updateSnippet (snippet, snippetFile) { if ( currentSnippet.name === snippet.name && currentSnippet.prefix === snippet.prefix && - currentSnippet.content === snippet.content + currentSnippet.content === snippet.content && + currentSnippet.linesHighlighted === snippet.linesHighlighted ) { // if everything is the same then don't write to disk resolve(snippets) @@ -20,6 +21,7 @@ function updateSnippet (snippet, snippetFile) { currentSnippet.name = snippet.name currentSnippet.prefix = snippet.prefix currentSnippet.content = snippet.content + currentSnippet.linesHighlighted = (snippet.linesHighlighted) fs.writeFile(snippetFile || consts.SNIPPET_FILE, JSON.stringify(snippets, null, 4), (err) => { if (err) reject(err) resolve(snippets) diff --git a/tests/dataApi/createNote-test.js b/tests/dataApi/createNote-test.js index 47446aab..3606dfd4 100644 --- a/tests/dataApi/createNote-test.js +++ b/tests/dataApi/createNote-test.js @@ -25,13 +25,16 @@ test.serial('Create a note', (t) => { const storageKey = t.context.storage.cache.key const folderKey = t.context.storage.json.folders[0].key + const randLinesHighlightedArray = new Array(10).fill().map(() => Math.round(Math.random() * 10)) + const input1 = { type: 'SNIPPET_NOTE', description: faker.lorem.lines(), snippets: [{ name: faker.system.fileName(), mode: 'text', - content: faker.lorem.lines() + content: faker.lorem.lines(), + linesHighlighted: randLinesHighlightedArray }], tags: faker.lorem.words().split(' '), folder: folderKey @@ -42,7 +45,8 @@ test.serial('Create a note', (t) => { type: 'MARKDOWN_NOTE', content: faker.lorem.lines(), tags: faker.lorem.words().split(' '), - folder: folderKey + folder: folderKey, + linesHighlighted: randLinesHighlightedArray } input2.title = input2.content.split('\n').shift() @@ -59,6 +63,7 @@ test.serial('Create a note', (t) => { t.is(storageKey, data1.storage) const jsonData1 = CSON.readFileSync(path.join(storagePath, 'notes', data1.key + '.cson')) + t.is(input1.title, data1.title) t.is(input1.title, jsonData1.title) t.is(input1.description, data1.description) @@ -71,6 +76,8 @@ test.serial('Create a note', (t) => { t.is(input1.snippets[0].content, jsonData1.snippets[0].content) t.is(input1.snippets[0].name, data1.snippets[0].name) t.is(input1.snippets[0].name, jsonData1.snippets[0].name) + t.deepEqual(input1.snippets[0].linesHighlighted, data1.snippets[0].linesHighlighted) + t.deepEqual(input1.snippets[0].linesHighlighted, jsonData1.snippets[0].linesHighlighted) t.is(storageKey, data2.storage) const jsonData2 = CSON.readFileSync(path.join(storagePath, 'notes', data2.key + '.cson')) @@ -80,6 +87,8 @@ test.serial('Create a note', (t) => { t.is(input2.content, jsonData2.content) t.is(input2.tags.length, data2.tags.length) t.is(input2.tags.length, jsonData2.tags.length) + t.deepEqual(input2.linesHighlighted, data2.linesHighlighted) + t.deepEqual(input2.linesHighlighted, jsonData2.linesHighlighted) }) }) diff --git a/tests/dataApi/createSnippet-test.js b/tests/dataApi/createSnippet-test.js index f06cf861..638b76ca 100644 --- a/tests/dataApi/createSnippet-test.js +++ b/tests/dataApi/createSnippet-test.js @@ -26,6 +26,7 @@ test.serial('Create a snippet', (t) => { t.is(snippet.name, data.name) t.deepEqual(snippet.prefix, data.prefix) t.is(snippet.content, data.content) + t.deepEqual(snippet.linesHighlighted, data.linesHighlighted) }) }) diff --git a/tests/dataApi/updateNote-test.js b/tests/dataApi/updateNote-test.js index 6043ee1e..da47c30c 100644 --- a/tests/dataApi/updateNote-test.js +++ b/tests/dataApi/updateNote-test.js @@ -26,13 +26,17 @@ test.serial('Update a note', (t) => { const storageKey = t.context.storage.cache.key const folderKey = t.context.storage.json.folders[0].key + const randLinesHighlightedArray = new Array(10).fill().map(() => Math.round(Math.random() * 10)) + const randLinesHighlightedArray2 = new Array(15).fill().map(() => Math.round(Math.random() * 15)) + const input1 = { type: 'SNIPPET_NOTE', description: faker.lorem.lines(), snippets: [{ name: faker.system.fileName(), mode: 'text', - content: faker.lorem.lines() + content: faker.lorem.lines(), + linesHighlighted: randLinesHighlightedArray }], tags: faker.lorem.words().split(' '), folder: folderKey @@ -43,7 +47,8 @@ test.serial('Update a note', (t) => { type: 'MARKDOWN_NOTE', content: faker.lorem.lines(), tags: faker.lorem.words().split(' '), - folder: folderKey + folder: folderKey, + linesHighlighted: randLinesHighlightedArray } input2.title = input2.content.split('\n').shift() @@ -53,7 +58,8 @@ test.serial('Update a note', (t) => { snippets: [{ name: faker.system.fileName(), mode: 'text', - content: faker.lorem.lines() + content: faker.lorem.lines(), + linesHighlighted: randLinesHighlightedArray2 }], tags: faker.lorem.words().split(' ') } @@ -62,7 +68,8 @@ test.serial('Update a note', (t) => { const input4 = { type: 'MARKDOWN_NOTE', content: faker.lorem.lines(), - tags: faker.lorem.words().split(' ') + tags: faker.lorem.words().split(' '), + linesHighlighted: randLinesHighlightedArray2 } input4.title = input4.content.split('\n').shift() @@ -99,6 +106,8 @@ test.serial('Update a note', (t) => { t.is(input3.snippets[0].content, jsonData1.snippets[0].content) t.is(input3.snippets[0].name, data1.snippets[0].name) t.is(input3.snippets[0].name, jsonData1.snippets[0].name) + t.deepEqual(input3.snippets[0].linesHighlighted, data1.snippets[0].linesHighlighted) + t.deepEqual(input3.snippets[0].linesHighlighted, jsonData1.snippets[0].linesHighlighted) const jsonData2 = CSON.readFileSync(path.join(storagePath, 'notes', data2.key + '.cson')) t.is(input4.title, data2.title) @@ -107,6 +116,8 @@ test.serial('Update a note', (t) => { t.is(input4.content, jsonData2.content) t.is(input4.tags.length, data2.tags.length) t.is(input4.tags.length, jsonData2.tags.length) + t.deepEqual(input4.linesHighlighted, data2.linesHighlighted) + t.deepEqual(input4.linesHighlighted, jsonData2.linesHighlighted) }) })