mirror of
https://github.com/BoostIo/Boostnote
synced 2025-12-12 17:26:17 +00:00
1200 lines
36 KiB
JavaScript
1200 lines
36 KiB
JavaScript
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')
|
|
import TurndownService from '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'
|
|
|
|
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)
|
|
}
|
|
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
|
|
attachmentManagement.deleteAttachmentsNotPresentInNote(
|
|
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 = new TurndownService()
|
|
}
|
|
|
|
handleSearch (msg) {
|
|
const cm = this.editor
|
|
const component = this
|
|
|
|
if (component.searchState) cm.removeOverlay(component.searchState)
|
|
if (msg.length < 3) 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
|
|
},
|
|
'Ctrl-/': function (cm) {
|
|
if (global.process.platform === 'darwin') { return }
|
|
const dateNow = new Date()
|
|
cm.replaceSelection(dateNow.toLocaleDateString())
|
|
},
|
|
'Cmd-/': function (cm) {
|
|
if (global.process.platform !== 'darwin') { return }
|
|
const dateNow = new Date()
|
|
cm.replaceSelection(dateNow.toLocaleDateString())
|
|
},
|
|
'Shift-Ctrl-/': function (cm) {
|
|
if (global.process.platform === 'darwin') { return }
|
|
const dateNow = new Date()
|
|
cm.replaceSelection(dateNow.toLocaleString())
|
|
},
|
|
'Shift-Cmd-/': function (cm) {
|
|
if (global.process.platform !== 'darwin') { return }
|
|
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.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 } = 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: true,
|
|
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,
|
|
foldGutter: true,
|
|
lint: enableMarkdownLint ? this.getCodeEditorLintConfig() : false,
|
|
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
|
|
autoCloseBrackets: {
|
|
pairs: this.props.matchingPairs,
|
|
triples: this.props.matchingTriples,
|
|
explode: this.props.explodingPairs,
|
|
override: true
|
|
},
|
|
extraKeys: this.defaultKeyMap
|
|
})
|
|
|
|
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.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.scrollPastEnd !== this.props.scrollPastEnd) {
|
|
this.editor.setOption('scrollPastEnd', this.props.scrollPastEnd)
|
|
}
|
|
|
|
if (prevProps.matchingPairs !== this.props.matchingPairs ||
|
|
prevProps.matchingTriples !== this.props.matchingTriples ||
|
|
prevProps.explodingPairs !== this.props.explodingPairs) {
|
|
const bracketObject = {
|
|
pairs: this.props.matchingPairs,
|
|
triples: this.props.matchingTriples,
|
|
explode: this.props.explodingPairs,
|
|
override: true
|
|
}
|
|
this.editor.setOption('autoCloseBrackets', bracketObject)
|
|
}
|
|
|
|
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 (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)
|
|
}
|
|
|
|
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
|
|
} = this.props
|
|
const fontFamily = normalizeEditorFontFamily(this.props.fontFamily)
|
|
const width = this.props.width
|
|
return (<
|
|
div className={
|
|
className == null ? 'CodeEditor' : `CodeEditor ${className}`
|
|
}
|
|
ref='root'
|
|
tabIndex='-1'
|
|
style={{
|
|
fontFamily,
|
|
fontSize: fontSize,
|
|
width: width
|
|
}}
|
|
onDrop={
|
|
e => this.handleDropImage(e)
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
createSpellCheckPanel () {
|
|
const panel = document.createElement('div')
|
|
panel.className = 'panel bottom'
|
|
panel.id = 'editor-bottom-panel'
|
|
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 = {
|
|
value: PropTypes.string,
|
|
enableRulers: PropTypes.bool,
|
|
rulers: PropTypes.arrayOf(Number),
|
|
mode: PropTypes.string,
|
|
className: PropTypes.string,
|
|
onBlur: PropTypes.func,
|
|
onChange: PropTypes.func,
|
|
readOnly: PropTypes.bool,
|
|
autoDetect: PropTypes.bool,
|
|
spellCheck: PropTypes.bool,
|
|
enableMarkdownLint: PropTypes.bool,
|
|
customMarkdownLintConfig: PropTypes.string
|
|
}
|
|
|
|
CodeEditor.defaultProps = {
|
|
readOnly: false,
|
|
theme: 'xcode',
|
|
keyMap: 'sublime',
|
|
fontSize: 14,
|
|
fontFamily: 'Monaco, Consolas',
|
|
indentSize: 4,
|
|
indentType: 'space',
|
|
autoDetect: false,
|
|
spellCheck: false,
|
|
enableMarkdownLint: DEFAULT_CONFIG.editor.enableMarkdownLint,
|
|
customMarkdownLintConfig: DEFAULT_CONFIG.editor.customMarkdownLintConfig
|
|
}
|