Laverna/laverna

View on GitHub
app/scripts/modules/codemirror/controller.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * Copyright (C) 2015 Laverna project Authors.
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 */
/* global define */
define([
    'underscore',
    'jquery',
    'marionette',
    'backbone.radio',
    'codemirror/lib/codemirror',
    'modules/codemirror/views/editor',
    'codemirror/mode/gfm/gfm',
    'codemirror/mode/markdown/markdown',
    'codemirror/addon/edit/continuelist',
    'codemirror/addon/mode/overlay',
    'codemirror/keymap/vim',
    'codemirror/keymap/emacs',
    'codemirror/keymap/sublime'
], function(_, $, Marionette, Radio, CodeMirror, View) {
    'use strict';

    /**
     * Codemirror module.
     * Regex and WYSIWG button functions are based on simplemde-markdown-editor:
     * https://github.com/NextStepWebs/simplemde-markdown-editor
     */
    var Controller = Marionette.Object.extend({

        marks: {
            strong: {
                tag   : ['**', '__'],
                start : /(\*\*|__)(?![\s\S]*(\*\*|__))/,
                end   : /(\*\*|__)/,
            },
            em: {
                tag   : ['*', '_'],
                start : /(\*|_)(?![\s\S]*(\*|_))/,
                end   : /(\*|_)/,
            },
            strikethrough: {
                tag   : ['~~'],
                start : /(\*\*|~~)(?![\s\S]*(\*\*|~~))/,
                end   : /(\*\*|~~)/,
            },
            'code': {
                tag    : '```\r\n',
                tagEnd : '\r\n```',
            },
            'unordered-list': {
                replace : /^(\s*)(\*|\-|\+)\s+/,
                tag     : '* ',
            },
            'ordered-list': {
                replace : /^(\s*)\d+\.\s+/,
                tag     : '1. ',
            },
        },

        initialize: function() {
            _.bindAll(this, 'onChange', 'onScroll', 'onCursor', 'boldAction', 'italicAction', 'linkAction', 'headingAction', 'attachmentAction', 'codeAction', 'hrAction', 'listAction', 'numberedListAction');

            // Get configs
            this.configs = Radio.request('configs', 'get:object');

            // Initialize the view
            this.view = new View({
                model   : Radio.request('notesForm', 'model'),
                configs : this.configs
            });

            this.view.once('dom:refresh', this.initEditor, this);

            // Events
            this.listenTo(this.view, 'editor:action', this.onViewAction);
            this.listenTo(Radio.channel('notesForm'), 'set:mode', this.changeMode);
            this.listenTo(Radio.channel('editor'), 'focus', this.focus);

            // Show the view and render Pagedown editor
            Radio.request('notesForm', 'show:editor', this.view);

            Radio.reply('editor', {
                'get:data'      : this.getData,
                'generate:link' : this.generateLink,
                'generate:image': this.generateImage
            }, this);

            // Init footer to show current line numbers
            // but first of hide it, because when you open/add a note
            // the title is focused, not the editor
            this.footer = $('#editor--footer');
            this.footer.hide();

        },

        onDestroy: function() {
            Radio.stopReplying('editor', 'get:data');
        },

        initEditor: function() {
            this.editor = CodeMirror.fromTextArea(document.getElementById('editor--input'), {
                mode          : {
                    name        : 'gfm',
                    gitHubSpice : false
                },
                keyMap: this.configs.textEditor || 'default',
                lineNumbers   : false,
                matchBrackets : true,
                lineWrapping  : true,
                indentUnit    : parseInt(this.configs.indentUnit, 10),
                extraKeys     : {
                    'Cmd-B'  : this.boldAction,
                    'Ctrl-B' : this.boldAction,

                    'Cmd-I'  : this.italicAction,
                    'Ctrl-I' : this.italicAction,

                    'Cmd-H'  : this.headingAction,
                    'Ctrl-H' : this.headingAction,

                    'Cmd-L'  : this.linkAction,
                    'Ctrl-L' : this.linkAction,

                    'Cmd-K'  : this.codeAction,
                    'Ctrl-K' : this.codeAction,

                    'Cmd-O'  : this.numberedListAction,
                    'Ctrl-O' : this.numberedListAction,

                    'Cmd-U'  : this.listAction,
                    'Ctrl-U' : this.listAction,

                    // Ctrl+G - attach file
                    'Cmd-G'  : this.attachmentAction,
                    'Ctrl-G' : this.attachmentAction,
                    
                    // Shift+Ctrl+- - divider
                    'Shift-Cmd--'   : this.hrAction,
                    'Shift-Ctrl--'  : this.hrAction,

                    // Ctrl+. - indent line
                    'Ctrl-.'         : 'indentMore',
                    'Shift-Ctrl-.'     : 'indentLess',
                    'Cmd-.'         : 'indentMore',
                    'Shift-Cmd-.'    : 'indentLess',

                    'Enter' : 'newlineAndIndentContinueMarkdownList',

                }
            });

            window.dispatchEvent(new Event('resize'));
            this.editor.on('change', this.onChange);
            this.editor.on('scroll', this.onScroll);
            this.editor.on('cursorActivity', this.onCursor);

            // Show the preview
            this.updatePreview();
        },

        changeMode: function(mode) {
            window.dispatchEvent(new Event('resize'));
            this.view.trigger('change:mode', mode);
        },

        /**
         * Update the preview.
         */
        updatePreview: function() {
            var self = this,
                data = _.pick(this.view.model, 'attributes', 'files');

            return Radio.request('markdown', 'render', _.extend({}, data, {
                attributes: {
                    content: this.editor.getValue()
                }
            }))
            .then(function(content) {
                self.view.trigger('editor:change', content);
            });
        },

        /**
         * Text in the editor changed.
         */
        onChange: function() {

            // Update the preview
            this.updatePreview();

            // Trigger autosave
            this.autoSave();
        },

        /**
         * Editor's cursor position changed.
         */
        onCursor: function() {
            var state  = this.getState();
            this.$btns = this.$btns || $('.editor--btns .btn');

            // Make a specific button active depending on the type of the element under cursor
            this.$btns.removeClass('btn-primary');
            for (var i = 0; i < state.length; i++) {
                this['$btn' + state[i]] = this['$btn' + state[i]] || $('.editor--btns [data-state="' + state[i] + '"]');
                this['$btn' + state[i]].addClass('btn-primary');
            }

            // Update lines in footer
            this.footer.show();
            var currentLine = this.editor.getCursor('start').line + 1;
            var numberOfLines = this.editor.lineCount();
            this.footer.html($.t('Line of',
                {currentLine: currentLine, numberOfLines: numberOfLines}));
        },

        /**
         * Trigger 'save:auto' event.
         */
        autoSave: _.debounce(function() {
            Radio.trigger('notesForm', 'save:auto');
        }, 1000),

        /**
         * Synchronize the editor's scroll position with the preview's.
         */
        onScroll: _.debounce(function(e) {

            // Don't do any computations
            if (!e.doc.scrollTop) {
                this.view.ui.previewScroll.scrollTop(0);
                return;
            }

            var info       = this.editor.getScrollInfo(),
                lineNumber = this.editor.lineAtHeight(info.top, 'local'),
                range      = this.editor.getRange({line: 0, ch: null}, {line: lineNumber, ch: null}),
                self       = this,
                fragment,
                temp,
                lines,
                els;

            Radio.request('markdown', 'render', range)
            .then(function(html) {

                // Create a fragment and attach rendered HTML
                fragment       = document.createDocumentFragment();
                temp           = document.createElement('div');
                temp.innerHTML = html;
                fragment.appendChild(temp);

                // Get all elements in both the fragment and the preview
                lines = temp.children;
                els   = self.view.ui.preview[0].children;

                // Get from the preview the last visible element of the editor
                var newPos = els[lines.length].offsetTop;

                /**
                 * If the scroll position is on the same element,
                 * change it according to the difference of scroll positions in the editor.
                 */
                if (self.scrollTop && self.scrollPos === newPos) {
                    self.view.ui.previewScroll.scrollTop(self.view.ui.previewScroll.scrollTop() + (e.doc.scrollTop - self.scrollTop));
                    self.scrollTop = e.doc.scrollTop;
                    return;
                }

                // Scroll to the last visible element's position
                self.view.ui.previewScroll.animate({
                    scrollTop: newPos
                }, 70, 'swing');

                self.scrollPos = newPos;
                self.scrollTop = e.doc.scrollTop;
            });
        }, 10),

        /**
         * If the view triggered some action event, call a suitable function.
         * For instance, when action='bold', call boldAction method.
         */
        onViewAction: function(action) {
            action = action + 'Action';

            if (this[action]) {
                this[action]();
            }
        },

        /**
         * Return data from the editor.
         */
        getData: function() {
            var content = this.editor.getValue();

            return Radio.request('markdown', 'parse', content)
            .then(function(env) {
                return _.extend(
                    _.pick(env, 'tags', 'tasks', 'taskCompleted', 'taskAll', 'files'),
                    {content: content}
                );
            });
        },

        /**
         * Return state of the element under the cursor.
         */
        getState: function(pos) {
            pos      = pos || this.editor.getCursor('start');
            var stat = this.editor.getTokenAt(pos);

            if (!stat.type) {
                return [];
            }

            stat.type = stat.type.split(' ');

            if (_.indexOf(stat.type, 'variable-2') !== -1) {
                if (/^\s*\d+\.\s/.test(this.editor.getLine(pos.line))) {
                    stat.type.push('ordered-list');
                }
                else {
                    stat.type.push('unordered-list');
                }
            }


            return stat.type;
        },

        /**
         * Toggle Markdown block.
         */
        toggleBlock: function(type) {
            var stat  = this.getState(),
                start = this.editor.getCursor('start'),
                end   = this.editor.getCursor('end'),
                text,
                startText,
                endText;

            // Text is already [strong|italic|etc]
            if (_.indexOf(stat, type) !== -1) {
                text      = this.editor.getLine(start.line);
                startText = text.slice(0, start.ch);
                endText   = text.slice(start.ch);

                // Remove Markdown tags from the text
                startText = startText.replace(this.marks[type].start, '');
                endText   = endText.replace(this.marks[type].end, '');

                this.replaceRange(startText + endText, start.line);

                start.ch -= this.marks[type].tag[0].length;
                end.ch   -= this.marks[type].tag[0].length;
            }
            else {
                text = this.editor.getSelection();

                for (var i = 0; i < this.marks[type].tag.length - 1; i++) {
                    text = text.split(this.marks[type].tag[i]).join('');
                }

                this.editor.replaceSelection(this.marks[type].tag[0] + text + this.marks[type].tag[0]);

                start.ch += this.marks[type].tag[0].length;
                end.ch    = start.ch + text.length;
            }

            this.editor.setSelection(start, end);
            this.editor.focus();
        },

        /**
         * Make selected text strong.
         */
        boldAction: function() {
            this.toggleBlock('strong');
        },

        /**
         * Make selected text italicized.
         */
        italicAction: function() {
            this.toggleBlock('em');
        },

        /**
         * Create headings.
         */
        headingAction: function() {
            var start = this.editor.getCursor('start'),
                end   = this.editor.getCursor('end');

            for (var i = start.line; i <= end.line; i++) {
                this.toggleHeading(i);
            }
        },

        /**
         * Show a dialog to attach images or files.
         */
        attachmentAction: function() {
            var self   = this,
                dialog = Radio.request('editor', 'show:attachment', this.view.model);

            if (!dialog) {
                return;
            }

            dialog.then(function(text) {
                if (!text || !text.length) {
                    return;
                }

                self.editor.replaceSelection(text, true);
                self.editor.focus();
            });
        },

        /**
         * Show a link dialog.
         */
        linkAction: function() {
            var self   = this,
                dialog = Radio.request('editor', 'show:link');

            if (!dialog) {
                return;
            }

            dialog.then(function(link) {
                if (!link || !link.length) {
                    return;
                }

                var cursor = self.editor.getCursor('start'),
                    text   = self.editor.getSelection() || 'Link';

                self.editor.replaceSelection('[' + text + '](' + link + ')');
                self.editor.setSelection(
                    {line: cursor.line, ch: cursor.ch + 1},
                    {line: cursor.line, ch: cursor.ch + text.length + 1}
                );
                self.editor.focus();
            });
        },

        /**
         * Create a divider.
         */
        hrAction: function() {
            var start = this.editor.getCursor('start');
            this.editor.replaceSelection('\r\r-----\r\r');

            start.line += 4;
            start.ch    = 0;
            this.editor.setSelection( start, start );
            this.editor.focus();
        },

        /**
         * Create a code block.
         */
        codeAction: function() {
            var state = this.getState(),
                start = this.editor.getCursor('start'),
                end   = this.editor.getCursor('end'),
                text;

            if (_.indexOf(state, 'code') !== -1) {
                return;
            }
            else {
                text = this.editor.getSelection();
                this.editor.replaceSelection(this.marks.code.tag + text + this.marks.code.tagEnd);
            }
            this.editor.setSelection({line: start.line + 1, ch: start.ch}, {line: end.line + 1, ch: end.ch});
            this.editor.focus();
        },

        replaceRange: function(text, line) {
            this.editor.replaceRange(text, {
                line : line,
                ch   : 0
            }, {
                line : line,
                ch   : 99999999999999
            });
            this.editor.focus();
        },

        /**
         * Convert a line to a headline.
         */
        toggleHeading: function(i) {
            var text       = this.editor.getLine(i),
                headingLvl = text.search(/[^#]/);

            // Create a default headline
            if (headingLvl === -1) {
                text = '# Heading';

                this.replaceRange(text, i);
                return this.editor.setSelection(
                    {line: i, ch: 2},
                    {line: i, ch: 9}
                );
            }

            // Increase headline level up to 6th
            if (headingLvl < 6) {
                text = headingLvl > 0 ? text.substr(headingLvl + 1) : text;
                text = new Array(headingLvl + 2).join('#') + ' ' + text;
            }
            else {
                text = text.substr(headingLvl + 1);
            }

            this.replaceRange(text, i);
        },

        /**
         * Convert selected text to unordered list.
         */
        listAction: function() {
            this.toggleLists('unordered-list');
        },

        /**
         * Convert selected text to ordered list.
         */
        numberedListAction: function() {
            this.toggleLists('ordered-list', 1);
        },

        /**
         * Convert several selected lines to ordered or unordered lists.
         */
        toggleLists: function(type, order) {
            var state = this.getState(),
                start = this.editor.getCursor('start'),
                end   = this.editor.getCursor('end');

            // Convert each line to list
            _.each(new Array(end.line - start.line + 1), function(val, i) {
                this.toggleList(type, start.line + i, state, order);
                if (order) {
                    order++;
                }
            }, this);
        },

        /**
         * Convert selected text to an ordered or unordered list.
         */
        toggleList: function(name, line, state, order) {
            var text = this.editor.getLine(line);

            // If it is a list, convert it to normal text
            if (_.indexOf(state, name) !== -1) {
                text = text.replace(this.marks[name].replace, '$1');
            }
            else if (order) {
                text = order + '. ' + text;
            }
            else {
                text = this.marks[name].tag + text;
            }

            this.replaceRange(text, line);
        },

        /**
         * Redo the last action in Codemirror.
         */
        redoAction: function() {
            this.editor.redo();
        },

        /**
         * Undo the last action in Codemirror.
         */
        undoAction: function() {
            this.editor.undo();
        },

        /**
         * Focus on the editor.
         */
        focus: function() {
            this.editor.focus();
        },

        generateLink: function(data) {
            return '[' + data.text + ']' + '(' + data.url + ')';
        },

        generateImage: function(data) {
            return '!' + this.generateLink(data);
        },

    });

    return Controller;
});