diff --git a/browser/components/CodeEditor.js b/browser/components/CodeEditor.js index d81ce39d..7f6b4bbd 100644 --- a/browser/components/CodeEditor.js +++ b/browser/components/CodeEditor.js @@ -5,7 +5,7 @@ import CodeMirror from 'codemirror' import 'codemirror-mode-elixir' import attachmentManagement from 'browser/main/lib/dataApi/attachmentManagement' import convertModeName from 'browser/lib/convertModeName' -import { options, TableEditor } from '@susisu/mte-kernel' +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' @@ -59,6 +59,7 @@ export default class CodeEditor extends React.Component { this.searchState = null this.formatTable = () => this.handleFormatTable() + this.editorActivityHandler = () => this.handleEditorActivity() } handleSearch (msg) { @@ -99,6 +100,28 @@ export default class CodeEditor extends React.Component { this.tableEditor.formatAll(options({textWidthOptions: {}})) } + handleEditorActivity () { + if (!this.textEditorInterface.transaction) { + this.updateTableEditorState() + } + } + + 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 } = this.props const expandSnippet = this.expandSnippet.bind(this) @@ -119,6 +142,59 @@ export default class CodeEditor extends React.Component { ) } + 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 snippets = JSON.parse( + fs.readFileSync(consts.SNIPPET_FILE, 'utf8') + ) + if (expandSnippet(line, cursor, cm, snippets) === false) { + if (tabs) { + cm.execCommand('insertTab') + } else { + cm.execCommand('insertSoftTab') + } + } + } else { + if (tabs) { + cm.execCommand('insertTab') + } else { + cm.execCommand('insertSoftTab') + } + } + } + }, + 'Cmd-T': function (cm) { + // Do nothing + }, + Enter: 'boostNewLineAndIndentContinueMarkdownList', + 'Ctrl-C': cm => { + if (cm.getOption('keyMap').substr(0, 3) === 'vim') { + document.execCommand('copy') + } + return CodeMirror.Pass + } + }) + this.value = this.props.value this.editor = CodeMirror(this.refs.root, { rulers: buildCMRulers(rulers, enableRulers), @@ -141,58 +217,7 @@ export default class CodeEditor extends React.Component { explode: '[]{}``$$', override: true }, - extraKeys: { - 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 snippets = JSON.parse( - fs.readFileSync(consts.SNIPPET_FILE, 'utf8') - ) - if (expandSnippet(line, cursor, cm, snippets) === false) { - if (tabs) { - cm.execCommand('insertTab') - } else { - cm.execCommand('insertSoftTab') - } - } - } else { - if (tabs) { - cm.execCommand('insertTab') - } else { - cm.execCommand('insertSoftTab') - } - } - } - }, - 'Cmd-T': function (cm) { - // Do nothing - }, - Enter: 'boostNewLineAndIndentContinueMarkdownList', - 'Ctrl-C': cm => { - if (cm.getOption('keyMap').substr(0, 3) === 'vim') { - document.execCommand('copy') - } - return CodeMirror.Pass - } - } + extraKeys: this.defaultKeyMap }) this.setMode(this.props.mode) @@ -215,8 +240,58 @@ export default class CodeEditor extends React.Component { CodeMirror.Vim.defineEx('qw', 'qw', this.quitEditor) CodeMirror.Vim.map('ZZ', ':q', 'normal') - this.tableEditor = new TableEditor(new TextEditorInterface(this.editor)) + this.textEditorInterface = new TextEditorInterface(this.editor) + this.tableEditor = new TableEditor(this.textEditorInterface) 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) + } } expandSnippet (line, cursor, cm, snippets) { @@ -353,6 +428,19 @@ export default class CodeEditor extends React.Component { this.editor.setOption('scrollPastEnd', this.props.scrollPastEnd) } + 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 (needRefresh) { this.editor.refresh() } diff --git a/browser/components/MarkdownEditor.js b/browser/components/MarkdownEditor.js index 63673583..70df16a0 100644 --- a/browser/components/MarkdownEditor.js +++ b/browser/components/MarkdownEditor.js @@ -265,6 +265,7 @@ class MarkdownEditor extends React.Component { storageKey={storageKey} noteKey={noteKey} fetchUrlTitle={config.editor.fetchUrlTitle} + enableTableEditor={config.editor.enableTableEditor} onChange={(e) => this.handleChange(e)} onBlur={(e) => this.handleBlur(e)} /> diff --git a/browser/components/MarkdownSplitEditor.js b/browser/components/MarkdownSplitEditor.js index ce518a89..d714125a 100644 --- a/browser/components/MarkdownSplitEditor.js +++ b/browser/components/MarkdownSplitEditor.js @@ -158,6 +158,7 @@ class MarkdownSplitEditor extends React.Component { rulers={config.editor.rulers} scrollPastEnd={config.editor.scrollPastEnd} fetchUrlTitle={config.editor.fetchUrlTitle} + enableTableEditor={config.editor.enableTableEditor} storageKey={storageKey} noteKey={noteKey} onChange={this.handleOnChange.bind(this)} diff --git a/browser/lib/TextEditorInterface.js b/browser/lib/TextEditorInterface.js index 53ae2337..55f2c29d 100644 --- a/browser/lib/TextEditorInterface.js +++ b/browser/lib/TextEditorInterface.js @@ -1,53 +1,113 @@ -import { Point } from '@susisu/mte-kernel' - -export default class TextEditorInterface { - constructor (editor) { - this.editor = editor - } - - getCursorPosition () { - const pos = this.editor.getCursor() - return new Point(pos.line, pos.ch) - } - - setCursorPosition (pos) { - this.editor.setCursor({line: pos.row, ch: pos.column}) - } - - setSelectionRange (range) { - this.editor.setSelection({ - anchor: {line: range.start.row, ch: range.start.column}, - head: {line: range.end.row, ch: range.end.column} - }) - } - - getLastRow () { - return this.editor.lastLine() - } - - acceptsTableEdit (row) { - return true - } - - getLine (row) { - return this.editor.getLine(row) - } - - insertLine (row, line) { - this.editor.replaceRange(line, {line: row, ch: 0}) - } - - deleteLine (row) { - this.editor.replaceRange('', {line: row, ch: 0}, {line: row, ch: this.editor.getLine(row).length}) - } - - replaceLines (startRow, endRow, lines) { - endRow-- // because endRow is a first line after a table. - const endRowCh = this.editor.getLine(endRow).length - this.editor.replaceRange(lines, {line: startRow, ch: 0}, {line: endRow, ch: endRowCh}) - } - - transact (func) { - func() - } -} +import { Point } from '@susisu/mte-kernel' + +export default class TextEditorInterface { + constructor (editor) { + this.editor = editor + this.doc = editor.getDoc() + this.transaction = false + } + + getCursorPosition () { + const { line, ch } = this.doc.getCursor() + return new Point(line, ch) + } + + setCursorPosition (pos) { + this.doc.setCursor({ + line: pos.row, + ch: pos.column + }) + } + + setSelectionRange (range) { + this.doc.setSelection( + { line: range.start.row, ch: range.start.column }, + { line: range.end.row, ch: range.end.column } + ) + } + + getLastRow () { + return this.doc.lineCount() - 1 + } + + acceptsTableEdit () { + return true + } + + getLine (row) { + return this.doc.getLine(row) + } + + insertLine (row, line) { + const lastRow = this.getLastRow() + if (row > lastRow) { + const lastLine = this.getLine(lastRow) + this.doc.replaceRange( + '\n' + line, + { line: lastRow, ch: lastLine.length }, + { line: lastRow, ch: lastLine.length } + ) + } else { + this.doc.replaceRange( + line + '\n', + { line: row, ch: 0 }, + { line: row, ch: 0 } + ) + } + } + + deleteLine (row) { + const lastRow = this.getLastRow() + if (row >= lastRow) { + if (lastRow > 0) { + const preLastLine = this.getLine(lastRow - 1) + const lastLine = this.getLine(lastRow) + this.doc.replaceRange( + '', + { line: lastRow - 1, ch: preLastLine.length }, + { line: lastRow, ch: lastLine.length } + ) + } else { + const lastLine = this.getLine(lastRow) + this.doc.replaceRange( + '', + { line: lastRow, ch: 0 }, + { line: lastRow, ch: lastLine.length } + ) + } + } else { + this.doc.replaceRange( + '', + { line: row, ch: 0 }, + { line: row + 1, ch: 0 } + ) + } + } + + replaceLines (startRow, endRow, lines) { + const lastRow = this.getLastRow() + if (endRow > lastRow) { + const lastLine = this.getLine(lastRow) + this.doc.replaceRange( + lines.join('\n'), + { line: startRow, ch: 0 }, + { line: lastRow, ch: lastLine.length } + ) + } else { + this.doc.replaceRange( + lines.join('\n') + '\n', + { line: startRow, ch: 0 }, + { line: endRow, ch: 0 } + ) + } + } + + transact (func) { + this.transaction = true + func() + this.transaction = false + if (this.onDidFinishTransaction) { + this.onDidFinishTransaction.call(undefined) + } + } +} diff --git a/browser/main/Detail/SnippetNoteDetail.js b/browser/main/Detail/SnippetNoteDetail.js index 6220b5a4..3564d6bf 100644 --- a/browser/main/Detail/SnippetNoteDetail.js +++ b/browser/main/Detail/SnippetNoteDetail.js @@ -692,6 +692,7 @@ class SnippetNoteDetail extends React.Component { keyMap={config.editor.keyMap} scrollPastEnd={config.editor.scrollPastEnd} fetchUrlTitle={config.editor.fetchUrlTitle} + enableTableEditor={config.editor.enableTableEditor} onChange={(e) => this.handleCodeChange(index)(e)} ref={'code-' + index} /> diff --git a/browser/main/lib/ConfigManager.js b/browser/main/lib/ConfigManager.js index 434b0d22..2c601b57 100644 --- a/browser/main/lib/ConfigManager.js +++ b/browser/main/lib/ConfigManager.js @@ -46,7 +46,8 @@ export const DEFAULT_CONFIG = { switchPreview: 'BLUR', // Available value: RIGHTCLICK, BLUR scrollPastEnd: false, type: 'SPLIT', - fetchUrlTitle: true + fetchUrlTitle: true, + enableTableEditor: false }, preview: { fontSize: '14', diff --git a/browser/main/modals/PreferencesModal/UiTab.js b/browser/main/modals/PreferencesModal/UiTab.js index 74047d44..02a235ce 100644 --- a/browser/main/modals/PreferencesModal/UiTab.js +++ b/browser/main/modals/PreferencesModal/UiTab.js @@ -87,7 +87,8 @@ class UiTab extends React.Component { keyMap: this.refs.editorKeyMap.value, snippetDefaultLanguage: this.refs.editorSnippetDefaultLanguage.value, scrollPastEnd: this.refs.scrollPastEnd.checked, - fetchUrlTitle: this.refs.editorFetchUrlTitle.checked + fetchUrlTitle: this.refs.editorFetchUrlTitle.checked, + enableTableEditor: this.refs.enableTableEditor.checked }, preview: { fontSize: this.refs.previewFontSize.value, @@ -437,6 +438,17 @@ class UiTab extends React.Component { +
+ +
+
{i18n.__('Preview')}
diff --git a/locales/en.json b/locales/en.json index ab7b33f6..5992fa5a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -176,5 +176,6 @@ "Allow dangerous html tags": "Allow dangerous html tags", "Convert textual arrows to beautiful signs. ⚠ This will interfere with using HTML comments in your Markdown.": "Convert textual arrows to beautiful signs. ⚠ This will interfere with using HTML comments in your Markdown.", "⚠ You have pasted a link referring an attachment that could not be found in the storage location of this note. Pasting links referring attachments is only supported if the source and destination location is the same storage. Please Drag&Drop the attachment instead! ⚠": "⚠ You have pasted a link referring an attachment that could not be found in the storage location of this note. Pasting links referring attachments is only supported if the source and destination location is the same storage. Please Drag&Drop the attachment instead! ⚠", + "Enable smart table editor": "Enable smart table editor", "Snippet Default Language": "Snippet Default Language" } diff --git a/locales/fr.json b/locales/fr.json index e5ce7314..64e0d1fd 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -153,5 +153,6 @@ "Allow dangerous html tags": "Accepter les tags html dangereux", "Convert textual arrows to beautiful signs. ⚠ This will interfere with using HTML comments in your Markdown.": "Convertir des flèches textuelles en jolis signes. ⚠ Cela va interferérer avec les éventuels commentaires HTML dans votre Markdown.", "⚠ You have pasted a link referring an attachment that could not be found in the storage location of this note. Pasting links referring attachments is only supported if the source and destination location is the same storage. Please Drag&Drop the attachment instead! ⚠": "⚠ Vous avez collé un lien qui référence une pièce-jointe qui n'a pas pu être récupéré dans le dossier de stockage de la note. Coller des liens qui font référence à des pièces-jointes ne fonctionne que si la source et la destination et la même. Veuillez plutôt utiliser du Drag & Drop ! ⚠", + "Enable smart table editor": "Activer l'intelligent éditeur de tableaux", "Snippet Default Language": "Langage par défaut d'un snippet" } diff --git a/readme.md b/readme.md index c6674902..dc558924 100644 --- a/readme.md +++ b/readme.md @@ -22,7 +22,8 @@ Thank you to all the people who already contributed to Boostnote! Boostnote is an open source project. It's an independent project with its ongoing development made possible entirely thanks to the support by these awesome backers. Any issues on Boostnote can be funded by anyone and that money will be distributed to contributors and maintainers. If you'd like to join them, please consider: -- [Become a backer on IssueHunt](https://issuehunt.io/repos/53266139). + +[![issuehunt-image](https://github.com/BoostIO/issuehunt-materials/blob/master/issuehunt-badge@1x.png?raw=true)](https://issuehunt.io/repos/53266139) ## Community - [Facebook Group](https://www.facebook.com/groups/boostnote/)