import PropTypes from 'prop-types' import React from 'react' import _ from 'lodash' import CodeMirror from 'codemirror' import hljs from 'highlight.js' import 'codemirror-mode-elixir' import attachmentManagement from 'browser/main/lib/dataApi/attachmentManagement' import convertModeName from 'browser/lib/convertModeName' import { options, TableEditor, Alignment } from '@susisu/mte-kernel' import TextEditorInterface from 'browser/lib/TextEditorInterface' import eventEmitter from 'browser/main/lib/eventEmitter' import iconv from 'iconv-lite' import { isMarkdownTitleURL } from 'browser/lib/utils' import styles from '../components/CodeEditor.styl' const { ipcRenderer, remote, clipboard } = require('electron') import normalizeEditorFontFamily from 'browser/lib/normalizeEditorFontFamily' const spellcheck = require('browser/lib/spellcheck') const buildEditorContextMenu = require('browser/lib/contextMenuBuilder') .buildEditorContextMenu import { createTurndownService } from '../lib/turndown' import { languageMaps } from '../lib/CMLanguageList' import snippetManager from '../lib/SnippetManager' import { generateInEditor, tocExistsInEditor } from 'browser/lib/markdown-toc-generator' import markdownlint from 'markdownlint' import Jsonlint from 'jsonlint-mod' import { DEFAULT_CONFIG } from '../main/lib/ConfigManager' import prettier from 'prettier' CodeMirror.modeURL = '../node_modules/codemirror/mode/%N/%N.js' const buildCMRulers = (rulers, enableRulers) => enableRulers ? rulers.map(ruler => ({ column: ruler })) : [] function translateHotkey(hotkey) { return hotkey .replace(/\s*\+\s*/g, '-') .replace(/Command/g, 'Cmd') .replace(/Control/g, 'Ctrl') } export default class CodeEditor extends React.Component { constructor(props) { super(props) this.scrollHandler = _.debounce(this.handleScroll.bind(this), 100, { leading: false, 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) } const debouncedDeletionOfAttachments = _.debounce( attachmentManagement.deleteAttachmentsNotPresentInNote, 30000 ) this.blurHandler = (editor, e) => { ipcRenderer.send('editor:focused', false) if (e == null) return null let el = e.relatedTarget while (el != null) { if (el === this.refs.root) { return } el = el.parentNode } this.props.onBlur != null && this.props.onBlur(e) const { storageKey, noteKey } = this.props if (this.props.deleteUnusedAttachments === true) { debouncedDeletionOfAttachments( this.editor.getValue(), storageKey, noteKey ) } } this.pasteHandler = (editor, e) => { e.preventDefault() this.handlePaste(editor, false) } this.loadStyleHandler = e => { this.editor.refresh() } this.searchHandler = (e, msg) => this.handleSearch(msg) this.searchState = null this.scrollToLineHandeler = this.scrollToLine.bind(this) this.getCodeEditorLintConfig = this.getCodeEditorLintConfig.bind(this) this.validatorOfMarkdown = this.validatorOfMarkdown.bind(this) this.formatTable = () => this.handleFormatTable() if (props.switchPreview !== 'RIGHTCLICK') { this.contextMenuHandler = function(editor, event) { const menu = buildEditorContextMenu(editor, event) if (menu != null) { setTimeout(() => menu.popup(remote.getCurrentWindow()), 30) } } } this.editorActivityHandler = () => this.handleEditorActivity() this.turndownService = createTurndownService() } handleSearch(msg) { const cm = this.editor const component = this if (component.searchState) cm.removeOverlay(component.searchState) if (msg.length < 1) return cm.operation(function() { component.searchState = makeOverlay(msg, 'searching') cm.addOverlay(component.searchState) function makeOverlay(query, style) { query = new RegExp( query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'), 'gi' ) return { token: function(stream) { query.lastIndex = stream.pos var match = query.exec(stream.string) if (match && match.index === stream.pos) { stream.pos += match[0].length || 1 return style } else if (match) { stream.pos = match.index } else { stream.skipToEnd() } } } } }) } handleFormatTable() { this.tableEditor.formatAll( options({ textWidthOptions: {} }) ) } handleEditorActivity() { if (!this.textEditorInterface.transaction) { this.updateTableEditorState() } } updateDefaultKeyMap() { const { hotkey } = this.props const self = this const expandSnippet = snippetManager.expandSnippet this.defaultKeyMap = CodeMirror.normalizeKeyMap({ Tab: function(cm) { const cursor = cm.getCursor() const line = cm.getLine(cursor.line) const cursorPosition = cursor.ch const charBeforeCursor = line.substr(cursorPosition - 1, 1) if (cm.somethingSelected()) cm.indentSelection('add') else { const tabs = cm.getOption('indentWithTabs') if (line.trimLeft().match(/^(-|\*|\+) (\[( |x)] )?$/)) { cm.execCommand('goLineStart') if (tabs) { cm.execCommand('insertTab') } else { cm.execCommand('insertSoftTab') } cm.execCommand('goLineEnd') } else if ( !charBeforeCursor.match(/\t|\s|\r|\n|\$/) && cursor.ch > 1 ) { // text expansion on tab key if the char before is alphabet const wordBeforeCursor = self.getWordBeforeCursor( line, cursor.line, cursor.ch ) if (expandSnippet(wordBeforeCursor, cursor, cm) === false) { if (tabs) { cm.execCommand('insertTab') } else { cm.execCommand('insertSoftTab') } } } else { if (tabs) { cm.execCommand('insertTab') } else { cm.execCommand('insertSoftTab') } } } }, 'Cmd-Left': function(cm) { cm.execCommand('goLineLeft') }, 'Cmd-T': function(cm) { // Do nothing }, [translateHotkey(hotkey.insertDate)]: function(cm) { const dateNow = new Date() cm.replaceSelection(dateNow.toLocaleDateString()) }, [translateHotkey(hotkey.insertDateTime)]: function(cm) { const dateNow = new Date() cm.replaceSelection(dateNow.toLocaleString()) }, Enter: 'boostNewLineAndIndentContinueMarkdownList', 'Ctrl-C': cm => { if (cm.getOption('keyMap').substr(0, 3) === 'vim') { document.execCommand('copy') } return CodeMirror.Pass }, [translateHotkey(hotkey.prettifyMarkdown)]: cm => { // Default / User configured prettier options const currentConfig = JSON.parse(self.props.prettierConfig) // Parser type will always need to be markdown so we override the option before use currentConfig.parser = 'markdown' // Get current cursor position const cursorPos = cm.getCursor() currentConfig.cursorOffset = cm.doc.indexFromPos(cursorPos) // Prettify contents of editor const formattedTextDetails = prettier.formatWithCursor( cm.doc.getValue(), currentConfig ) const formattedText = formattedTextDetails.formatted const formattedCursorPos = formattedTextDetails.cursorOffset cm.doc.setValue(formattedText) // Reset Cursor position to be at the same markdown as was before prettifying const newCursorPos = cm.doc.posFromIndex(formattedCursorPos) cm.doc.setCursor(newCursorPos) }, [translateHotkey(hotkey.sortLines)]: cm => { const selection = cm.doc.getSelection() const appendLineBreak = /\n$/.test(selection) const sorted = _.split(selection.trim(), '\n').sort() const sortedString = _.join(sorted, '\n') + (appendLineBreak ? '\n' : '') cm.doc.replaceSelection(sortedString) }, [translateHotkey(hotkey.pasteSmartly)]: cm => { this.handlePaste(cm, true) } }) } updateTableEditorState() { const active = this.tableEditor.cursorIsInTable(this.tableEditorOptions) if (active) { if (this.extraKeysMode !== 'editor') { this.extraKeysMode = 'editor' this.editor.setOption('extraKeys', this.editorKeyMap) } } else { if (this.extraKeysMode !== 'default') { this.extraKeysMode = 'default' this.editor.setOption('extraKeys', this.defaultKeyMap) this.tableEditor.resetSmartCursor() } } } componentDidMount() { const { rulers, enableRulers, enableMarkdownLint, RTL } = this.props eventEmitter.on('line:jump', this.scrollToLineHandeler) snippetManager.init() this.updateDefaultKeyMap() this.value = this.props.value this.editor = CodeMirror(this.refs.root, { rulers: buildCMRulers(rulers, enableRulers), value: this.props.value, linesHighlighted: this.props.linesHighlighted, lineNumbers: this.props.displayLineNumbers, lineWrapping: this.props.lineWrapping, theme: this.props.theme, indentUnit: this.props.indentSize, tabSize: this.props.indentSize, indentWithTabs: this.props.indentType !== 'space', keyMap: this.props.keyMap, scrollPastEnd: this.props.scrollPastEnd, inputStyle: 'textarea', dragDrop: false, direction: RTL ? 'rtl' : 'ltr', rtlMoveVisually: RTL, foldGutter: true, lint: enableMarkdownLint ? this.getCodeEditorLintConfig() : false, gutters: [ 'CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers' ], autoCloseBrackets: { codeBlock: { pairs: this.props.codeBlockMatchingPairs, closeBefore: this.props.codeBlockMatchingCloseBefore, triples: this.props.codeBlockMatchingTriples, explode: this.props.codeBlockExplodingPairs }, markdown: { pairs: this.props.matchingPairs, closeBefore: this.props.matchingCloseBefore, triples: this.props.matchingTriples, explode: this.props.explodingPairs } }, extraKeys: this.defaultKeyMap, prettierConfig: this.props.prettierConfig }) document.querySelector( '.CodeMirror-lint-markers' ).style.display = enableMarkdownLint ? 'inline-block' : 'none' if (!this.props.mode && this.props.value && this.props.autoDetect) { this.autoDetectLanguage(this.props.value) } else { this.setMode(this.props.mode) } 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) } eventEmitter.on('top:search', this.searchHandler) eventEmitter.emit('code:init') this.editor.on('scroll', this.scrollHandler) const editorTheme = document.getElementById('editorTheme') editorTheme.addEventListener('load', this.loadStyleHandler) CodeMirror.Vim.defineEx('quit', 'q', this.quitEditor) CodeMirror.Vim.defineEx('q!', 'q!', this.quitEditor) CodeMirror.Vim.defineEx('wq', 'wq', this.quitEditor) CodeMirror.Vim.defineEx('qw', 'qw', this.quitEditor) CodeMirror.Vim.map('ZZ', ':q', 'normal') this.textEditorInterface = new TextEditorInterface(this.editor) this.tableEditor = new TableEditor(this.textEditorInterface) if (this.props.spellCheck) { this.editor.addPanel(this.createSpellCheckPanel(), { position: 'bottom' }) } eventEmitter.on('code:format-table', this.formatTable) this.tableEditorOptions = options({ smartCursor: true }) this.editorKeyMap = CodeMirror.normalizeKeyMap({ Tab: () => { this.tableEditor.nextCell(this.tableEditorOptions) }, 'Shift-Tab': () => { this.tableEditor.previousCell(this.tableEditorOptions) }, Enter: () => { this.tableEditor.nextRow(this.tableEditorOptions) }, 'Ctrl-Enter': () => { this.tableEditor.escape(this.tableEditorOptions) }, 'Cmd-Enter': () => { this.tableEditor.escape(this.tableEditorOptions) }, 'Shift-Ctrl-Left': () => { this.tableEditor.alignColumn(Alignment.LEFT, this.tableEditorOptions) }, 'Shift-Cmd-Left': () => { this.tableEditor.alignColumn(Alignment.LEFT, this.tableEditorOptions) }, 'Shift-Ctrl-Right': () => { this.tableEditor.alignColumn(Alignment.RIGHT, this.tableEditorOptions) }, 'Shift-Cmd-Right': () => { this.tableEditor.alignColumn(Alignment.RIGHT, this.tableEditorOptions) }, 'Shift-Ctrl-Up': () => { this.tableEditor.alignColumn(Alignment.CENTER, this.tableEditorOptions) }, 'Shift-Cmd-Up': () => { this.tableEditor.alignColumn(Alignment.CENTER, this.tableEditorOptions) }, 'Shift-Ctrl-Down': () => { this.tableEditor.alignColumn(Alignment.NONE, this.tableEditorOptions) }, 'Shift-Cmd-Down': () => { this.tableEditor.alignColumn(Alignment.NONE, this.tableEditorOptions) }, 'Ctrl-Left': () => { this.tableEditor.moveFocus(0, -1, this.tableEditorOptions) }, 'Cmd-Left': () => { this.tableEditor.moveFocus(0, -1, this.tableEditorOptions) }, 'Ctrl-Right': () => { this.tableEditor.moveFocus(0, 1, this.tableEditorOptions) }, 'Cmd-Right': () => { this.tableEditor.moveFocus(0, 1, this.tableEditorOptions) }, 'Ctrl-Up': () => { this.tableEditor.moveFocus(-1, 0, this.tableEditorOptions) }, 'Cmd-Up': () => { this.tableEditor.moveFocus(-1, 0, this.tableEditorOptions) }, 'Ctrl-Down': () => { this.tableEditor.moveFocus(1, 0, this.tableEditorOptions) }, 'Cmd-Down': () => { this.tableEditor.moveFocus(1, 0, this.tableEditorOptions) }, 'Ctrl-K Ctrl-I': () => { this.tableEditor.insertRow(this.tableEditorOptions) }, 'Cmd-K Cmd-I': () => { this.tableEditor.insertRow(this.tableEditorOptions) }, 'Ctrl-L Ctrl-I': () => { this.tableEditor.deleteRow(this.tableEditorOptions) }, 'Cmd-L Cmd-I': () => { this.tableEditor.deleteRow(this.tableEditorOptions) }, 'Ctrl-K Ctrl-J': () => { this.tableEditor.insertColumn(this.tableEditorOptions) }, 'Cmd-K Cmd-J': () => { this.tableEditor.insertColumn(this.tableEditorOptions) }, 'Ctrl-L Ctrl-J': () => { this.tableEditor.deleteColumn(this.tableEditorOptions) }, 'Cmd-L Cmd-J': () => { this.tableEditor.deleteColumn(this.tableEditorOptions) }, 'Alt-Shift-Ctrl-Left': () => { this.tableEditor.moveColumn(-1, this.tableEditorOptions) }, 'Alt-Shift-Cmd-Left': () => { this.tableEditor.moveColumn(-1, this.tableEditorOptions) }, 'Alt-Shift-Ctrl-Right': () => { this.tableEditor.moveColumn(1, this.tableEditorOptions) }, 'Alt-Shift-Cmd-Right': () => { this.tableEditor.moveColumn(1, this.tableEditorOptions) }, 'Alt-Shift-Ctrl-Up': () => { this.tableEditor.moveRow(-1, this.tableEditorOptions) }, 'Alt-Shift-Cmd-Up': () => { this.tableEditor.moveRow(-1, this.tableEditorOptions) }, 'Alt-Shift-Ctrl-Down': () => { this.tableEditor.moveRow(1, this.tableEditorOptions) }, 'Alt-Shift-Cmd-Down': () => { this.tableEditor.moveRow(1, this.tableEditorOptions) } }) if (this.props.enableTableEditor) { this.editor.on('cursorActivity', this.editorActivityHandler) this.editor.on('changes', this.editorActivityHandler) } this.setState({ clientWidth: this.refs.root.clientWidth }) this.initialHighlighting() } getWordBeforeCursor(line, lineNumber, cursorPosition) { let wordBeforeCursor = '' const originCursorPosition = cursorPosition const emptyChars = /\t|\s|\r|\n|\$/ // to prevent the word is long that will crash the whole app // the safeStop is there to stop user to expand words that longer than 20 chars const safeStop = 20 while (cursorPosition > 0) { const currentChar = line.substr(cursorPosition - 1, 1) // if char is not an empty char if (!emptyChars.test(currentChar)) { wordBeforeCursor = currentChar + wordBeforeCursor } else if (wordBeforeCursor.length >= safeStop) { throw new Error('Stopped after 20 loops for safety reason !') } else { break } cursorPosition-- } return { text: wordBeforeCursor, range: { from: { line: lineNumber, ch: originCursorPosition }, to: { line: lineNumber, ch: cursorPosition } } } } quitEditor() { document.querySelector('textarea').blur() } componentWillUnmount() { this.editor.off('focus', this.focusHandler) this.editor.off('blur', this.blurHandler) this.editor.off('change', this.changeHandler) 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) } componentDidUpdate(prevProps, prevState) { let needRefresh = false const { rulers, enableRulers, enableMarkdownLint, customMarkdownLintConfig } = this.props if (prevProps.mode !== this.props.mode) { this.setMode(this.props.mode) } if (prevProps.theme !== this.props.theme) { this.editor.setOption('theme', this.props.theme) // editor should be refreshed after css loaded } if (prevProps.fontSize !== this.props.fontSize) { needRefresh = true } if (prevProps.fontFamily !== this.props.fontFamily) { needRefresh = true } if (prevProps.keyMap !== this.props.keyMap) { needRefresh = true } if (prevProps.RTL !== this.props.RTL) { this.editor.setOption('direction', this.props.RTL ? 'rtl' : 'ltr') this.editor.setOption('rtlMoveVisually', this.props.RTL) } if ( prevProps.enableMarkdownLint !== enableMarkdownLint || prevProps.customMarkdownLintConfig !== customMarkdownLintConfig ) { if (!enableMarkdownLint) { this.editor.setOption('lint', { default: false }) document.querySelector('.CodeMirror-lint-markers').style.display = 'none' } else { this.editor.setOption('lint', this.getCodeEditorLintConfig()) document.querySelector('.CodeMirror-lint-markers').style.display = 'inline-block' } needRefresh = true } if ( prevProps.enableRulers !== enableRulers || prevProps.rulers !== rulers ) { this.editor.setOption('rulers', buildCMRulers(rulers, enableRulers)) } if (prevProps.indentSize !== this.props.indentSize) { this.editor.setOption('indentUnit', this.props.indentSize) this.editor.setOption('tabSize', this.props.indentSize) } if (prevProps.indentType !== this.props.indentType) { this.editor.setOption('indentWithTabs', this.props.indentType !== 'space') } if (prevProps.displayLineNumbers !== this.props.displayLineNumbers) { this.editor.setOption('lineNumbers', this.props.displayLineNumbers) } if (prevProps.lineWrapping !== this.props.lineWrapping) { this.editor.setOption('lineWrapping', this.props.lineWrapping) } if (prevProps.scrollPastEnd !== this.props.scrollPastEnd) { this.editor.setOption('scrollPastEnd', this.props.scrollPastEnd) } if ( prevProps.matchingPairs !== this.props.matchingPairs || prevProps.matchingCloseBefore !== this.props.matchingCloseBefore || prevProps.matchingTriples !== this.props.matchingTriples || prevProps.explodingPairs !== this.props.explodingPairs || prevProps.codeBlockMatchingPairs !== this.props.codeBlockMatchingPairs || prevProps.codeBlockMatchingCloseBefore !== this.props.codeBlockMatchingCloseBefore || prevProps.codeBlockMatchingTriples !== this.props.codeBlockMatchingTriples || prevProps.codeBlockExplodingPairs !== this.props.codeBlockExplodingPairs ) { const autoCloseBrackets = { codeBlock: { pairs: this.props.codeBlockMatchingPairs, closeBefore: this.props.codeBlockMatchingCloseBefore, triples: this.props.codeBlockMatchingTriples, explode: this.props.codeBlockExplodingPairs }, markdown: { pairs: this.props.matchingPairs, closeBefore: this.props.matchingCloseBefore, triples: this.props.matchingTriples, explode: this.props.explodingPairs } } this.editor.setOption('autoCloseBrackets', autoCloseBrackets) } if (prevProps.enableTableEditor !== this.props.enableTableEditor) { if (this.props.enableTableEditor) { this.editor.on('cursorActivity', this.editorActivityHandler) this.editor.on('changes', this.editorActivityHandler) } else { this.editor.off('cursorActivity', this.editorActivityHandler) this.editor.off('changes', this.editorActivityHandler) } this.extraKeysMode = 'default' this.editor.setOption('extraKeys', this.defaultKeyMap) } if (prevProps.hotkey !== this.props.hotkey) { this.updateDefaultKeyMap() if (this.extraKeysMode === 'default') { this.editor.setOption('extraKeys', this.defaultKeyMap) } } if (this.state.clientWidth !== this.refs.root.clientWidth) { this.setState({ clientWidth: this.refs.root.clientWidth }) needRefresh = true } if (prevProps.spellCheck !== this.props.spellCheck) { if (this.props.spellCheck === false) { spellcheck.setLanguage(this.editor, spellcheck.SPELLCHECK_DISABLED) const elem = document.getElementById('editor-bottom-panel') elem.parentNode.removeChild(elem) } else { this.editor.addPanel(this.createSpellCheckPanel(), { position: 'bottom' }) } } if ( prevProps.deleteUnusedAttachments !== this.props.deleteUnusedAttachments ) { this.editor.setOption( 'deleteUnusedAttachments', this.props.deleteUnusedAttachments ) } if (needRefresh) { this.editor.refresh() } } getCodeEditorLintConfig() { const { mode } = this.props const checkMarkdownNoteIsOpen = mode === 'Boost Flavored Markdown' return checkMarkdownNoteIsOpen ? { getAnnotations: this.validatorOfMarkdown, async: true } : false } validatorOfMarkdown(text, updateLinting) { const { customMarkdownLintConfig } = this.props let lintConfigJson try { Jsonlint.parse(customMarkdownLintConfig) lintConfigJson = JSON.parse(customMarkdownLintConfig) } catch (err) { eventEmitter.emit('APP_SETTING_ERROR') return } const lintOptions = { strings: { content: text }, config: lintConfigJson } return markdownlint(lintOptions, (err, result) => { if (!err) { const foundIssues = [] const splitText = text.split('\n') result.content.map(item => { let ruleNames = '' item.ruleNames.map((ruleName, index) => { ruleNames += ruleName ruleNames += index === item.ruleNames.length - 1 ? ': ' : '/' }) const lineNumber = item.lineNumber - 1 foundIssues.push({ from: CodeMirror.Pos(lineNumber, 0), to: CodeMirror.Pos(lineNumber, splitText[lineNumber].length), message: ruleNames + item.ruleDescription, severity: 'warning' }) }) updateLinting(foundIssues) } }) } setMode(mode) { let syntax = CodeMirror.findModeByName(convertModeName(mode || 'text')) if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') this.editor.setOption('mode', syntax.mime) CodeMirror.autoLoadMode(this.editor, syntax.mode) } handleChange(editor, changeObject) { spellcheck.handleChange(editor, changeObject) // The current note contains an toc. We'll check for changes on headlines. // origin is undefined when markdownTocGenerator replace the old tod if (tocExistsInEditor(editor) && changeObject.origin !== undefined) { let requireTocUpdate // Check if one of the changed lines contains a headline for (let line = 0; line < changeObject.text.length; line++) { if ( this.linePossibleContainsHeadline( editor.getLine(changeObject.from.line + line) ) ) { requireTocUpdate = true break } } if (!requireTocUpdate) { // Check if one of the removed lines contains a headline for (let line = 0; line < changeObject.removed.length; line++) { if (this.linePossibleContainsHeadline(changeObject.removed[line])) { requireTocUpdate = true break } } } if (requireTocUpdate) { generateInEditor(editor) } } this.updateHighlight(editor, changeObject) this.value = editor.getValue() if (this.props.onChange) { this.props.onChange(editor) } } linePossibleContainsHeadline(currentLine) { // We can't check if the line start with # because when some write text before // the # we also need to update the toc return currentLine.includes('# ') } incrementLines(start, linesAdded, linesRemoved, editor) { const highlightedLines = editor.options.linesHighlighted const totalHighlightedLines = highlightedLines.length const offset = linesAdded - linesRemoved // Store new items to be added as we're changing the lines const 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) { const cursor = { line: num, ch: 1 } this.editor.setCursor(cursor) const top = this.editor.charCoords({ line: num, ch: 0 }, 'local').top const middleHeight = this.editor.getScrollerElement().offsetHeight / 2 this.editor.scrollTo(null, top - middleHeight - 5) } focus() { this.editor.focus() } blur() { this.editor.blur() } reload() { // Change event shouldn't be fired when switch note this.editor.off('change', this.changeHandler) 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() } setValue(value) { const cursor = this.editor.getCursor() this.editor.setValue(value) this.editor.setCursor(cursor) } /** * Update content of one line * @param {Number} lineNumber * @param {String} content */ setLineContent(lineNumber, content) { const prevContent = this.editor.getLine(lineNumber) const prevContentLength = prevContent ? prevContent.length : 0 this.editor.replaceRange( content, { line: lineNumber, ch: 0 }, { line: lineNumber, ch: prevContentLength } ) } handleDropImage(dropEvent) { dropEvent.preventDefault() const { storageKey, noteKey } = this.props attachmentManagement.handleAttachmentDrop( this, storageKey, noteKey, dropEvent ) } insertAttachmentMd(imageMd) { this.editor.replaceSelection(imageMd) } autoDetectLanguage(content) { const res = hljs.highlightAuto(content, Object.keys(languageMaps)) this.setMode(languageMaps[res.language]) } handlePaste(editor, forceSmartPaste) { const { storageKey, noteKey, fetchUrlTitle, enableSmartPaste } = this.props const isURL = str => /(?:^\w+:|^)\/\/(?:[^\s\.]+\.\S{2}|localhost[\:?\d]*)/.test(str) const isInLinkTag = editor => { const startCursor = editor.getCursor('start') const prevChar = editor.getRange( { line: startCursor.line, ch: startCursor.ch - 2 }, { line: startCursor.line, ch: startCursor.ch } ) const endCursor = editor.getCursor('end') const nextChar = editor.getRange( { line: endCursor.line, ch: endCursor.ch }, { line: endCursor.line, ch: endCursor.ch + 1 } ) return prevChar === '](' && nextChar === ')' } const isInFencedCodeBlock = editor => { const cursor = editor.getCursor() let token = editor.getTokenAt(cursor) if (token.state.fencedState) { return true } let line = (line = cursor.line - 1) while (line >= 0) { token = editor.getTokenAt({ ch: 3, line }) if (token.start === token.end) { --line } else if (token.type === 'comment') { if (line > 0) { token = editor.getTokenAt({ ch: 3, line: line - 1 }) return token.type !== 'comment' } else { return true } } else { return false } } return false } const pastedTxt = clipboard.readText() if (isInFencedCodeBlock(editor)) { this.handlePasteText(editor, pastedTxt) } else if ( fetchUrlTitle && isMarkdownTitleURL(pastedTxt) && !isInLinkTag(editor) ) { this.handlePasteUrl(editor, pastedTxt) } else if (fetchUrlTitle && isURL(pastedTxt) && !isInLinkTag(editor)) { this.handlePasteUrl(editor, pastedTxt) } else if (attachmentManagement.isAttachmentLink(pastedTxt)) { attachmentManagement .handleAttachmentLinkPaste(storageKey, noteKey, pastedTxt) .then(modifiedText => { this.editor.replaceSelection(modifiedText) }) } else { const image = clipboard.readImage() if (!image.isEmpty()) { attachmentManagement.handlePasteNativeImage( this, storageKey, noteKey, image ) } else if (enableSmartPaste || forceSmartPaste) { const pastedHtml = clipboard.readHTML() if (pastedHtml.length > 0) { this.handlePasteHtml(editor, pastedHtml) } else { this.handlePasteText(editor, pastedTxt) } } else { this.handlePasteText(editor, pastedTxt) } } if (!this.props.mode && this.props.autoDetect) { this.autoDetectLanguage(editor.doc.getValue()) } } handleScroll(e) { if (this.props.onScroll) { this.props.onScroll(e) } } handlePasteUrl(editor, pastedTxt) { let taggedUrl = `<${pastedTxt}>` let urlToFetch = pastedTxt let titleMark = '' if (isMarkdownTitleURL(pastedTxt)) { const pastedTxtSplitted = pastedTxt.split(' ') titleMark = `${pastedTxtSplitted[0]} ` urlToFetch = pastedTxtSplitted[1] taggedUrl = `<${urlToFetch}>` } editor.replaceSelection(taggedUrl) const isImageReponse = response => { return ( response.headers.has('content-type') && response.headers.get('content-type').match(/^image\/.+$/) ) } const replaceTaggedUrl = replacement => { const value = editor.getValue() const cursor = editor.getCursor() const newValue = value.replace(taggedUrl, titleMark + replacement) const newCursor = Object.assign({}, cursor, { ch: cursor.ch + newValue.length - (value.length - titleMark.length) }) editor.setValue(newValue) editor.setCursor(newCursor) } fetch(urlToFetch, { method: 'get' }) .then(response => { if (isImageReponse(response)) { return this.mapImageResponse(response, urlToFetch) } else { return this.mapNormalResponse(response, urlToFetch) } }) .then(replacement => { replaceTaggedUrl(replacement) }) .catch(e => { replaceTaggedUrl(pastedTxt) }) } handlePasteHtml(editor, pastedHtml) { const markdown = this.turndownService.turndown(pastedHtml) editor.replaceSelection(markdown) } handlePasteText(editor, pastedTxt) { editor.replaceSelection(pastedTxt) } mapNormalResponse(response, pastedTxt) { return this.decodeResponse(response).then(body => { return new Promise((resolve, reject) => { try { const parsedBody = new window.DOMParser().parseFromString( body, 'text/html' ) const escapePipe = str => { return str.replace('|', '\\|') } const linkWithTitle = `[${escapePipe( parsedBody.title )}](${pastedTxt})` resolve(linkWithTitle) } catch (e) { reject(e) } }) }) } 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 { const url = response.url const name = url.substring(url.lastIndexOf('/') + 1) const imageLinkWithName = `` resolve(imageLinkWithName) } catch (e) { reject(e) } }) } decodeResponse(response) { const headers = response.headers const _charset = headers.has('content-type') ? this.extractContentTypeCharset(headers.get('content-type')) : undefined return response.arrayBuffer().then(buff => { return new Promise((resolve, reject) => { try { const charset = _charset !== undefined && iconv.encodingExists(_charset) ? _charset : 'utf-8' resolve(iconv.decode(Buffer.from(buff), charset).toString()) } catch (e) { reject(e) } }) }) } extractContentTypeCharset(contentType) { return contentType .split(';') .filter(str => { return str .trim() .toLowerCase() .startsWith('charset') }) .map(str => { return str.replace(/['"]/g, '').split('=')[1] })[0] } render() { const { className, fontSize, fontFamily, width, height } = this.props const normalisedFontFamily = normalizeEditorFontFamily(fontFamily) return (