CartoDB/cartodb20

View on GitHub
lib/assets/javascripts/builder/components/code-mirror/code-mirror-view.js

Summary

Maintainability
D
1 day
Test Coverage
var CoreView = require('backbone/core-view');
var _ = require('underscore');
var CodeMirror = require('codemirror');
var ColorPicker = require('./colorpicker.code-mirror');
var template = require('./code-mirror.tpl');
var bulletTemplate = require('./code-mirror-bullet.tpl');
var errorTemplate = require('./code-mirror-error.tpl');
var warningTemplate = require('./code-mirror-warning.tpl');
var DATA_SERVICES = require('./data-services');

require('./mode/sql')(CodeMirror);
require('./mode/mustache')(CodeMirror);
require('./cartocss.code-mirror')(CodeMirror);
require('./scroll.code-mirror')(CodeMirror);
require('./show-hint.code-mirror')(CodeMirror);
require('./hint/custom-list-hint')(CodeMirror);
require('./searchcursor.code-mirror')(CodeMirror);
require('./placeholder.code-mirror')(CodeMirror);

var ESCAPE_KEY_CODE = 27;
var RETURN_KEY_CODE = 13;

var NOHINT = [ESCAPE_KEY_CODE, RETURN_KEY_CODE];

var ADDONS = {
  'color-picker': ColorPicker
};

