/* global CodeMirror, $, editor, Cookies */ import * as utils from './utils' import config from './config' import statusBarTemplate from './statusbar.html' import toolBarTemplate from './toolbar.html' import './markdown-lint' import { initTableEditor } from './table-editor' import { options, Alignment, FormatType } from '@susisu/mte-kernel' /* config section */ const isMac = CodeMirror.keyMap.default === CodeMirror.keyMap.macDefault const defaultEditorMode = 'gfm' const viewportMargin = 20 const jumpToAddressBarKeymapName = isMac ? 'Cmd-L' : 'Ctrl-L' export default class Editor { constructor () { this.editor = null this.jumpToAddressBarKeymapValue = null this.defaultExtraKeys = { F10: function (cm) { cm.setOption('fullScreen', !cm.getOption('fullScreen')) }, Esc: function (cm) { if (cm.getOption('fullScreen') && !(cm.getOption('keyMap').substr(0, 3) === 'vim')) { cm.setOption('fullScreen', false) } else { return CodeMirror.Pass } }, 'Cmd-S': function () { return false }, 'Ctrl-S': function () { return false }, Enter: 'newlineAndIndentContinueMarkdownList', Tab: function (cm) { var tab = '\t' // contruct x length spaces var spaces = Array(parseInt(cm.getOption('indentUnit')) + 1).join(' ') // auto indent whole line when in list or blockquote var cursor = cm.getCursor() var line = cm.getLine(cursor.line) // this regex match the following patterns // 1. blockquote starts with "> " or ">>" // 2. unorder list starts with *+- // 3. order list starts with "1." or "1)" var regex = /^(\s*)(>[> ]*|[*+-]\s|(\d+)([.)]))/ var match var multiple = cm.getSelection().split('\n').length > 1 || cm.getSelections().length > 1 if (multiple) { cm.execCommand('defaultTab') } else if ((match = regex.exec(line)) !== null) { var ch = match[1].length var pos = { line: cursor.line, ch: ch } if (cm.getOption('indentWithTabs')) { cm.replaceRange(tab, pos, pos, '+input') } else { cm.replaceRange(spaces, pos, pos, '+input') } } else { if (cm.getOption('indentWithTabs')) { cm.execCommand('defaultTab') } else { cm.replaceSelection(spaces) } } }, 'Cmd-Left': 'goLineLeftSmart', 'Cmd-Right': 'goLineRight', Home: 'goLineLeftSmart', End: 'goLineRight', 'Ctrl-C': function (cm) { if (!isMac && cm.getOption('keyMap').substr(0, 3) === 'vim') { document.execCommand('copy') } else { return CodeMirror.Pass } }, 'Ctrl-*': cm => { utils.wrapTextWith(this.editor, cm, '*') }, 'Shift-Ctrl-8': cm => { utils.wrapTextWith(this.editor, cm, '*') }, 'Ctrl-_': cm => { utils.wrapTextWith(this.editor, cm, '_') }, 'Shift-Ctrl--': cm => { utils.wrapTextWith(this.editor, cm, '_') }, 'Ctrl-~': cm => { utils.wrapTextWith(this.editor, cm, '~') }, 'Shift-Ctrl-`': cm => { utils.wrapTextWith(this.editor, cm, '~') }, 'Ctrl-^': cm => { utils.wrapTextWith(this.editor, cm, '^') }, 'Shift-Ctrl-6': cm => { utils.wrapTextWith(this.editor, cm, '^') }, 'Ctrl-+': cm => { utils.wrapTextWith(this.editor, cm, '+') }, 'Shift-Ctrl-=': cm => { utils.wrapTextWith(this.editor, cm, '+') }, 'Ctrl-=': cm => { utils.wrapTextWith(this.editor, cm, '=') }, 'Shift-Ctrl-Backspace': cm => { utils.wrapTextWith(this.editor, cm, 'Backspace') } } this.eventListeners = {} this.config = config } on (event, cb) { if (!this.eventListeners[event]) { this.eventListeners[event] = [cb] } else { this.eventListeners[event].push(cb) } this.editor.on(event, (...args) => { this.eventListeners[event].forEach(cb => cb.bind(this)(...args)) }) } addToolBar () { this.toolBar = $(toolBarTemplate) this.toolbarPanel = this.editor.addPanel(this.toolBar[0], { position: 'top' }) var makeBold = $('#makeBold') var makeItalic = $('#makeItalic') var makeStrike = $('#makeStrike') var makeHeader = $('#makeHeader') var makeCode = $('#makeCode') var makeQuote = $('#makeQuote') var makeGenericList = $('#makeGenericList') var makeOrderedList = $('#makeOrderedList') var makeCheckList = $('#makeCheckList') var makeLink = $('#makeLink') var makeImage = $('#makeImage') var makeTable = $('#makeTable') var makeLine = $('#makeLine') var makeComment = $('#makeComment') var insertRow = $('#insertRow') var deleteRow = $('#deleteRow') var moveRowUp = $('#moveRowUp') var moveRowDown = $('#moveRowDown') var insertColumn = $('#insertColumn') var deleteColumn = $('#deleteColumn') var moveColumnLeft = $('#moveColumnLeft') var moveColumnRight = $('#moveColumnRight') var alignLeft = $('#alignLeft') var alignCenter = $('#alignCenter') var alignRight = $('#alignRight') var alignNone = $('#alignNone') makeBold.click(() => { utils.wrapTextWith(this.editor, this.editor, '**') this.editor.focus() }) makeItalic.click(() => { utils.wrapTextWith(this.editor, this.editor, '*') this.editor.focus() }) makeStrike.click(() => { utils.wrapTextWith(this.editor, this.editor, '~~') this.editor.focus() }) makeHeader.click(() => { utils.insertHeader(this.editor) }) makeCode.click(() => { utils.wrapTextWith(this.editor, this.editor, '```') this.editor.focus() }) makeQuote.click(() => { utils.insertOnStartOfLines(this.editor, '> ') }) makeGenericList.click(() => { utils.insertOnStartOfLines(this.editor, '* ') }) makeOrderedList.click(() => { utils.insertOnStartOfLines(this.editor, '1. ') }) makeCheckList.click(() => { utils.insertOnStartOfLines(this.editor, '- [ ] ') }) makeLink.click(() => { utils.insertLink(this.editor, false) }) makeImage.click(() => { utils.insertLink(this.editor, true) }) makeTable.click(() => { utils.insertText(this.editor, '\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Text | Text | Text |\n') }) makeLine.click(() => { utils.insertText(this.editor, '\n----\n') }) makeComment.click(() => { utils.insertText(this.editor, '> []') }) // table tools UI const opts = options({ smartCursor: true, formatType: FormatType.NORMAL }) insertRow.click(() => { this.tableEditor.insertRow(opts) this.editor.focus() }) deleteRow.click(() => { this.tableEditor.deleteRow(opts) this.editor.focus() }) moveRowUp.click(() => { this.tableEditor.moveRow(-1, opts) this.editor.focus() }) moveRowDown.click(() => { this.tableEditor.moveRow(1, opts) this.editor.focus() }) insertColumn.click(() => { this.tableEditor.insertColumn(opts) this.editor.focus() }) deleteColumn.click(() => { this.tableEditor.deleteColumn(opts) this.editor.focus() }) moveColumnLeft.click(() => { this.tableEditor.moveColumn(-1, opts) this.editor.focus() }) moveColumnRight.click(() => { this.tableEditor.moveColumn(1, opts) this.editor.focus() }) alignLeft.click(() => { this.tableEditor.alignColumn(Alignment.LEFT, opts) this.editor.focus() }) alignCenter.click(() => { this.tableEditor.alignColumn(Alignment.CENTER, opts) this.editor.focus() }) alignRight.click(() => { this.tableEditor.alignColumn(Alignment.RIGHT, opts) this.editor.focus() }) alignNone.click(() => { this.tableEditor.alignColumn(Alignment.NONE, opts) this.editor.focus() }) } addStatusBar () { this.statusBar = $(statusBarTemplate) this.statusCursor = this.statusBar.find('.status-cursor > .status-line-column') this.statusSelection = this.statusBar.find('.status-cursor > .status-selection') this.statusFile = this.statusBar.find('.status-file') this.statusIndicators = this.statusBar.find('.status-indicators') this.statusIndent = this.statusBar.find('.status-indent') this.statusKeymap = this.statusBar.find('.status-keymap') this.statusLength = this.statusBar.find('.status-length') this.statusTheme = this.statusBar.find('.status-theme') this.statusSpellcheck = this.statusBar.find('.status-spellcheck') this.statusLinter = this.statusBar.find('.status-linter') this.statusPreferences = this.statusBar.find('.status-preferences') this.statusPanel = this.editor.addPanel(this.statusBar[0], { position: 'bottom' }) this.setIndent() this.setKeymap() this.setTheme() this.setSpellcheck() this.setLinter() this.setPreferences() } updateStatusBar () { if (!this.statusBar) return var cursor = this.editor.getCursor() var cursorText = 'Line ' + (cursor.line + 1) + ', Column ' + (cursor.ch + 1) this.statusCursor.text(cursorText) var fileText = ' — ' + editor.lineCount() + ' Lines' this.statusFile.text(fileText) var docLength = editor.getValue().length this.statusLength.text('Length ' + docLength) if (docLength > (config.docmaxlength * 0.95)) { this.statusLength.css('color', 'red') this.statusLength.attr('title', 'You have almost reached the limit for this document.') } else if (docLength > (config.docmaxlength * 0.8)) { this.statusLength.css('color', 'orange') this.statusLength.attr('title', 'This document is nearly full, consider splitting it or creating a new one.') } else { this.statusLength.css('color', 'white') this.statusLength.attr('title', 'You can write up to ' + config.docmaxlength + ' characters in this document.') } } setIndent () { var cookieIndentType = Cookies.get('indent_type') var cookieTabSize = parseInt(Cookies.get('tab_size')) var cookieSpaceUnits = parseInt(Cookies.get('space_units')) if (cookieIndentType) { if (cookieIndentType === 'tab') { this.editor.setOption('indentWithTabs', true) if (cookieTabSize) { this.editor.setOption('indentUnit', cookieTabSize) } } else if (cookieIndentType === 'space') { this.editor.setOption('indentWithTabs', false) if (cookieSpaceUnits) { this.editor.setOption('indentUnit', cookieSpaceUnits) } } } if (cookieTabSize) { this.editor.setOption('tabSize', cookieTabSize) } var type = this.statusIndicators.find('.indent-type') var widthLabel = this.statusIndicators.find('.indent-width-label') var widthInput = this.statusIndicators.find('.indent-width-input') const setType = () => { if (this.editor.getOption('indentWithTabs')) { Cookies.set('indent_type', 'tab', { expires: 365 }) type.text('Tab Size:') } else { Cookies.set('indent_type', 'space', { expires: 365 }) type.text('Spaces:') } } setType() const setUnit = () => { var unit = this.editor.getOption('indentUnit') if (this.editor.getOption('indentWithTabs')) { Cookies.set('tab_size', unit, { expires: 365 }) } else { Cookies.set('space_units', unit, { expires: 365 }) } widthLabel.text(unit) } setUnit() type.click(() => { if (this.editor.getOption('indentWithTabs')) { this.editor.setOption('indentWithTabs', false) cookieSpaceUnits = parseInt(Cookies.get('space_units')) if (cookieSpaceUnits) { this.editor.setOption('indentUnit', cookieSpaceUnits) } } else { this.editor.setOption('indentWithTabs', true) cookieTabSize = parseInt(Cookies.get('tab_size')) if (cookieTabSize) { this.editor.setOption('indentUnit', cookieTabSize) this.editor.setOption('tabSize', cookieTabSize) } } setType() setUnit() }) widthLabel.click(() => { if (widthLabel.is(':visible')) { widthLabel.addClass('hidden') widthInput.removeClass('hidden') widthInput.val(this.editor.getOption('indentUnit')) widthInput.select() } else { widthLabel.removeClass('hidden') widthInput.addClass('hidden') } }) widthInput.on('change', () => { var val = parseInt(widthInput.val()) if (!val) val = this.editor.getOption('indentUnit') if (val < 1) val = 1 else if (val > 10) val = 10 if (this.editor.getOption('indentWithTabs')) { this.editor.setOption('tabSize', val) } this.editor.setOption('indentUnit', val) setUnit() }) widthInput.on('blur', function () { widthLabel.removeClass('hidden') widthInput.addClass('hidden') }) } setKeymap () { var cookieKeymap = Cookies.get('keymap') if (cookieKeymap) { this.editor.setOption('keyMap', cookieKeymap) } var label = this.statusIndicators.find('.ui-keymap-label') var sublime = this.statusIndicators.find('.ui-keymap-sublime') var emacs = this.statusIndicators.find('.ui-keymap-emacs') var vim = this.statusIndicators.find('.ui-keymap-vim') const setKeymapLabel = () => { var keymap = this.editor.getOption('keyMap') Cookies.set('keymap', keymap, { expires: 365 }) label.text(keymap) this.restoreOverrideEditorKeymap() this.setOverrideBrowserKeymap() } setKeymapLabel() sublime.click(() => { this.editor.setOption('keyMap', 'sublime') setKeymapLabel() }) emacs.click(() => { this.editor.setOption('keyMap', 'emacs') setKeymapLabel() }) vim.click(() => { this.editor.setOption('keyMap', 'vim') setKeymapLabel() }) } setTheme () { var cookieTheme = Cookies.get('theme') if (cookieTheme) { this.editor.setOption('theme', cookieTheme) } const setTheme = theme => { this.editor.setOption('theme', theme) Cookies.set('theme', theme, { expires: 365 }) } this.statusIndicators.find('.status-theme li').click(function () { setTheme($(this).attr('value')) }) } setSpellcheck () { var cookieSpellcheck = Cookies.get('spellcheck') if (cookieSpellcheck) { var mode = null if (cookieSpellcheck === 'true' || cookieSpellcheck === true) { mode = 'spell-checker' } else { mode = defaultEditorMode } if (mode && mode !== this.editor.getOption('mode')) { this.editor.setOption('mode', mode) } } var spellcheckToggle = this.statusSpellcheck.find('.ui-spellcheck-toggle') const checkSpellcheck = () => { var mode = this.editor.getOption('mode') if (mode === defaultEditorMode) { spellcheckToggle.removeClass('active') } else { spellcheckToggle.addClass('active') } } spellcheckToggle.click(() => { var mode = this.editor.getOption('mode') if (mode === defaultEditorMode) { mode = 'spell-checker' } else { mode = defaultEditorMode } if (mode && mode !== this.editor.getOption('mode')) { this.editor.setOption('mode', mode) } Cookies.set('spellcheck', mode === 'spell-checker', { expires: 365 }) checkSpellcheck() }) checkSpellcheck() // workaround spellcheck might not activate beacuse the ajax loading if (window.num_loaded < 2) { var spellcheckTimer = setInterval( () => { if (window.num_loaded >= 2) { if (this.editor.getOption('mode') === 'spell-checker') { this.editor.setOption('mode', 'spell-checker') } clearInterval(spellcheckTimer) } }, 100 ) } } toggleLinter (enable) { const gutters = this.editor.getOption('gutters') const lintGutter = 'CodeMirror-lint-markers' if (enable) { if (!gutters.includes(lintGutter)) { this.editor.setOption('gutters', [lintGutter, ...gutters]) } Cookies.set('linter', true, { expires: 365 }) } else { this.editor.setOption('gutters', gutters.filter(g => g !== lintGutter)) Cookies.remove('linter') } this.editor.setOption('lint', enable) } setLinter () { const linterToggle = this.statusLinter.find('.ui-linter-toggle') const updateLinterStatus = (enable) => { linterToggle.toggleClass('active', enable) } linterToggle.click(() => { const lintEnable = this.editor.getOption('lint') this.toggleLinter.bind(this)(!lintEnable) updateLinterStatus(!lintEnable) }) const enable = !!Cookies.get('linter') this.toggleLinter.bind(this)(enable) updateLinterStatus(enable) } resetEditorKeymapToBrowserKeymap () { var keymap = this.editor.getOption('keyMap') if (!this.jumpToAddressBarKeymapValue) { this.jumpToAddressBarKeymapValue = CodeMirror.keyMap[keymap][jumpToAddressBarKeymapName] delete CodeMirror.keyMap[keymap][jumpToAddressBarKeymapName] } } restoreOverrideEditorKeymap () { var keymap = this.editor.getOption('keyMap') if (this.jumpToAddressBarKeymapValue) { CodeMirror.keyMap[keymap][jumpToAddressBarKeymapName] = this.jumpToAddressBarKeymapValue this.jumpToAddressBarKeymapValue = null } } setOverrideBrowserKeymap () { var overrideBrowserKeymap = $( '.ui-preferences-override-browser-keymap label > input[type="checkbox"]' ) if (overrideBrowserKeymap.is(':checked')) { Cookies.set('preferences-override-browser-keymap', true, { expires: 365 }) this.restoreOverrideEditorKeymap() } else { Cookies.remove('preferences-override-browser-keymap') this.resetEditorKeymapToBrowserKeymap() } } setPreferences () { var overrideBrowserKeymap = $( '.ui-preferences-override-browser-keymap label > input[type="checkbox"]' ) var cookieOverrideBrowserKeymap = Cookies.get( 'preferences-override-browser-keymap' ) if (cookieOverrideBrowserKeymap && cookieOverrideBrowserKeymap === 'true') { overrideBrowserKeymap.prop('checked', true) } else { overrideBrowserKeymap.prop('checked', false) } this.setOverrideBrowserKeymap() overrideBrowserKeymap.change(() => { this.setOverrideBrowserKeymap() }) } init (textit) { this.editor = CodeMirror.fromTextArea(textit, { mode: defaultEditorMode, backdrop: defaultEditorMode, keyMap: 'sublime', viewportMargin: viewportMargin, styleActiveLine: true, lineNumbers: true, lineWrapping: true, showCursorWhenSelecting: true, highlightSelectionMatches: true, indentUnit: 4, continueComments: 'Enter', theme: 'one-dark', inputStyle: 'textarea', matchBrackets: true, autoCloseBrackets: true, matchTags: { bothTags: true }, autoCloseTags: true, foldGutter: true, gutters: [ 'CodeMirror-linenumbers', 'authorship-gutters', 'CodeMirror-foldgutter' ], extraKeys: this.defaultExtraKeys, flattenSpans: true, addModeClass: true, readOnly: true, autoRefresh: true, otherCursors: true, placeholder: "← Start by entering a title here\n===\nVisit /features if you don't know what to do.\nHappy hacking :)" }) this.tableEditor = initTableEditor(this.editor) return this.editor } getEditor () { return this.editor } }