browser/devtools/sourceeditor/editor.js

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/browser/devtools/sourceeditor/editor.js	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,1072 @@
     1.4 +/* vim:set ts=2 sw=2 sts=2 et tw=80:
     1.5 + * This Source Code Form is subject to the terms of the Mozilla Public
     1.6 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     1.7 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.8 +
     1.9 +"use strict";
    1.10 +
    1.11 +const { Cu, Cc, Ci, components } = require("chrome");
    1.12 +
    1.13 +const TAB_SIZE    = "devtools.editor.tabsize";
    1.14 +const EXPAND_TAB  = "devtools.editor.expandtab";
    1.15 +const KEYMAP      = "devtools.editor.keymap";
    1.16 +const AUTO_CLOSE  = "devtools.editor.autoclosebrackets";
    1.17 +const DETECT_INDENT = "devtools.editor.detectindentation";
    1.18 +const DETECT_INDENT_MAX_LINES = 500;
    1.19 +const L10N_BUNDLE = "chrome://browser/locale/devtools/sourceeditor.properties";
    1.20 +const XUL_NS      = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
    1.21 +
    1.22 +// Maximum allowed margin (in number of lines) from top or bottom of the editor
    1.23 +// while shifting to a line which was initially out of view.
    1.24 +const MAX_VERTICAL_OFFSET = 3;
    1.25 +
    1.26 +const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
    1.27 +const events  = require("devtools/toolkit/event-emitter");
    1.28 +
    1.29 +Cu.import("resource://gre/modules/Services.jsm");
    1.30 +const L10N = Services.strings.createBundle(L10N_BUNDLE);
    1.31 +
    1.32 +// CM_STYLES, CM_SCRIPTS and CM_IFRAME represent the HTML,
    1.33 +// JavaScript and CSS that is injected into an iframe in
    1.34 +// order to initialize a CodeMirror instance.
    1.35 +
    1.36 +const CM_STYLES   = [
    1.37 +  "chrome://browser/skin/devtools/common.css",
    1.38 +  "chrome://browser/content/devtools/codemirror/codemirror.css",
    1.39 +  "chrome://browser/content/devtools/codemirror/dialog.css",
    1.40 +  "chrome://browser/content/devtools/codemirror/mozilla.css"
    1.41 +];
    1.42 +
    1.43 +const CM_SCRIPTS  = [
    1.44 +  "chrome://browser/content/devtools/theme-switching.js",
    1.45 +  "chrome://browser/content/devtools/codemirror/codemirror.js",
    1.46 +  "chrome://browser/content/devtools/codemirror/dialog.js",
    1.47 +  "chrome://browser/content/devtools/codemirror/searchcursor.js",
    1.48 +  "chrome://browser/content/devtools/codemirror/search.js",
    1.49 +  "chrome://browser/content/devtools/codemirror/matchbrackets.js",
    1.50 +  "chrome://browser/content/devtools/codemirror/closebrackets.js",
    1.51 +  "chrome://browser/content/devtools/codemirror/comment.js",
    1.52 +  "chrome://browser/content/devtools/codemirror/javascript.js",
    1.53 +  "chrome://browser/content/devtools/codemirror/xml.js",
    1.54 +  "chrome://browser/content/devtools/codemirror/css.js",
    1.55 +  "chrome://browser/content/devtools/codemirror/htmlmixed.js",
    1.56 +  "chrome://browser/content/devtools/codemirror/clike.js",
    1.57 +  "chrome://browser/content/devtools/codemirror/activeline.js",
    1.58 +  "chrome://browser/content/devtools/codemirror/trailingspace.js",
    1.59 +  "chrome://browser/content/devtools/codemirror/emacs.js",
    1.60 +  "chrome://browser/content/devtools/codemirror/vim.js",
    1.61 +  "chrome://browser/content/devtools/codemirror/sublime.js",
    1.62 +  "chrome://browser/content/devtools/codemirror/foldcode.js",
    1.63 +  "chrome://browser/content/devtools/codemirror/brace-fold.js",
    1.64 +  "chrome://browser/content/devtools/codemirror/comment-fold.js",
    1.65 +  "chrome://browser/content/devtools/codemirror/xml-fold.js",
    1.66 +  "chrome://browser/content/devtools/codemirror/foldgutter.js"
    1.67 +];
    1.68 +
    1.69 +const CM_IFRAME   =
    1.70 +  "data:text/html;charset=utf8,<!DOCTYPE html>" +
    1.71 +  "<html dir='ltr'>" +
    1.72 +  "  <head>" +
    1.73 +  "    <style>" +
    1.74 +  "      html, body { height: 100%; }" +
    1.75 +  "      body { margin: 0; overflow: hidden; }" +
    1.76 +  "      .CodeMirror { width: 100%; height: 100% !important; line-height: 1.25 !important;}" +
    1.77 +  "    </style>" +
    1.78 +[ "    <link rel='stylesheet' href='" + style + "'>" for (style of CM_STYLES) ].join("\n") +
    1.79 +  "  </head>" +
    1.80 +  "  <body class='theme-body devtools-monospace'></body>" +
    1.81 +  "</html>";
    1.82 +
    1.83 +const CM_MAPPING = [
    1.84 +  "focus",
    1.85 +  "hasFocus",
    1.86 +  "lineCount",
    1.87 +  "somethingSelected",
    1.88 +  "getCursor",
    1.89 +  "setSelection",
    1.90 +  "getSelection",
    1.91 +  "replaceSelection",
    1.92 +  "extendSelection",
    1.93 +  "undo",
    1.94 +  "redo",
    1.95 +  "clearHistory",
    1.96 +  "openDialog",
    1.97 +  "refresh",
    1.98 +  "getScrollInfo",
    1.99 +  "getOption",
   1.100 +  "setOption"
   1.101 +];
   1.102 +
   1.103 +const { cssProperties, cssValues, cssColors } = getCSSKeywords();
   1.104 +
   1.105 +const editors = new WeakMap();
   1.106 +
   1.107 +Editor.modes = {
   1.108 +  text: { name: "text" },
   1.109 +  html: { name: "htmlmixed" },
   1.110 +  css:  { name: "css" },
   1.111 +  js:   { name: "javascript" },
   1.112 +  vs:   { name: "x-shader/x-vertex" },
   1.113 +  fs:   { name: "x-shader/x-fragment" }
   1.114 +};
   1.115 +
   1.116 +/**
   1.117 + * A very thin wrapper around CodeMirror. Provides a number
   1.118 + * of helper methods to make our use of CodeMirror easier and
   1.119 + * another method, appendTo, to actually create and append
   1.120 + * the CodeMirror instance.
   1.121 + *
   1.122 + * Note that Editor doesn't expose CodeMirror instance to the
   1.123 + * outside world.
   1.124 + *
   1.125 + * Constructor accepts one argument, config. It is very
   1.126 + * similar to the CodeMirror configuration object so for most
   1.127 + * properties go to CodeMirror's documentation (see below).
   1.128 + *
   1.129 + * Other than that, it accepts one additional and optional
   1.130 + * property contextMenu. This property should be an ID of
   1.131 + * an element we can use as a context menu.
   1.132 + *
   1.133 + * This object is also an event emitter.
   1.134 + *
   1.135 + * CodeMirror docs: http://codemirror.net/doc/manual.html
   1.136 + */
   1.137 +function Editor(config) {
   1.138 +  const tabSize = Services.prefs.getIntPref(TAB_SIZE);
   1.139 +  const useTabs = !Services.prefs.getBoolPref(EXPAND_TAB);
   1.140 +  const keyMap = Services.prefs.getCharPref(KEYMAP);
   1.141 +  const useAutoClose = Services.prefs.getBoolPref(AUTO_CLOSE);
   1.142 +
   1.143 +  this.version = null;
   1.144 +  this.config = {
   1.145 +    value:             "",
   1.146 +    mode:              Editor.modes.text,
   1.147 +    indentUnit:        tabSize,
   1.148 +    tabSize:           tabSize,
   1.149 +    contextMenu:       null,
   1.150 +    matchBrackets:     true,
   1.151 +    extraKeys:         {},
   1.152 +    indentWithTabs:    useTabs,
   1.153 +    styleActiveLine:   true,
   1.154 +    autoCloseBrackets: "()[]{}''\"\"",
   1.155 +    autoCloseEnabled:  useAutoClose,
   1.156 +    theme:             "mozilla"
   1.157 +  };
   1.158 +
   1.159 +  // Additional shortcuts.
   1.160 +  this.config.extraKeys[Editor.keyFor("jumpToLine")] = () => this.jumpToLine();
   1.161 +  this.config.extraKeys[Editor.keyFor("moveLineUp", { noaccel: true })] = () => this.moveLineUp();
   1.162 +  this.config.extraKeys[Editor.keyFor("moveLineDown", { noaccel: true })] = () => this.moveLineDown();
   1.163 +  this.config.extraKeys[Editor.keyFor("toggleComment")] = "toggleComment";
   1.164 +
   1.165 +  // Disable ctrl-[ and ctrl-] because toolbox uses those shortcuts.
   1.166 +  this.config.extraKeys[Editor.keyFor("indentLess")] = false;
   1.167 +  this.config.extraKeys[Editor.keyFor("indentMore")] = false;
   1.168 +
   1.169 +  // If alternative keymap is provided, use it.
   1.170 +  if (keyMap === "emacs" || keyMap === "vim" || keyMap === "sublime")
   1.171 +    this.config.keyMap = keyMap;
   1.172 +
   1.173 +  // Overwrite default config with user-provided, if needed.
   1.174 +  Object.keys(config).forEach((k) => {
   1.175 +    if (k != "extraKeys") {
   1.176 +      this.config[k] = config[k];
   1.177 +      return;
   1.178 +    }
   1.179 +
   1.180 +    if (!config.extraKeys)
   1.181 +      return;
   1.182 +
   1.183 +    Object.keys(config.extraKeys).forEach((key) => {
   1.184 +      this.config.extraKeys[key] = config.extraKeys[key];
   1.185 +    });
   1.186 +  });
   1.187 +
   1.188 +  // Set the code folding gutter, if needed.
   1.189 +  if (this.config.enableCodeFolding) {
   1.190 +    this.config.foldGutter = true;
   1.191 +
   1.192 +    if (!this.config.gutters) {
   1.193 +      this.config.gutters = this.config.lineNumbers ? ["CodeMirror-linenumbers"] : [];
   1.194 +      this.config.gutters.push("CodeMirror-foldgutter");
   1.195 +    }
   1.196 +  }
   1.197 +
   1.198 +  // Configure automatic bracket closing.
   1.199 +  if (!this.config.autoCloseEnabled)
   1.200 +    this.config.autoCloseBrackets = false;
   1.201 +
   1.202 +  // Overwrite default tab behavior. If something is selected,
   1.203 +  // indent those lines. If nothing is selected and we're
   1.204 +  // indenting with tabs, insert one tab. Otherwise insert N
   1.205 +  // whitespaces where N == indentUnit option.
   1.206 +  this.config.extraKeys.Tab = (cm) => {
   1.207 +    if (cm.somethingSelected()) {
   1.208 +      cm.indentSelection("add");
   1.209 +      return;
   1.210 +    }
   1.211 +
   1.212 +    if (this.config.indentWithTabs) {
   1.213 +      cm.replaceSelection("\t", "end", "+input");
   1.214 +      return;
   1.215 +    }
   1.216 +
   1.217 +    var num = cm.getOption("indentUnit");
   1.218 +    if (cm.getCursor().ch !== 0) num -= 1;
   1.219 +    cm.replaceSelection(" ".repeat(num), "end", "+input");
   1.220 +  };
   1.221 +
   1.222 +  events.decorate(this);
   1.223 +}
   1.224 +
   1.225 +Editor.prototype = {
   1.226 +  container: null,
   1.227 +  version: null,
   1.228 +  config: null,
   1.229 +
   1.230 +  /**
   1.231 +   * Appends the current Editor instance to the element specified by
   1.232 +   * 'el'. You can also provide your won iframe to host the editor as
   1.233 +   * an optional second parameter. This method actually creates and
   1.234 +   * loads CodeMirror and all its dependencies.
   1.235 +   *
   1.236 +   * This method is asynchronous and returns a promise.
   1.237 +   */
   1.238 +  appendTo: function (el, env) {
   1.239 +    let def = promise.defer();
   1.240 +    let cm  = editors.get(this);
   1.241 +
   1.242 +    if (!env)
   1.243 +      env = el.ownerDocument.createElementNS(XUL_NS, "iframe");
   1.244 +
   1.245 +    env.flex = 1;
   1.246 +
   1.247 +    if (cm)
   1.248 +      throw new Error("You can append an editor only once.");
   1.249 +
   1.250 +    let onLoad = () => {
   1.251 +      // Once the iframe is loaded, we can inject CodeMirror
   1.252 +      // and its dependencies into its DOM.
   1.253 +
   1.254 +      env.removeEventListener("load", onLoad, true);
   1.255 +      let win = env.contentWindow.wrappedJSObject;
   1.256 +
   1.257 +      CM_SCRIPTS.forEach((url) =>
   1.258 +        Services.scriptloader.loadSubScript(url, win, "utf8"));
   1.259 +
   1.260 +      // Replace the propertyKeywords, colorKeywords and valueKeywords
   1.261 +      // properties of the CSS MIME type with the values provided by Gecko.
   1.262 +      let cssSpec = win.CodeMirror.resolveMode("text/css");
   1.263 +      cssSpec.propertyKeywords = cssProperties;
   1.264 +      cssSpec.colorKeywords = cssColors;
   1.265 +      cssSpec.valueKeywords = cssValues;
   1.266 +      win.CodeMirror.defineMIME("text/css", cssSpec);
   1.267 +
   1.268 +      let scssSpec = win.CodeMirror.resolveMode("text/x-scss");
   1.269 +      scssSpec.propertyKeywords = cssProperties;
   1.270 +      scssSpec.colorKeywords = cssColors;
   1.271 +      scssSpec.valueKeywords = cssValues;
   1.272 +      win.CodeMirror.defineMIME("text/x-scss", scssSpec);
   1.273 +
   1.274 +      win.CodeMirror.commands.save = () => this.emit("save");
   1.275 +
   1.276 +      // Create a CodeMirror instance add support for context menus,
   1.277 +      // overwrite the default controller (otherwise items in the top and
   1.278 +      // context menus won't work).
   1.279 +
   1.280 +      cm = win.CodeMirror(win.document.body, this.config);
   1.281 +      cm.getWrapperElement().addEventListener("contextmenu", (ev) => {
   1.282 +        ev.preventDefault();
   1.283 +        if (!this.config.contextMenu) return;
   1.284 +        let popup = el.ownerDocument.getElementById(this.config.contextMenu);
   1.285 +        popup.openPopupAtScreen(ev.screenX, ev.screenY, true);
   1.286 +      }, false);
   1.287 +
   1.288 +      cm.on("focus", () => this.emit("focus"));
   1.289 +      cm.on("scroll", () => this.emit("scroll"));
   1.290 +      cm.on("change", () => {
   1.291 +        this.emit("change");
   1.292 +        if (!this._lastDirty) {
   1.293 +          this._lastDirty = true;
   1.294 +          this.emit("dirty-change");
   1.295 +        }
   1.296 +      });
   1.297 +      cm.on("cursorActivity", (cm) => this.emit("cursorActivity"));
   1.298 +
   1.299 +      cm.on("gutterClick", (cm, line, gutter, ev) => {
   1.300 +        let head = { line: line, ch: 0 };
   1.301 +        let tail = { line: line, ch: this.getText(line).length };
   1.302 +
   1.303 +        // Shift-click on a gutter selects the whole line.
   1.304 +        if (ev.shiftKey) {
   1.305 +          cm.setSelection(head, tail);
   1.306 +          return;
   1.307 +        }
   1.308 +
   1.309 +        this.emit("gutterClick", line);
   1.310 +      });
   1.311 +
   1.312 +      win.CodeMirror.defineExtension("l10n", (name) => {
   1.313 +        return L10N.GetStringFromName(name);
   1.314 +      });
   1.315 +
   1.316 +      cm.getInputField().controllers.insertControllerAt(0, controller(this));
   1.317 +
   1.318 +      this.container = env;
   1.319 +      editors.set(this, cm);
   1.320 +
   1.321 +      this.resetIndentUnit();
   1.322 +
   1.323 +      def.resolve();
   1.324 +    };
   1.325 +
   1.326 +    env.addEventListener("load", onLoad, true);
   1.327 +    env.setAttribute("src", CM_IFRAME);
   1.328 +    el.appendChild(env);
   1.329 +
   1.330 +    this.once("destroy", () => el.removeChild(env));
   1.331 +    return def.promise;
   1.332 +  },
   1.333 +
   1.334 +  /**
   1.335 +   * Returns the currently active highlighting mode.
   1.336 +   * See Editor.modes for the list of all suppoert modes.
   1.337 +   */
   1.338 +  getMode: function () {
   1.339 +    return this.getOption("mode");
   1.340 +  },
   1.341 +
   1.342 +  /**
   1.343 +   * Changes the value of a currently used highlighting mode.
   1.344 +   * See Editor.modes for the list of all suppoert modes.
   1.345 +   */
   1.346 +  setMode: function (value) {
   1.347 +    this.setOption("mode", value);
   1.348 +  },
   1.349 +
   1.350 +  /**
   1.351 +   * Returns text from the text area. If line argument is provided
   1.352 +   * the method returns only that line.
   1.353 +   */
   1.354 +  getText: function (line) {
   1.355 +    let cm = editors.get(this);
   1.356 +
   1.357 +    if (line == null)
   1.358 +      return cm.getValue();
   1.359 +
   1.360 +    let info = cm.lineInfo(line);
   1.361 +    return info ? cm.lineInfo(line).text : "";
   1.362 +  },
   1.363 +
   1.364 +  /**
   1.365 +   * Replaces whatever is in the text area with the contents of
   1.366 +   * the 'value' argument.
   1.367 +   */
   1.368 +  setText: function (value) {
   1.369 +    let cm = editors.get(this);
   1.370 +    cm.setValue(value);
   1.371 +
   1.372 +    this.resetIndentUnit();
   1.373 +  },
   1.374 +
   1.375 +  /**
   1.376 +   * Set the editor's indentation based on the current prefs and
   1.377 +   * re-detect indentation if we should.
   1.378 +   */
   1.379 +  resetIndentUnit: function() {
   1.380 +    let cm = editors.get(this);
   1.381 +
   1.382 +    let indentWithTabs = !Services.prefs.getBoolPref(EXPAND_TAB);
   1.383 +    let indentUnit = Services.prefs.getIntPref(TAB_SIZE);
   1.384 +    let shouldDetect = Services.prefs.getBoolPref(DETECT_INDENT);
   1.385 +
   1.386 +    cm.setOption("tabSize", indentUnit);
   1.387 +
   1.388 +    if (shouldDetect) {
   1.389 +      let indent = detectIndentation(this);
   1.390 +      if (indent != null) {
   1.391 +        indentWithTabs = indent.tabs;
   1.392 +        indentUnit = indent.spaces ? indent.spaces : indentUnit;
   1.393 +      }
   1.394 +    }
   1.395 +
   1.396 +    cm.setOption("indentUnit", indentUnit);
   1.397 +    cm.setOption("indentWithTabs", indentWithTabs);
   1.398 +  },
   1.399 +
   1.400 +  /**
   1.401 +   * Replaces contents of a text area within the from/to {line, ch}
   1.402 +   * range. If neither from nor to arguments are provided works
   1.403 +   * exactly like setText. If only from object is provided, inserts
   1.404 +   * text at that point, *overwriting* as many characters as needed.
   1.405 +   */
   1.406 +  replaceText: function (value, from, to) {
   1.407 +    let cm = editors.get(this);
   1.408 +
   1.409 +    if (!from) {
   1.410 +      this.setText(value);
   1.411 +      return;
   1.412 +    }
   1.413 +
   1.414 +    if (!to) {
   1.415 +      let text = cm.getRange({ line: 0, ch: 0 }, from);
   1.416 +      this.setText(text + value);
   1.417 +      return;
   1.418 +    }
   1.419 +
   1.420 +    cm.replaceRange(value, from, to);
   1.421 +  },
   1.422 +
   1.423 +  /**
   1.424 +   * Inserts text at the specified {line, ch} position, shifting existing
   1.425 +   * contents as necessary.
   1.426 +   */
   1.427 +  insertText: function (value, at) {
   1.428 +    let cm = editors.get(this);
   1.429 +    cm.replaceRange(value, at, at);
   1.430 +  },
   1.431 +
   1.432 +  /**
   1.433 +   * Deselects contents of the text area.
   1.434 +   */
   1.435 +  dropSelection: function () {
   1.436 +    if (!this.somethingSelected())
   1.437 +      return;
   1.438 +
   1.439 +    this.setCursor(this.getCursor());
   1.440 +  },
   1.441 +
   1.442 +  /**
   1.443 +   * Returns true if there is more than one selection in the editor.
   1.444 +   */
   1.445 +  hasMultipleSelections: function () {
   1.446 +    let cm = editors.get(this);
   1.447 +    return cm.listSelections().length > 1;
   1.448 +  },
   1.449 +
   1.450 +  /**
   1.451 +   * Gets the first visible line number in the editor.
   1.452 +   */
   1.453 +  getFirstVisibleLine: function () {
   1.454 +    let cm = editors.get(this);
   1.455 +    return cm.lineAtHeight(0, "local");
   1.456 +  },
   1.457 +
   1.458 +  /**
   1.459 +   * Scrolls the view such that the given line number is the first visible line.
   1.460 +   */
   1.461 +  setFirstVisibleLine: function (line) {
   1.462 +    let cm = editors.get(this);
   1.463 +    let { top } = cm.charCoords({line: line, ch: 0}, "local");
   1.464 +    cm.scrollTo(0, top);
   1.465 +  },
   1.466 +
   1.467 +  /**
   1.468 +   * Sets the cursor to the specified {line, ch} position with an additional
   1.469 +   * option to align the line at the "top", "center" or "bottom" of the editor
   1.470 +   * with "top" being default value.
   1.471 +   */
   1.472 +  setCursor: function ({line, ch}, align) {
   1.473 +    let cm = editors.get(this);
   1.474 +    this.alignLine(line, align);
   1.475 +    cm.setCursor({line: line, ch: ch});
   1.476 +  },
   1.477 +
   1.478 +  /**
   1.479 +   * Aligns the provided line to either "top", "center" or "bottom" of the
   1.480 +   * editor view with a maximum margin of MAX_VERTICAL_OFFSET lines from top or
   1.481 +   * bottom.
   1.482 +   */
   1.483 +  alignLine: function(line, align) {
   1.484 +    let cm = editors.get(this);
   1.485 +    let from = cm.lineAtHeight(0, "page");
   1.486 +    let to = cm.lineAtHeight(cm.getWrapperElement().clientHeight, "page");
   1.487 +    let linesVisible = to - from;
   1.488 +    let halfVisible = Math.round(linesVisible/2);
   1.489 +
   1.490 +    // If the target line is in view, skip the vertical alignment part.
   1.491 +    if (line <= to && line >= from) {
   1.492 +      return;
   1.493 +    }
   1.494 +
   1.495 +    // Setting the offset so that the line always falls in the upper half
   1.496 +    // of visible lines (lower half for bottom aligned).
   1.497 +    // MAX_VERTICAL_OFFSET is the maximum allowed value.
   1.498 +    let offset = Math.min(halfVisible, MAX_VERTICAL_OFFSET);
   1.499 +
   1.500 +    let topLine = {
   1.501 +      "center": Math.max(line - halfVisible, 0),
   1.502 +      "bottom": Math.max(line - linesVisible + offset, 0),
   1.503 +      "top": Math.max(line - offset, 0)
   1.504 +    }[align || "top"] || offset;
   1.505 +
   1.506 +    // Bringing down the topLine to total lines in the editor if exceeding.
   1.507 +    topLine = Math.min(topLine, this.lineCount());
   1.508 +    this.setFirstVisibleLine(topLine);
   1.509 +  },
   1.510 +
   1.511 +  /**
   1.512 +   * Returns whether a marker of a specified class exists in a line's gutter.
   1.513 +   */
   1.514 +  hasMarker: function (line, gutterName, markerClass) {
   1.515 +    let cm = editors.get(this);
   1.516 +    let info = cm.lineInfo(line);
   1.517 +    if (!info)
   1.518 +      return false;
   1.519 +
   1.520 +    let gutterMarkers = info.gutterMarkers;
   1.521 +    if (!gutterMarkers)
   1.522 +      return false;
   1.523 +
   1.524 +    let marker = gutterMarkers[gutterName];
   1.525 +    if (!marker)
   1.526 +      return false;
   1.527 +
   1.528 +    return marker.classList.contains(markerClass);
   1.529 +  },
   1.530 +
   1.531 +  /**
   1.532 +   * Adds a marker with a specified class to a line's gutter. If another marker
   1.533 +   * exists on that line, the new marker class is added to its class list.
   1.534 +   */
   1.535 +  addMarker: function (line, gutterName, markerClass) {
   1.536 +    let cm = editors.get(this);
   1.537 +    let info = cm.lineInfo(line);
   1.538 +    if (!info)
   1.539 +      return;
   1.540 +
   1.541 +    let gutterMarkers = info.gutterMarkers;
   1.542 +    if (gutterMarkers) {
   1.543 +      let marker = gutterMarkers[gutterName];
   1.544 +      if (marker) {
   1.545 +        marker.classList.add(markerClass);
   1.546 +        return;
   1.547 +      }
   1.548 +    }
   1.549 +
   1.550 +    let marker = cm.getWrapperElement().ownerDocument.createElement("div");
   1.551 +    marker.className = markerClass;
   1.552 +    cm.setGutterMarker(info.line, gutterName, marker);
   1.553 +  },
   1.554 +
   1.555 +  /**
   1.556 +   * The reverse of addMarker. Removes a marker of a specified class from a
   1.557 +   * line's gutter.
   1.558 +   */
   1.559 +  removeMarker: function (line, gutterName, markerClass) {
   1.560 +    if (!this.hasMarker(line, gutterName, markerClass))
   1.561 +      return;
   1.562 +
   1.563 +    let cm = editors.get(this);
   1.564 +    cm.lineInfo(line).gutterMarkers[gutterName].classList.remove(markerClass);
   1.565 +  },
   1.566 +
   1.567 +  /**
   1.568 +   * Remove all gutter markers in the gutter with the given name.
   1.569 +   */
   1.570 +  removeAllMarkers: function (gutterName) {
   1.571 +    let cm = editors.get(this);
   1.572 +    cm.clearGutter(gutterName);
   1.573 +  },
   1.574 +
   1.575 +  /**
   1.576 +   * Handles attaching a set of events listeners on a marker. They should
   1.577 +   * be passed as an object literal with keys as event names and values as
   1.578 +   * function listeners. The line number, marker node and optional data
   1.579 +   * will be passed as arguments to the function listener.
   1.580 +   *
   1.581 +   * You don't need to worry about removing these event listeners.
   1.582 +   * They're automatically orphaned when clearing markers.
   1.583 +   */
   1.584 +  setMarkerListeners: function(line, gutterName, markerClass, events, data) {
   1.585 +    if (!this.hasMarker(line, gutterName, markerClass))
   1.586 +      return;
   1.587 +
   1.588 +    let cm = editors.get(this);
   1.589 +    let marker = cm.lineInfo(line).gutterMarkers[gutterName];
   1.590 +
   1.591 +    for (let name in events) {
   1.592 +      let listener = events[name].bind(this, line, marker, data);
   1.593 +      marker.addEventListener(name, listener);
   1.594 +    }
   1.595 +  },
   1.596 +
   1.597 +  /**
   1.598 +   * Returns whether a line is decorated using the specified class name.
   1.599 +   */
   1.600 +  hasLineClass: function (line, className) {
   1.601 +    let cm = editors.get(this);
   1.602 +    let info = cm.lineInfo(line);
   1.603 +
   1.604 +    if (!info || !info.wrapClass)
   1.605 +      return false;
   1.606 +
   1.607 +    return info.wrapClass.split(" ").indexOf(className) != -1;
   1.608 +  },
   1.609 +
   1.610 +  /**
   1.611 +   * Set a CSS class name for the given line, including the text and gutter.
   1.612 +   */
   1.613 +  addLineClass: function (line, className) {
   1.614 +    let cm = editors.get(this);
   1.615 +    cm.addLineClass(line, "wrap", className);
   1.616 +  },
   1.617 +
   1.618 +  /**
   1.619 +   * The reverse of addLineClass.
   1.620 +   */
   1.621 +  removeLineClass: function (line, className) {
   1.622 +    let cm = editors.get(this);
   1.623 +    cm.removeLineClass(line, "wrap", className);
   1.624 +  },
   1.625 +
   1.626 +  /**
   1.627 +   * Mark a range of text inside the two {line, ch} bounds. Since the range may
   1.628 +   * be modified, for example, when typing text, this method returns a function
   1.629 +   * that can be used to remove the mark.
   1.630 +   */
   1.631 +  markText: function(from, to, className = "marked-text") {
   1.632 +    let cm = editors.get(this);
   1.633 +    let text = cm.getRange(from, to);
   1.634 +    let span = cm.getWrapperElement().ownerDocument.createElement("span");
   1.635 +    span.className = className;
   1.636 +    span.textContent = text;
   1.637 +
   1.638 +    let mark = cm.markText(from, to, { replacedWith: span });
   1.639 +    return {
   1.640 +      anchor: span,
   1.641 +      clear: () => mark.clear()
   1.642 +    };
   1.643 +  },
   1.644 +
   1.645 +  /**
   1.646 +   * Calculates and returns one or more {line, ch} objects for
   1.647 +   * a zero-based index who's value is relative to the start of
   1.648 +   * the editor's text.
   1.649 +   *
   1.650 +   * If only one argument is given, this method returns a single
   1.651 +   * {line,ch} object. Otherwise it returns an array.
   1.652 +   */
   1.653 +  getPosition: function (...args) {
   1.654 +    let cm = editors.get(this);
   1.655 +    let res = args.map((ind) => cm.posFromIndex(ind));
   1.656 +    return args.length === 1 ? res[0] : res;
   1.657 +  },
   1.658 +
   1.659 +  /**
   1.660 +   * The reverse of getPosition. Similarly to getPosition this
   1.661 +   * method returns a single value if only one argument was given
   1.662 +   * and an array otherwise.
   1.663 +   */
   1.664 +  getOffset: function (...args) {
   1.665 +    let cm = editors.get(this);
   1.666 +    let res = args.map((pos) => cm.indexFromPos(pos));
   1.667 +    return args.length > 1 ? res : res[0];
   1.668 +  },
   1.669 +
   1.670 +  /**
   1.671 +   * Returns a {line, ch} object that corresponds to the
   1.672 +   * left, top coordinates.
   1.673 +   */
   1.674 +  getPositionFromCoords: function ({left, top}) {
   1.675 +    let cm = editors.get(this);
   1.676 +    return cm.coordsChar({ left: left, top: top });
   1.677 +  },
   1.678 +
   1.679 +  /**
   1.680 +   * The reverse of getPositionFromCoords. Similarly, returns a {left, top}
   1.681 +   * object that corresponds to the specified line and character number.
   1.682 +   */
   1.683 +  getCoordsFromPosition: function ({line, ch}) {
   1.684 +    let cm = editors.get(this);
   1.685 +    return cm.charCoords({ line: ~~line, ch: ~~ch });
   1.686 +  },
   1.687 +
   1.688 +  /**
   1.689 +   * Returns true if there's something to undo and false otherwise.
   1.690 +   */
   1.691 +  canUndo: function () {
   1.692 +    let cm = editors.get(this);
   1.693 +    return cm.historySize().undo > 0;
   1.694 +  },
   1.695 +
   1.696 +  /**
   1.697 +   * Returns true if there's something to redo and false otherwise.
   1.698 +   */
   1.699 +  canRedo: function () {
   1.700 +    let cm = editors.get(this);
   1.701 +    return cm.historySize().redo > 0;
   1.702 +  },
   1.703 +
   1.704 +  /**
   1.705 +   * Marks the contents as clean and returns the current
   1.706 +   * version number.
   1.707 +   */
   1.708 +  setClean: function () {
   1.709 +    let cm = editors.get(this);
   1.710 +    this.version = cm.changeGeneration();
   1.711 +    this._lastDirty = false;
   1.712 +    this.emit("dirty-change");
   1.713 +    return this.version;
   1.714 +  },
   1.715 +
   1.716 +  /**
   1.717 +   * Returns true if contents of the text area are
   1.718 +   * clean i.e. no changes were made since the last version.
   1.719 +   */
   1.720 +  isClean: function () {
   1.721 +    let cm = editors.get(this);
   1.722 +    return cm.isClean(this.version);
   1.723 +  },
   1.724 +
   1.725 +  /**
   1.726 +   * This method opens an in-editor dialog asking for a line to
   1.727 +   * jump to. Once given, it changes cursor to that line.
   1.728 +   */
   1.729 +  jumpToLine: function () {
   1.730 +    let doc = editors.get(this).getWrapperElement().ownerDocument;
   1.731 +    let div = doc.createElement("div");
   1.732 +    let inp = doc.createElement("input");
   1.733 +    let txt = doc.createTextNode(L10N.GetStringFromName("gotoLineCmd.promptTitle"));
   1.734 +
   1.735 +    inp.type = "text";
   1.736 +    inp.style.width = "10em";
   1.737 +    inp.style.MozMarginStart = "1em";
   1.738 +
   1.739 +    div.appendChild(txt);
   1.740 +    div.appendChild(inp);
   1.741 +
   1.742 +    this.openDialog(div, (line) => this.setCursor({ line: line - 1, ch: 0 }));
   1.743 +  },
   1.744 +
   1.745 +  /**
   1.746 +   * Moves the content of the current line or the lines selected up a line.
   1.747 +   */
   1.748 +  moveLineUp: function () {
   1.749 +    let cm = editors.get(this);
   1.750 +    let start = cm.getCursor("start");
   1.751 +    let end = cm.getCursor("end");
   1.752 +
   1.753 +    if (start.line === 0)
   1.754 +      return;
   1.755 +
   1.756 +    // Get the text in the lines selected or the current line of the cursor
   1.757 +    // and append the text of the previous line.
   1.758 +    let value;
   1.759 +    if (start.line !== end.line) {
   1.760 +      value = cm.getRange({ line: start.line, ch: 0 },
   1.761 +        { line: end.line, ch: cm.getLine(end.line).length }) + "\n";
   1.762 +    } else {
   1.763 +      value = cm.getLine(start.line) + "\n";
   1.764 +    }
   1.765 +    value += cm.getLine(start.line - 1);
   1.766 +
   1.767 +    // Replace the previous line and the currently selected lines with the new
   1.768 +    // value and maintain the selection of the text.
   1.769 +    cm.replaceRange(value, { line: start.line - 1, ch: 0 },
   1.770 +      { line: end.line, ch: cm.getLine(end.line).length });
   1.771 +    cm.setSelection({ line: start.line - 1, ch: start.ch },
   1.772 +      { line: end.line - 1, ch: end.ch });
   1.773 +  },
   1.774 +
   1.775 +  /**
   1.776 +   * Moves the content of the current line or the lines selected down a line.
   1.777 +   */
   1.778 +  moveLineDown: function () {
   1.779 +    let cm = editors.get(this);
   1.780 +    let start = cm.getCursor("start");
   1.781 +    let end = cm.getCursor("end");
   1.782 +
   1.783 +    if (end.line + 1 === cm.lineCount())
   1.784 +      return;
   1.785 +
   1.786 +    // Get the text of next line and append the text in the lines selected
   1.787 +    // or the current line of the cursor.
   1.788 +    let value = cm.getLine(end.line + 1) + "\n";
   1.789 +    if (start.line !== end.line) {
   1.790 +      value += cm.getRange({ line: start.line, ch: 0 },
   1.791 +        { line: end.line, ch: cm.getLine(end.line).length });
   1.792 +    } else {
   1.793 +      value += cm.getLine(start.line);
   1.794 +    }
   1.795 +
   1.796 +    // Replace the currently selected lines and the next line with the new
   1.797 +    // value and maintain the selection of the text.
   1.798 +    cm.replaceRange(value, { line: start.line, ch: 0 },
   1.799 +      { line: end.line + 1, ch: cm.getLine(end.line + 1).length});
   1.800 +    cm.setSelection({ line: start.line + 1, ch: start.ch },
   1.801 +      { line: end.line + 1, ch: end.ch });
   1.802 +  },
   1.803 +
   1.804 +  /**
   1.805 +   * Returns current font size for the editor area, in pixels.
   1.806 +   */
   1.807 +  getFontSize: function () {
   1.808 +    let cm  = editors.get(this);
   1.809 +    let el  = cm.getWrapperElement();
   1.810 +    let win = el.ownerDocument.defaultView;
   1.811 +
   1.812 +    return parseInt(win.getComputedStyle(el).getPropertyValue("font-size"), 10);
   1.813 +  },
   1.814 +
   1.815 +  /**
   1.816 +   * Sets font size for the editor area.
   1.817 +   */
   1.818 +  setFontSize: function (size) {
   1.819 +    let cm = editors.get(this);
   1.820 +    cm.getWrapperElement().style.fontSize = parseInt(size, 10) + "px";
   1.821 +    cm.refresh();
   1.822 +  },
   1.823 +
   1.824 +  /**
   1.825 +   * Extends an instance of the Editor object with additional
   1.826 +   * functions. Each function will be called with context as
   1.827 +   * the first argument. Context is a {ed, cm} object where
   1.828 +   * 'ed' is an instance of the Editor object and 'cm' is an
   1.829 +   * instance of the CodeMirror object. Example:
   1.830 +   *
   1.831 +   * function hello(ctx, name) {
   1.832 +   *   let { cm, ed } = ctx;
   1.833 +   *   cm;   // CodeMirror instance
   1.834 +   *   ed;   // Editor instance
   1.835 +   *   name; // 'Mozilla'
   1.836 +   * }
   1.837 +   *
   1.838 +   * editor.extend({ hello: hello });
   1.839 +   * editor.hello('Mozilla');
   1.840 +   */
   1.841 +  extend: function (funcs) {
   1.842 +    Object.keys(funcs).forEach((name) => {
   1.843 +      let cm  = editors.get(this);
   1.844 +      let ctx = { ed: this, cm: cm, Editor: Editor};
   1.845 +
   1.846 +      if (name === "initialize") {
   1.847 +        funcs[name](ctx);
   1.848 +        return;
   1.849 +      }
   1.850 +
   1.851 +      this[name] = funcs[name].bind(null, ctx);
   1.852 +    });
   1.853 +  },
   1.854 +
   1.855 +  destroy: function () {
   1.856 +    this.container = null;
   1.857 +    this.config = null;
   1.858 +    this.version = null;
   1.859 +    this.emit("destroy");
   1.860 +  }
   1.861 +};
   1.862 +
   1.863 +// Since Editor is a thin layer over CodeMirror some methods
   1.864 +// are mapped directly—without any changes.
   1.865 +
   1.866 +CM_MAPPING.forEach(function (name) {
   1.867 +  Editor.prototype[name] = function (...args) {
   1.868 +    let cm = editors.get(this);
   1.869 +    return cm[name].apply(cm, args);
   1.870 +  };
   1.871 +});
   1.872 +
   1.873 +// Static methods on the Editor object itself.
   1.874 +
   1.875 +/**
   1.876 + * Returns a string representation of a shortcut 'key' with
   1.877 + * a OS specific modifier. Cmd- for Macs, Ctrl- for other
   1.878 + * platforms. Useful with extraKeys configuration option.
   1.879 + *
   1.880 + * CodeMirror defines all keys with modifiers in the following
   1.881 + * order: Shift - Ctrl/Cmd - Alt - Key
   1.882 + */
   1.883 +Editor.accel = function (key, modifiers={}) {
   1.884 +  return (modifiers.shift ? "Shift-" : "") +
   1.885 +         (Services.appinfo.OS == "Darwin" ? "Cmd-" : "Ctrl-") +
   1.886 +         (modifiers.alt ? "Alt-" : "") + key;
   1.887 +};
   1.888 +
   1.889 +/**
   1.890 + * Returns a string representation of a shortcut for a
   1.891 + * specified command 'cmd'. Append Cmd- for macs, Ctrl- for other
   1.892 + * platforms unless noaccel is specified in the options. Useful when overwriting
   1.893 + * or disabling default shortcuts.
   1.894 + */
   1.895 +Editor.keyFor = function (cmd, opts={ noaccel: false }) {
   1.896 +  let key = L10N.GetStringFromName(cmd + ".commandkey");
   1.897 +  return opts.noaccel ? key : Editor.accel(key);
   1.898 +};
   1.899 +
   1.900 +// Since Gecko already provide complete and up to date list of CSS property
   1.901 +// names, values and color names, we compute them so that they can replace
   1.902 +// the ones used in CodeMirror while initiating an editor object. This is done
   1.903 +// here instead of the file codemirror/css.js so as to leave that file untouched
   1.904 +// and easily upgradable.
   1.905 +function getCSSKeywords() {
   1.906 +  function keySet(array) {
   1.907 +    var keys = {};
   1.908 +    for (var i = 0; i < array.length; ++i) {
   1.909 +      keys[array[i]] = true;
   1.910 +    }
   1.911 +    return keys;
   1.912 +  }
   1.913 +
   1.914 +  let domUtils = Cc["@mozilla.org/inspector/dom-utils;1"]
   1.915 +                   .getService(Ci.inIDOMUtils);
   1.916 +  let cssProperties = domUtils.getCSSPropertyNames(domUtils.INCLUDE_ALIASES);
   1.917 +  let cssColors = {};
   1.918 +  let cssValues = {};
   1.919 +  cssProperties.forEach(property => {
   1.920 +    if (property.contains("color")) {
   1.921 +      domUtils.getCSSValuesForProperty(property).forEach(value => {
   1.922 +        cssColors[value] = true;
   1.923 +      });
   1.924 +    }
   1.925 +    else {
   1.926 +      domUtils.getCSSValuesForProperty(property).forEach(value => {
   1.927 +        cssValues[value] = true;
   1.928 +      });
   1.929 +    }
   1.930 +  });
   1.931 +  return {
   1.932 +    cssProperties: keySet(cssProperties),
   1.933 +    cssValues: cssValues,
   1.934 +    cssColors: cssColors
   1.935 +  };
   1.936 +}
   1.937 +
   1.938 +/**
   1.939 + * Returns a controller object that can be used for
   1.940 + * editor-specific commands such as find, jump to line,
   1.941 + * copy/paste, etc.
   1.942 + */
   1.943 +function controller(ed) {
   1.944 +  return {
   1.945 +    supportsCommand: function (cmd) {
   1.946 +      switch (cmd) {
   1.947 +        case "cmd_find":
   1.948 +        case "cmd_findAgain":
   1.949 +        case "cmd_findPrevious":
   1.950 +        case "cmd_gotoLine":
   1.951 +        case "cmd_undo":
   1.952 +        case "cmd_redo":
   1.953 +        case "cmd_delete":
   1.954 +        case "cmd_selectAll":
   1.955 +          return true;
   1.956 +      }
   1.957 +
   1.958 +      return false;
   1.959 +    },
   1.960 +
   1.961 +    isCommandEnabled: function (cmd) {
   1.962 +      let cm = editors.get(ed);
   1.963 +
   1.964 +      switch (cmd) {
   1.965 +        case "cmd_find":
   1.966 +        case "cmd_gotoLine":
   1.967 +        case "cmd_selectAll":
   1.968 +          return true;
   1.969 +        case "cmd_findAgain":
   1.970 +          return cm.state.search != null && cm.state.search.query != null;
   1.971 +        case "cmd_undo":
   1.972 +          return ed.canUndo();
   1.973 +        case "cmd_redo":
   1.974 +          return ed.canRedo();
   1.975 +        case "cmd_delete":
   1.976 +          return ed.somethingSelected();
   1.977 +      }
   1.978 +
   1.979 +      return false;
   1.980 +    },
   1.981 +
   1.982 +    doCommand: function (cmd) {
   1.983 +      let cm  = editors.get(ed);
   1.984 +      let map = {
   1.985 +        "cmd_selectAll": "selectAll",
   1.986 +        "cmd_find": "find",
   1.987 +        "cmd_undo": "undo",
   1.988 +        "cmd_redo": "redo",
   1.989 +        "cmd_delete": "delCharAfter",
   1.990 +        "cmd_findAgain": "findNext"
   1.991 +      };
   1.992 +
   1.993 +      if (map[cmd]) {
   1.994 +        cm.execCommand(map[cmd]);
   1.995 +        return;
   1.996 +      }
   1.997 +
   1.998 +      if (cmd == "cmd_gotoLine")
   1.999 +        ed.jumpToLine();
  1.1000 +    },
  1.1001 +
  1.1002 +    onEvent: function () {}
  1.1003 +  };
  1.1004 +}
  1.1005 +
  1.1006 +/**
  1.1007 + * Detect the indentation used in an editor. Returns an object
  1.1008 + * with 'tabs' - whether this is tab-indented and 'spaces' - the
  1.1009 + * width of one indent in spaces. Or `null` if it's inconclusive.
  1.1010 + */
  1.1011 +function detectIndentation(ed) {
  1.1012 +  let cm = editors.get(ed);
  1.1013 +
  1.1014 +  let spaces = {};  // # spaces indent -> # lines with that indent
  1.1015 +  let last = 0;     // indentation width of the last line we saw
  1.1016 +  let tabs = 0;     // # of lines that start with a tab
  1.1017 +  let total = 0;    // # of indented lines (non-zero indent)
  1.1018 +
  1.1019 +  cm.eachLine(0, DETECT_INDENT_MAX_LINES, (line) => {
  1.1020 +    let text = line.text;
  1.1021 +
  1.1022 +    if (text.startsWith("\t")) {
  1.1023 +      tabs++;
  1.1024 +      total++;
  1.1025 +      return;
  1.1026 +    }
  1.1027 +    let width = 0;
  1.1028 +    while (text[width] === " ") {
  1.1029 +      width++;
  1.1030 +    }
  1.1031 +    // don't count lines that are all spaces
  1.1032 +    if (width == text.length) {
  1.1033 +      last = 0;
  1.1034 +      return;
  1.1035 +    }
  1.1036 +    if (width > 1) {
  1.1037 +      total++;
  1.1038 +    }
  1.1039 +
  1.1040 +    // see how much this line is offset from the line above it
  1.1041 +    let indent = Math.abs(width - last);
  1.1042 +    if (indent > 1 && indent <= 8) {
  1.1043 +      spaces[indent] = (spaces[indent] || 0) + 1;
  1.1044 +    }
  1.1045 +    last = width;
  1.1046 +  });
  1.1047 +
  1.1048 +  // this file is not indented at all
  1.1049 +  if (total == 0) {
  1.1050 +    return null;
  1.1051 +  }
  1.1052 +
  1.1053 +  // mark as tabs if they start more than half the lines
  1.1054 +  if (tabs >= total / 2) {
  1.1055 +    return { tabs: true };
  1.1056 +  }
  1.1057 +
  1.1058 +  // find most frequent non-zero width difference between adjacent lines
  1.1059 +  let freqIndent = null, max = 1;
  1.1060 +  for (let width in spaces) {
  1.1061 +    width = parseInt(width, 10);
  1.1062 +    let tally = spaces[width];
  1.1063 +    if (tally > max) {
  1.1064 +      max = tally;
  1.1065 +      freqIndent = width;
  1.1066 +    }
  1.1067 +  }
  1.1068 +  if (!freqIndent) {
  1.1069 +    return null;
  1.1070 +  }
  1.1071 +
  1.1072 +  return { tabs: false, spaces: freqIndent };
  1.1073 +}
  1.1074 +
  1.1075 +module.exports = Editor;

mercurial