module.exports = CoreView.extend({
  module: 'components:code-mirror:code-mirror-view',

  className: 'Editor-content',

  options: {
    readonly: false,
    lineNumbers: true,
    autocompleteChars: 3
  },

  initialize: function (opts) {
    if (!opts) throw new Error('options for codemirror are required.');
    if (!opts.model) throw new Error('Model for codemirror is required.');
    if (opts.model.get('content') === void 0 &&
        opts.placeholder === void 0) throw new Error('Content property or placeholder for codemirror is required.');
    if (!opts.tips) throw new Error('tip messages are required');

    this._autocompleteChars = opts.autocompleteChars || this.options.autocompleteChars;
    this._mode = opts.mode || 'cartocss';
    this._addons = opts.addons;
    this._hints = opts.hints;
    this._autocompletePrefix = opts.autocompletePrefix;
    this._autocompleteTriggers = opts.autocompleteTriggers;
    this._autocompleteSuffix = opts.autocompleteSuffix;
    this._errorTemplate = opts.errorTemplate || errorTemplate;
    this._warningTemplate = opts.warningTemplate || warningTemplate;
    this._warnings = null;
    this._tips = opts.tips;
    this._lineWithErrors = [];
    this._onInputRead = _.bind(this._onKeyUpEditor, this);
    this._placeholder = opts.placeholder;
  },

  render: function () {
    this.$el.html(
      template({
        content: this.model.get('content'),
        tips: this._tips.join(' '),
        warnings: this._warnings
      })
    );

    this._initViews();
    this._bindEvents();
    this._showErrors();
    return this;
  },

  _initViews: function () {
    var options = _.defaults(_.extend({}, this.model.toJSON()), this.options);

    var isReadOnly = options.readonly;
    var hasLineNumbers = options.lineNumbers;

    var extraKeys = {
      'Ctrl-S': this.triggerApplyEvent.bind(this),
      'Cmd-S': this.triggerApplyEvent.bind(this),
      'Ctrl-Space': this._completeIfAfterCtrlSpace.bind(this)
    };

    this.editor = CodeMirror.fromTextArea(this.$('.js-editor').get(0), {
      lineNumbers: hasLineNumbers,
      theme: 'material',
      mode: this._mode,
      scrollbarStyle: 'simple',
      lineWrapping: true,
      readOnly: isReadOnly,
      extraKeys: extraKeys,
      placeholder: this._placeholder
    });
    this.editor.on('change', _.debounce(this._onCodeMirrorChange.bind(this), 150), this);

    if (!_.isEmpty(this._addons)) {
      _.each(this._addons, function (addon) {
        var Class = ADDONS[addon];
        var addonView = new Class({
          editor: this.editor
        });
        addonView.bind('codeSaved', this.triggerApplyEvent, this);
        this.$el.append(addonView.el);
        this.addView(addonView);
      }, this);
    }

    if (this._hints) {
      this.editor.on('keyup', this._onInputRead);
    }

    this._toggleReadOnly();

    setTimeout(function () {
      this.editor && this.editor.refresh();
    }.bind(this), 0);
  },

  _completeIfAfterCtrlSpace: function (cm) {
    var autocompletePrefix = this._autocompletePrefix;
    var opts = {};
    var cur = cm.getCursor();

    if (autocompletePrefix &&
        cm.getRange(CodeMirror.Pos(cur.line, cur.ch - autocompletePrefix.length), cur) !== autocompletePrefix) {
      opts = { autocompletePrefix: autocompletePrefix };
    }

    return this._completeAfter(cm, opts);
  },

  updateHints: function (hints) {
    this._hints = hints;
  },

  _onKeyUpEditor: function (cm, event) {
    var code = event.keyCode;
    var hints = this._hints;
    var autocompleteChars = this._autocompleteChars - 1;
    var autocompletePrefix = this._autocompletePrefix;

    if (NOHINT.indexOf(code) === -1) {
      var self = this;

      if (this._autocompleteTimeout) clearTimeout(this._autocompleteTimeout);

      this._autocompleteTimeout = setTimeout(function () {
        var opts = {};
        var cur = cm.getCursor();
        var str = cm.getTokenAt(cur).string;
        str = str.toLowerCase();

        if (autocompletePrefix &&
            cm.getRange(CodeMirror.Pos(cur.line, cur.ch - autocompletePrefix.length), cur) !== autocompletePrefix) {
          opts = { autocompletePrefix: autocompletePrefix };
        }

        return self._completeAfter(cm, opts, function () {
          var autocompleteHandler = function (listItem) {
            // every list can be an array of strings or an array of objects {text, type}
            var hit = _.isObject(listItem) ? listItem.text : listItem;
            hit = hit.toLowerCase();
            return hit.indexOf(str) !== -1;
          };

          if (str.length > autocompleteChars) {
            var listHints = _.filter(hints, autocompleteHandler);

            return listHints.length > 0 || autocompletePrefix && autocompletePrefix === str;
          }
        });
      }, 150);
    }
  },

  _onCodeMirrorChange: function () {
    this.trigger('codeChanged');
  },

  _completeAfter: function (cm, opts, pred) {
    if (!pred || pred()) {
      if (!cm.state.completionActive) {
        this._showAutocomplete(cm, _.extend({}, opts));
      }
    }

    return CodeMirror.Pass;
  },

  _showAutocomplete: function (cm, opts) {
    var autocompletePrefix = opts && opts.autocompletePrefix;

    CodeMirror.showHint(cm, CodeMirror.hint['custom-list'], {
      completeSingle: false,
      list: this._hints,
      autocompletePrefix: autocompletePrefix,
      autocompleteSuffix: this._autocompleteSuffix
    });
  },

  _showWarning: function (warnings) {
    var $warning = this._getWarning();
    var hasNodes = $warning.children().length;

    if (warnings && !hasNodes) {
      $warning.append(this._warningTemplate(warnings));
    }
  },

  _hideWarning: function () {
    var $warning = this._getWarning();
    var hasNodes = $warning.children().length;

    if (hasNodes) {
      $warning.children()[0].remove();
    }
  },

  _bindEvents: function () {
    var self = this;
    this.editor.on('change', function (editor, changed) {
      var content = self.getContent();
      var dataService = self._containsDataService(content);

      if (dataService) {
        self._showWarning('Quota error ' + dataService);
      } else {
        self._hideWarning();
      }

      self.model.set('content', content, { silent: true });
    });

    this.model.on('change:content', function () {
      this.setContent(this.model.get('content'));
    }, this);

    this.model.on('change:readonly', this._toggleReadOnly, this);

    this.model.on('change:errors', function () {
      this._showErrors();
    }, this);

    this.model.on('undo redo', function () {
      this.setContent(this.model.get('content'));
    }, this);
  },

  _toggleReadOnly: function () {
    var isReadOnly = !!this.model.get('readonly');
    this.editor.setOption('readOnly', isReadOnly);
    if (isReadOnly) {
      this.editor.setOption('theme', '');
      this._getInfo().hide();
    } else {
      this.editor.setOption('theme', 'material');
      this._getInfo().show();
    }
  },

  search: function (query, caseInsensitive) {
    var cursor = this.editor.getSearchCursor(query, null, true);
    cursor.find();
    return cursor.pos;
  },

  markReadOnly: function (from, to) {
    var options = {readOnly: true, inclusiveLeft: true};
    this.editor.markText(from, to, options);

    for (var i = from.line; i <= to.line; i++) {
      this.editor.addLineClass(i, 'background', 'CodeMirror-readonlyLine');
    }
  },

  setContent: function (value) {
    this.editor.setValue(value);
  },

  getContent: function () {
    return this.editor.getValue();
  },

  triggerApplyEvent: function () {
    this.trigger('codeSaved', this.getContent(), this);
  },

  destroyEditor: function () {
    this.editor.off('change');
    var el = this.editor.getWrapperElement();
    var parent = el.parentNode;
    parent && parent.removeChild(el);
    this.editor = null;
  },

  _getInfo: function () {
    return this.$('.js-console');
  },

  _getConsole: function () {
    return this.$('.js-console-error');
  },

  _getWarning: function () {
    return this.$('.js-warning');
  },

  _getCode: function () {
    return this.$('.CodeMirror-code');
  },

  _containsDataService: function (content) {
    return _.find(DATA_SERVICES, function (dataService) {
      return content.indexOf(dataService) !== -1;
    });
  },

  _removeErrors: function () {
    this._getConsole().empty();
    _.each(this._lineWithErrors, function ($line) {
      $line.find('.CodeMirror-bullet').remove();
      $line.find('.CodeMirror-linenumber').removeClass('has-error');
    });

    this._lineWithErrors = [];
  },

  _showErrors: function () {
    var errors = this.model.get('errors');
    this._removeErrors();

    if (errors && errors.length > 0) {
      _.each(errors, function (err) {
        this._renderError(err);
        this._renderBullet(err);
      }, this);
    }
  },

  _renderBullet: function (error) {
    var line = error.line;
    var $line;
    if (line) {
      $line = this._getCode().children().eq(+line - 1);
      $line.append(bulletTemplate);
      $line.find('.CodeMirror-linenumber').addClass('has-error');
      this._lineWithErrors.push($line);
    }
  },

  _renderError: function (error) {
    this._getConsole().append(this._errorTemplate(error));
  },

  clean: function () {
    this.destroyEditor();
    CoreView.prototype.clean.apply(this);
  }
});