michael@0: /* vim:set ts=2 sw=2 sts=2 et tw=80: michael@0: * This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: const { Cu, Cc, Ci, components } = require("chrome"); michael@0: michael@0: const TAB_SIZE = "devtools.editor.tabsize"; michael@0: const EXPAND_TAB = "devtools.editor.expandtab"; michael@0: const KEYMAP = "devtools.editor.keymap"; michael@0: const AUTO_CLOSE = "devtools.editor.autoclosebrackets"; michael@0: const DETECT_INDENT = "devtools.editor.detectindentation"; michael@0: const DETECT_INDENT_MAX_LINES = 500; michael@0: const L10N_BUNDLE = "chrome://browser/locale/devtools/sourceeditor.properties"; michael@0: const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; michael@0: michael@0: // Maximum allowed margin (in number of lines) from top or bottom of the editor michael@0: // while shifting to a line which was initially out of view. michael@0: const MAX_VERTICAL_OFFSET = 3; michael@0: michael@0: const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); michael@0: const events = require("devtools/toolkit/event-emitter"); michael@0: michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: const L10N = Services.strings.createBundle(L10N_BUNDLE); michael@0: michael@0: // CM_STYLES, CM_SCRIPTS and CM_IFRAME represent the HTML, michael@0: // JavaScript and CSS that is injected into an iframe in michael@0: // order to initialize a CodeMirror instance. michael@0: michael@0: const CM_STYLES = [ michael@0: "chrome://browser/skin/devtools/common.css", michael@0: "chrome://browser/content/devtools/codemirror/codemirror.css", michael@0: "chrome://browser/content/devtools/codemirror/dialog.css", michael@0: "chrome://browser/content/devtools/codemirror/mozilla.css" michael@0: ]; michael@0: michael@0: const CM_SCRIPTS = [ michael@0: "chrome://browser/content/devtools/theme-switching.js", michael@0: "chrome://browser/content/devtools/codemirror/codemirror.js", michael@0: "chrome://browser/content/devtools/codemirror/dialog.js", michael@0: "chrome://browser/content/devtools/codemirror/searchcursor.js", michael@0: "chrome://browser/content/devtools/codemirror/search.js", michael@0: "chrome://browser/content/devtools/codemirror/matchbrackets.js", michael@0: "chrome://browser/content/devtools/codemirror/closebrackets.js", michael@0: "chrome://browser/content/devtools/codemirror/comment.js", michael@0: "chrome://browser/content/devtools/codemirror/javascript.js", michael@0: "chrome://browser/content/devtools/codemirror/xml.js", michael@0: "chrome://browser/content/devtools/codemirror/css.js", michael@0: "chrome://browser/content/devtools/codemirror/htmlmixed.js", michael@0: "chrome://browser/content/devtools/codemirror/clike.js", michael@0: "chrome://browser/content/devtools/codemirror/activeline.js", michael@0: "chrome://browser/content/devtools/codemirror/trailingspace.js", michael@0: "chrome://browser/content/devtools/codemirror/emacs.js", michael@0: "chrome://browser/content/devtools/codemirror/vim.js", michael@0: "chrome://browser/content/devtools/codemirror/sublime.js", michael@0: "chrome://browser/content/devtools/codemirror/foldcode.js", michael@0: "chrome://browser/content/devtools/codemirror/brace-fold.js", michael@0: "chrome://browser/content/devtools/codemirror/comment-fold.js", michael@0: "chrome://browser/content/devtools/codemirror/xml-fold.js", michael@0: "chrome://browser/content/devtools/codemirror/foldgutter.js" michael@0: ]; michael@0: michael@0: const CM_IFRAME = michael@0: "data:text/html;charset=utf8," + michael@0: "" + michael@0: "
" + michael@0: " " + michael@0: [ " " for (style of CM_STYLES) ].join("\n") + michael@0: " " + michael@0: " " + michael@0: ""; michael@0: michael@0: const CM_MAPPING = [ michael@0: "focus", michael@0: "hasFocus", michael@0: "lineCount", michael@0: "somethingSelected", michael@0: "getCursor", michael@0: "setSelection", michael@0: "getSelection", michael@0: "replaceSelection", michael@0: "extendSelection", michael@0: "undo", michael@0: "redo", michael@0: "clearHistory", michael@0: "openDialog", michael@0: "refresh", michael@0: "getScrollInfo", michael@0: "getOption", michael@0: "setOption" michael@0: ]; michael@0: michael@0: const { cssProperties, cssValues, cssColors } = getCSSKeywords(); michael@0: michael@0: const editors = new WeakMap(); michael@0: michael@0: Editor.modes = { michael@0: text: { name: "text" }, michael@0: html: { name: "htmlmixed" }, michael@0: css: { name: "css" }, michael@0: js: { name: "javascript" }, michael@0: vs: { name: "x-shader/x-vertex" }, michael@0: fs: { name: "x-shader/x-fragment" } michael@0: }; michael@0: michael@0: /** michael@0: * A very thin wrapper around CodeMirror. Provides a number michael@0: * of helper methods to make our use of CodeMirror easier and michael@0: * another method, appendTo, to actually create and append michael@0: * the CodeMirror instance. michael@0: * michael@0: * Note that Editor doesn't expose CodeMirror instance to the michael@0: * outside world. michael@0: * michael@0: * Constructor accepts one argument, config. It is very michael@0: * similar to the CodeMirror configuration object so for most michael@0: * properties go to CodeMirror's documentation (see below). michael@0: * michael@0: * Other than that, it accepts one additional and optional michael@0: * property contextMenu. This property should be an ID of michael@0: * an element we can use as a context menu. michael@0: * michael@0: * This object is also an event emitter. michael@0: * michael@0: * CodeMirror docs: http://codemirror.net/doc/manual.html michael@0: */ michael@0: function Editor(config) { michael@0: const tabSize = Services.prefs.getIntPref(TAB_SIZE); michael@0: const useTabs = !Services.prefs.getBoolPref(EXPAND_TAB); michael@0: const keyMap = Services.prefs.getCharPref(KEYMAP); michael@0: const useAutoClose = Services.prefs.getBoolPref(AUTO_CLOSE); michael@0: michael@0: this.version = null; michael@0: this.config = { michael@0: value: "", michael@0: mode: Editor.modes.text, michael@0: indentUnit: tabSize, michael@0: tabSize: tabSize, michael@0: contextMenu: null, michael@0: matchBrackets: true, michael@0: extraKeys: {}, michael@0: indentWithTabs: useTabs, michael@0: styleActiveLine: true, michael@0: autoCloseBrackets: "()[]{}''\"\"", michael@0: autoCloseEnabled: useAutoClose, michael@0: theme: "mozilla" michael@0: }; michael@0: michael@0: // Additional shortcuts. michael@0: this.config.extraKeys[Editor.keyFor("jumpToLine")] = () => this.jumpToLine(); michael@0: this.config.extraKeys[Editor.keyFor("moveLineUp", { noaccel: true })] = () => this.moveLineUp(); michael@0: this.config.extraKeys[Editor.keyFor("moveLineDown", { noaccel: true })] = () => this.moveLineDown(); michael@0: this.config.extraKeys[Editor.keyFor("toggleComment")] = "toggleComment"; michael@0: michael@0: // Disable ctrl-[ and ctrl-] because toolbox uses those shortcuts. michael@0: this.config.extraKeys[Editor.keyFor("indentLess")] = false; michael@0: this.config.extraKeys[Editor.keyFor("indentMore")] = false; michael@0: michael@0: // If alternative keymap is provided, use it. michael@0: if (keyMap === "emacs" || keyMap === "vim" || keyMap === "sublime") michael@0: this.config.keyMap = keyMap; michael@0: michael@0: // Overwrite default config with user-provided, if needed. michael@0: Object.keys(config).forEach((k) => { michael@0: if (k != "extraKeys") { michael@0: this.config[k] = config[k]; michael@0: return; michael@0: } michael@0: michael@0: if (!config.extraKeys) michael@0: return; michael@0: michael@0: Object.keys(config.extraKeys).forEach((key) => { michael@0: this.config.extraKeys[key] = config.extraKeys[key]; michael@0: }); michael@0: }); michael@0: michael@0: // Set the code folding gutter, if needed. michael@0: if (this.config.enableCodeFolding) { michael@0: this.config.foldGutter = true; michael@0: michael@0: if (!this.config.gutters) { michael@0: this.config.gutters = this.config.lineNumbers ? ["CodeMirror-linenumbers"] : []; michael@0: this.config.gutters.push("CodeMirror-foldgutter"); michael@0: } michael@0: } michael@0: michael@0: // Configure automatic bracket closing. michael@0: if (!this.config.autoCloseEnabled) michael@0: this.config.autoCloseBrackets = false; michael@0: michael@0: // Overwrite default tab behavior. If something is selected, michael@0: // indent those lines. If nothing is selected and we're michael@0: // indenting with tabs, insert one tab. Otherwise insert N michael@0: // whitespaces where N == indentUnit option. michael@0: this.config.extraKeys.Tab = (cm) => { michael@0: if (cm.somethingSelected()) { michael@0: cm.indentSelection("add"); michael@0: return; michael@0: } michael@0: michael@0: if (this.config.indentWithTabs) { michael@0: cm.replaceSelection("\t", "end", "+input"); michael@0: return; michael@0: } michael@0: michael@0: var num = cm.getOption("indentUnit"); michael@0: if (cm.getCursor().ch !== 0) num -= 1; michael@0: cm.replaceSelection(" ".repeat(num), "end", "+input"); michael@0: }; michael@0: michael@0: events.decorate(this); michael@0: } michael@0: michael@0: Editor.prototype = { michael@0: container: null, michael@0: version: null, michael@0: config: null, michael@0: michael@0: /** michael@0: * Appends the current Editor instance to the element specified by michael@0: * 'el'. You can also provide your won iframe to host the editor as michael@0: * an optional second parameter. This method actually creates and michael@0: * loads CodeMirror and all its dependencies. michael@0: * michael@0: * This method is asynchronous and returns a promise. michael@0: */ michael@0: appendTo: function (el, env) { michael@0: let def = promise.defer(); michael@0: let cm = editors.get(this); michael@0: michael@0: if (!env) michael@0: env = el.ownerDocument.createElementNS(XUL_NS, "iframe"); michael@0: michael@0: env.flex = 1; michael@0: michael@0: if (cm) michael@0: throw new Error("You can append an editor only once."); michael@0: michael@0: let onLoad = () => { michael@0: // Once the iframe is loaded, we can inject CodeMirror michael@0: // and its dependencies into its DOM. michael@0: michael@0: env.removeEventListener("load", onLoad, true); michael@0: let win = env.contentWindow.wrappedJSObject; michael@0: michael@0: CM_SCRIPTS.forEach((url) => michael@0: Services.scriptloader.loadSubScript(url, win, "utf8")); michael@0: michael@0: // Replace the propertyKeywords, colorKeywords and valueKeywords michael@0: // properties of the CSS MIME type with the values provided by Gecko. michael@0: let cssSpec = win.CodeMirror.resolveMode("text/css"); michael@0: cssSpec.propertyKeywords = cssProperties; michael@0: cssSpec.colorKeywords = cssColors; michael@0: cssSpec.valueKeywords = cssValues; michael@0: win.CodeMirror.defineMIME("text/css", cssSpec); michael@0: michael@0: let scssSpec = win.CodeMirror.resolveMode("text/x-scss"); michael@0: scssSpec.propertyKeywords = cssProperties; michael@0: scssSpec.colorKeywords = cssColors; michael@0: scssSpec.valueKeywords = cssValues; michael@0: win.CodeMirror.defineMIME("text/x-scss", scssSpec); michael@0: michael@0: win.CodeMirror.commands.save = () => this.emit("save"); michael@0: michael@0: // Create a CodeMirror instance add support for context menus, michael@0: // overwrite the default controller (otherwise items in the top and michael@0: // context menus won't work). michael@0: michael@0: cm = win.CodeMirror(win.document.body, this.config); michael@0: cm.getWrapperElement().addEventListener("contextmenu", (ev) => { michael@0: ev.preventDefault(); michael@0: if (!this.config.contextMenu) return; michael@0: let popup = el.ownerDocument.getElementById(this.config.contextMenu); michael@0: popup.openPopupAtScreen(ev.screenX, ev.screenY, true); michael@0: }, false); michael@0: michael@0: cm.on("focus", () => this.emit("focus")); michael@0: cm.on("scroll", () => this.emit("scroll")); michael@0: cm.on("change", () => { michael@0: this.emit("change"); michael@0: if (!this._lastDirty) { michael@0: this._lastDirty = true; michael@0: this.emit("dirty-change"); michael@0: } michael@0: }); michael@0: cm.on("cursorActivity", (cm) => this.emit("cursorActivity")); michael@0: michael@0: cm.on("gutterClick", (cm, line, gutter, ev) => { michael@0: let head = { line: line, ch: 0 }; michael@0: let tail = { line: line, ch: this.getText(line).length }; michael@0: michael@0: // Shift-click on a gutter selects the whole line. michael@0: if (ev.shiftKey) { michael@0: cm.setSelection(head, tail); michael@0: return; michael@0: } michael@0: michael@0: this.emit("gutterClick", line); michael@0: }); michael@0: michael@0: win.CodeMirror.defineExtension("l10n", (name) => { michael@0: return L10N.GetStringFromName(name); michael@0: }); michael@0: michael@0: cm.getInputField().controllers.insertControllerAt(0, controller(this)); michael@0: michael@0: this.container = env; michael@0: editors.set(this, cm); michael@0: michael@0: this.resetIndentUnit(); michael@0: michael@0: def.resolve(); michael@0: }; michael@0: michael@0: env.addEventListener("load", onLoad, true); michael@0: env.setAttribute("src", CM_IFRAME); michael@0: el.appendChild(env); michael@0: michael@0: this.once("destroy", () => el.removeChild(env)); michael@0: return def.promise; michael@0: }, michael@0: michael@0: /** michael@0: * Returns the currently active highlighting mode. michael@0: * See Editor.modes for the list of all suppoert modes. michael@0: */ michael@0: getMode: function () { michael@0: return this.getOption("mode"); michael@0: }, michael@0: michael@0: /** michael@0: * Changes the value of a currently used highlighting mode. michael@0: * See Editor.modes for the list of all suppoert modes. michael@0: */ michael@0: setMode: function (value) { michael@0: this.setOption("mode", value); michael@0: }, michael@0: michael@0: /** michael@0: * Returns text from the text area. If line argument is provided michael@0: * the method returns only that line. michael@0: */ michael@0: getText: function (line) { michael@0: let cm = editors.get(this); michael@0: michael@0: if (line == null) michael@0: return cm.getValue(); michael@0: michael@0: let info = cm.lineInfo(line); michael@0: return info ? cm.lineInfo(line).text : ""; michael@0: }, michael@0: michael@0: /** michael@0: * Replaces whatever is in the text area with the contents of michael@0: * the 'value' argument. michael@0: */ michael@0: setText: function (value) { michael@0: let cm = editors.get(this); michael@0: cm.setValue(value); michael@0: michael@0: this.resetIndentUnit(); michael@0: }, michael@0: michael@0: /** michael@0: * Set the editor's indentation based on the current prefs and michael@0: * re-detect indentation if we should. michael@0: */ michael@0: resetIndentUnit: function() { michael@0: let cm = editors.get(this); michael@0: michael@0: let indentWithTabs = !Services.prefs.getBoolPref(EXPAND_TAB); michael@0: let indentUnit = Services.prefs.getIntPref(TAB_SIZE); michael@0: let shouldDetect = Services.prefs.getBoolPref(DETECT_INDENT); michael@0: michael@0: cm.setOption("tabSize", indentUnit); michael@0: michael@0: if (shouldDetect) { michael@0: let indent = detectIndentation(this); michael@0: if (indent != null) { michael@0: indentWithTabs = indent.tabs; michael@0: indentUnit = indent.spaces ? indent.spaces : indentUnit; michael@0: } michael@0: } michael@0: michael@0: cm.setOption("indentUnit", indentUnit); michael@0: cm.setOption("indentWithTabs", indentWithTabs); michael@0: }, michael@0: michael@0: /** michael@0: * Replaces contents of a text area within the from/to {line, ch} michael@0: * range. If neither from nor to arguments are provided works michael@0: * exactly like setText. If only from object is provided, inserts michael@0: * text at that point, *overwriting* as many characters as needed. michael@0: */ michael@0: replaceText: function (value, from, to) { michael@0: let cm = editors.get(this); michael@0: michael@0: if (!from) { michael@0: this.setText(value); michael@0: return; michael@0: } michael@0: michael@0: if (!to) { michael@0: let text = cm.getRange({ line: 0, ch: 0 }, from); michael@0: this.setText(text + value); michael@0: return; michael@0: } michael@0: michael@0: cm.replaceRange(value, from, to); michael@0: }, michael@0: michael@0: /** michael@0: * Inserts text at the specified {line, ch} position, shifting existing michael@0: * contents as necessary. michael@0: */ michael@0: insertText: function (value, at) { michael@0: let cm = editors.get(this); michael@0: cm.replaceRange(value, at, at); michael@0: }, michael@0: michael@0: /** michael@0: * Deselects contents of the text area. michael@0: */ michael@0: dropSelection: function () { michael@0: if (!this.somethingSelected()) michael@0: return; michael@0: michael@0: this.setCursor(this.getCursor()); michael@0: }, michael@0: michael@0: /** michael@0: * Returns true if there is more than one selection in the editor. michael@0: */ michael@0: hasMultipleSelections: function () { michael@0: let cm = editors.get(this); michael@0: return cm.listSelections().length > 1; michael@0: }, michael@0: michael@0: /** michael@0: * Gets the first visible line number in the editor. michael@0: */ michael@0: getFirstVisibleLine: function () { michael@0: let cm = editors.get(this); michael@0: return cm.lineAtHeight(0, "local"); michael@0: }, michael@0: michael@0: /** michael@0: * Scrolls the view such that the given line number is the first visible line. michael@0: */ michael@0: setFirstVisibleLine: function (line) { michael@0: let cm = editors.get(this); michael@0: let { top } = cm.charCoords({line: line, ch: 0}, "local"); michael@0: cm.scrollTo(0, top); michael@0: }, michael@0: michael@0: /** michael@0: * Sets the cursor to the specified {line, ch} position with an additional michael@0: * option to align the line at the "top", "center" or "bottom" of the editor michael@0: * with "top" being default value. michael@0: */ michael@0: setCursor: function ({line, ch}, align) { michael@0: let cm = editors.get(this); michael@0: this.alignLine(line, align); michael@0: cm.setCursor({line: line, ch: ch}); michael@0: }, michael@0: michael@0: /** michael@0: * Aligns the provided line to either "top", "center" or "bottom" of the michael@0: * editor view with a maximum margin of MAX_VERTICAL_OFFSET lines from top or michael@0: * bottom. michael@0: */ michael@0: alignLine: function(line, align) { michael@0: let cm = editors.get(this); michael@0: let from = cm.lineAtHeight(0, "page"); michael@0: let to = cm.lineAtHeight(cm.getWrapperElement().clientHeight, "page"); michael@0: let linesVisible = to - from; michael@0: let halfVisible = Math.round(linesVisible/2); michael@0: michael@0: // If the target line is in view, skip the vertical alignment part. michael@0: if (line <= to && line >= from) { michael@0: return; michael@0: } michael@0: michael@0: // Setting the offset so that the line always falls in the upper half michael@0: // of visible lines (lower half for bottom aligned). michael@0: // MAX_VERTICAL_OFFSET is the maximum allowed value. michael@0: let offset = Math.min(halfVisible, MAX_VERTICAL_OFFSET); michael@0: michael@0: let topLine = { michael@0: "center": Math.max(line - halfVisible, 0), michael@0: "bottom": Math.max(line - linesVisible + offset, 0), michael@0: "top": Math.max(line - offset, 0) michael@0: }[align || "top"] || offset; michael@0: michael@0: // Bringing down the topLine to total lines in the editor if exceeding. michael@0: topLine = Math.min(topLine, this.lineCount()); michael@0: this.setFirstVisibleLine(topLine); michael@0: }, michael@0: michael@0: /** michael@0: * Returns whether a marker of a specified class exists in a line's gutter. michael@0: */ michael@0: hasMarker: function (line, gutterName, markerClass) { michael@0: let cm = editors.get(this); michael@0: let info = cm.lineInfo(line); michael@0: if (!info) michael@0: return false; michael@0: michael@0: let gutterMarkers = info.gutterMarkers; michael@0: if (!gutterMarkers) michael@0: return false; michael@0: michael@0: let marker = gutterMarkers[gutterName]; michael@0: if (!marker) michael@0: return false; michael@0: michael@0: return marker.classList.contains(markerClass); michael@0: }, michael@0: michael@0: /** michael@0: * Adds a marker with a specified class to a line's gutter. If another marker michael@0: * exists on that line, the new marker class is added to its class list. michael@0: */ michael@0: addMarker: function (line, gutterName, markerClass) { michael@0: let cm = editors.get(this); michael@0: let info = cm.lineInfo(line); michael@0: if (!info) michael@0: return; michael@0: michael@0: let gutterMarkers = info.gutterMarkers; michael@0: if (gutterMarkers) { michael@0: let marker = gutterMarkers[gutterName]; michael@0: if (marker) { michael@0: marker.classList.add(markerClass); michael@0: return; michael@0: } michael@0: } michael@0: michael@0: let marker = cm.getWrapperElement().ownerDocument.createElement("div"); michael@0: marker.className = markerClass; michael@0: cm.setGutterMarker(info.line, gutterName, marker); michael@0: }, michael@0: michael@0: /** michael@0: * The reverse of addMarker. Removes a marker of a specified class from a michael@0: * line's gutter. michael@0: */ michael@0: removeMarker: function (line, gutterName, markerClass) { michael@0: if (!this.hasMarker(line, gutterName, markerClass)) michael@0: return; michael@0: michael@0: let cm = editors.get(this); michael@0: cm.lineInfo(line).gutterMarkers[gutterName].classList.remove(markerClass); michael@0: }, michael@0: michael@0: /** michael@0: * Remove all gutter markers in the gutter with the given name. michael@0: */ michael@0: removeAllMarkers: function (gutterName) { michael@0: let cm = editors.get(this); michael@0: cm.clearGutter(gutterName); michael@0: }, michael@0: michael@0: /** michael@0: * Handles attaching a set of events listeners on a marker. They should michael@0: * be passed as an object literal with keys as event names and values as michael@0: * function listeners. The line number, marker node and optional data michael@0: * will be passed as arguments to the function listener. michael@0: * michael@0: * You don't need to worry about removing these event listeners. michael@0: * They're automatically orphaned when clearing markers. michael@0: */ michael@0: setMarkerListeners: function(line, gutterName, markerClass, events, data) { michael@0: if (!this.hasMarker(line, gutterName, markerClass)) michael@0: return; michael@0: michael@0: let cm = editors.get(this); michael@0: let marker = cm.lineInfo(line).gutterMarkers[gutterName]; michael@0: michael@0: for (let name in events) { michael@0: let listener = events[name].bind(this, line, marker, data); michael@0: marker.addEventListener(name, listener); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Returns whether a line is decorated using the specified class name. michael@0: */ michael@0: hasLineClass: function (line, className) { michael@0: let cm = editors.get(this); michael@0: let info = cm.lineInfo(line); michael@0: michael@0: if (!info || !info.wrapClass) michael@0: return false; michael@0: michael@0: return info.wrapClass.split(" ").indexOf(className) != -1; michael@0: }, michael@0: michael@0: /** michael@0: * Set a CSS class name for the given line, including the text and gutter. michael@0: */ michael@0: addLineClass: function (line, className) { michael@0: let cm = editors.get(this); michael@0: cm.addLineClass(line, "wrap", className); michael@0: }, michael@0: michael@0: /** michael@0: * The reverse of addLineClass. michael@0: */ michael@0: removeLineClass: function (line, className) { michael@0: let cm = editors.get(this); michael@0: cm.removeLineClass(line, "wrap", className); michael@0: }, michael@0: michael@0: /** michael@0: * Mark a range of text inside the two {line, ch} bounds. Since the range may michael@0: * be modified, for example, when typing text, this method returns a function michael@0: * that can be used to remove the mark. michael@0: */ michael@0: markText: function(from, to, className = "marked-text") { michael@0: let cm = editors.get(this); michael@0: let text = cm.getRange(from, to); michael@0: let span = cm.getWrapperElement().ownerDocument.createElement("span"); michael@0: span.className = className; michael@0: span.textContent = text; michael@0: michael@0: let mark = cm.markText(from, to, { replacedWith: span }); michael@0: return { michael@0: anchor: span, michael@0: clear: () => mark.clear() michael@0: }; michael@0: }, michael@0: michael@0: /** michael@0: * Calculates and returns one or more {line, ch} objects for michael@0: * a zero-based index who's value is relative to the start of michael@0: * the editor's text. michael@0: * michael@0: * If only one argument is given, this method returns a single michael@0: * {line,ch} object. Otherwise it returns an array. michael@0: */ michael@0: getPosition: function (...args) { michael@0: let cm = editors.get(this); michael@0: let res = args.map((ind) => cm.posFromIndex(ind)); michael@0: return args.length === 1 ? res[0] : res; michael@0: }, michael@0: michael@0: /** michael@0: * The reverse of getPosition. Similarly to getPosition this michael@0: * method returns a single value if only one argument was given michael@0: * and an array otherwise. michael@0: */ michael@0: getOffset: function (...args) { michael@0: let cm = editors.get(this); michael@0: let res = args.map((pos) => cm.indexFromPos(pos)); michael@0: return args.length > 1 ? res : res[0]; michael@0: }, michael@0: michael@0: /** michael@0: * Returns a {line, ch} object that corresponds to the michael@0: * left, top coordinates. michael@0: */ michael@0: getPositionFromCoords: function ({left, top}) { michael@0: let cm = editors.get(this); michael@0: return cm.coordsChar({ left: left, top: top }); michael@0: }, michael@0: michael@0: /** michael@0: * The reverse of getPositionFromCoords. Similarly, returns a {left, top} michael@0: * object that corresponds to the specified line and character number. michael@0: */ michael@0: getCoordsFromPosition: function ({line, ch}) { michael@0: let cm = editors.get(this); michael@0: return cm.charCoords({ line: ~~line, ch: ~~ch }); michael@0: }, michael@0: michael@0: /** michael@0: * Returns true if there's something to undo and false otherwise. michael@0: */ michael@0: canUndo: function () { michael@0: let cm = editors.get(this); michael@0: return cm.historySize().undo > 0; michael@0: }, michael@0: michael@0: /** michael@0: * Returns true if there's something to redo and false otherwise. michael@0: */ michael@0: canRedo: function () { michael@0: let cm = editors.get(this); michael@0: return cm.historySize().redo > 0; michael@0: }, michael@0: michael@0: /** michael@0: * Marks the contents as clean and returns the current michael@0: * version number. michael@0: */ michael@0: setClean: function () { michael@0: let cm = editors.get(this); michael@0: this.version = cm.changeGeneration(); michael@0: this._lastDirty = false; michael@0: this.emit("dirty-change"); michael@0: return this.version; michael@0: }, michael@0: michael@0: /** michael@0: * Returns true if contents of the text area are michael@0: * clean i.e. no changes were made since the last version. michael@0: */ michael@0: isClean: function () { michael@0: let cm = editors.get(this); michael@0: return cm.isClean(this.version); michael@0: }, michael@0: michael@0: /** michael@0: * This method opens an in-editor dialog asking for a line to michael@0: * jump to. Once given, it changes cursor to that line. michael@0: */ michael@0: jumpToLine: function () { michael@0: let doc = editors.get(this).getWrapperElement().ownerDocument; michael@0: let div = doc.createElement("div"); michael@0: let inp = doc.createElement("input"); michael@0: let txt = doc.createTextNode(L10N.GetStringFromName("gotoLineCmd.promptTitle")); michael@0: michael@0: inp.type = "text"; michael@0: inp.style.width = "10em"; michael@0: inp.style.MozMarginStart = "1em"; michael@0: michael@0: div.appendChild(txt); michael@0: div.appendChild(inp); michael@0: michael@0: this.openDialog(div, (line) => this.setCursor({ line: line - 1, ch: 0 })); michael@0: }, michael@0: michael@0: /** michael@0: * Moves the content of the current line or the lines selected up a line. michael@0: */ michael@0: moveLineUp: function () { michael@0: let cm = editors.get(this); michael@0: let start = cm.getCursor("start"); michael@0: let end = cm.getCursor("end"); michael@0: michael@0: if (start.line === 0) michael@0: return; michael@0: michael@0: // Get the text in the lines selected or the current line of the cursor michael@0: // and append the text of the previous line. michael@0: let value; michael@0: if (start.line !== end.line) { michael@0: value = cm.getRange({ line: start.line, ch: 0 }, michael@0: { line: end.line, ch: cm.getLine(end.line).length }) + "\n"; michael@0: } else { michael@0: value = cm.getLine(start.line) + "\n"; michael@0: } michael@0: value += cm.getLine(start.line - 1); michael@0: michael@0: // Replace the previous line and the currently selected lines with the new michael@0: // value and maintain the selection of the text. michael@0: cm.replaceRange(value, { line: start.line - 1, ch: 0 }, michael@0: { line: end.line, ch: cm.getLine(end.line).length }); michael@0: cm.setSelection({ line: start.line - 1, ch: start.ch }, michael@0: { line: end.line - 1, ch: end.ch }); michael@0: }, michael@0: michael@0: /** michael@0: * Moves the content of the current line or the lines selected down a line. michael@0: */ michael@0: moveLineDown: function () { michael@0: let cm = editors.get(this); michael@0: let start = cm.getCursor("start"); michael@0: let end = cm.getCursor("end"); michael@0: michael@0: if (end.line + 1 === cm.lineCount()) michael@0: return; michael@0: michael@0: // Get the text of next line and append the text in the lines selected michael@0: // or the current line of the cursor. michael@0: let value = cm.getLine(end.line + 1) + "\n"; michael@0: if (start.line !== end.line) { michael@0: value += cm.getRange({ line: start.line, ch: 0 }, michael@0: { line: end.line, ch: cm.getLine(end.line).length }); michael@0: } else { michael@0: value += cm.getLine(start.line); michael@0: } michael@0: michael@0: // Replace the currently selected lines and the next line with the new michael@0: // value and maintain the selection of the text. michael@0: cm.replaceRange(value, { line: start.line, ch: 0 }, michael@0: { line: end.line + 1, ch: cm.getLine(end.line + 1).length}); michael@0: cm.setSelection({ line: start.line + 1, ch: start.ch }, michael@0: { line: end.line + 1, ch: end.ch }); michael@0: }, michael@0: michael@0: /** michael@0: * Returns current font size for the editor area, in pixels. michael@0: */ michael@0: getFontSize: function () { michael@0: let cm = editors.get(this); michael@0: let el = cm.getWrapperElement(); michael@0: let win = el.ownerDocument.defaultView; michael@0: michael@0: return parseInt(win.getComputedStyle(el).getPropertyValue("font-size"), 10); michael@0: }, michael@0: michael@0: /** michael@0: * Sets font size for the editor area. michael@0: */ michael@0: setFontSize: function (size) { michael@0: let cm = editors.get(this); michael@0: cm.getWrapperElement().style.fontSize = parseInt(size, 10) + "px"; michael@0: cm.refresh(); michael@0: }, michael@0: michael@0: /** michael@0: * Extends an instance of the Editor object with additional michael@0: * functions. Each function will be called with context as michael@0: * the first argument. Context is a {ed, cm} object where michael@0: * 'ed' is an instance of the Editor object and 'cm' is an michael@0: * instance of the CodeMirror object. Example: michael@0: * michael@0: * function hello(ctx, name) { michael@0: * let { cm, ed } = ctx; michael@0: * cm; // CodeMirror instance michael@0: * ed; // Editor instance michael@0: * name; // 'Mozilla' michael@0: * } michael@0: * michael@0: * editor.extend({ hello: hello }); michael@0: * editor.hello('Mozilla'); michael@0: */ michael@0: extend: function (funcs) { michael@0: Object.keys(funcs).forEach((name) => { michael@0: let cm = editors.get(this); michael@0: let ctx = { ed: this, cm: cm, Editor: Editor}; michael@0: michael@0: if (name === "initialize") { michael@0: funcs[name](ctx); michael@0: return; michael@0: } michael@0: michael@0: this[name] = funcs[name].bind(null, ctx); michael@0: }); michael@0: }, michael@0: michael@0: destroy: function () { michael@0: this.container = null; michael@0: this.config = null; michael@0: this.version = null; michael@0: this.emit("destroy"); michael@0: } michael@0: }; michael@0: michael@0: // Since Editor is a thin layer over CodeMirror some methods michael@0: // are mapped directly—without any changes. michael@0: michael@0: CM_MAPPING.forEach(function (name) { michael@0: Editor.prototype[name] = function (...args) { michael@0: let cm = editors.get(this); michael@0: return cm[name].apply(cm, args); michael@0: }; michael@0: }); michael@0: michael@0: // Static methods on the Editor object itself. michael@0: michael@0: /** michael@0: * Returns a string representation of a shortcut 'key' with michael@0: * a OS specific modifier. Cmd- for Macs, Ctrl- for other michael@0: * platforms. Useful with extraKeys configuration option. michael@0: * michael@0: * CodeMirror defines all keys with modifiers in the following michael@0: * order: Shift - Ctrl/Cmd - Alt - Key michael@0: */ michael@0: Editor.accel = function (key, modifiers={}) { michael@0: return (modifiers.shift ? "Shift-" : "") + michael@0: (Services.appinfo.OS == "Darwin" ? "Cmd-" : "Ctrl-") + michael@0: (modifiers.alt ? "Alt-" : "") + key; michael@0: }; michael@0: michael@0: /** michael@0: * Returns a string representation of a shortcut for a michael@0: * specified command 'cmd'. Append Cmd- for macs, Ctrl- for other michael@0: * platforms unless noaccel is specified in the options. Useful when overwriting michael@0: * or disabling default shortcuts. michael@0: */ michael@0: Editor.keyFor = function (cmd, opts={ noaccel: false }) { michael@0: let key = L10N.GetStringFromName(cmd + ".commandkey"); michael@0: return opts.noaccel ? key : Editor.accel(key); michael@0: }; michael@0: michael@0: // Since Gecko already provide complete and up to date list of CSS property michael@0: // names, values and color names, we compute them so that they can replace michael@0: // the ones used in CodeMirror while initiating an editor object. This is done michael@0: // here instead of the file codemirror/css.js so as to leave that file untouched michael@0: // and easily upgradable. michael@0: function getCSSKeywords() { michael@0: function keySet(array) { michael@0: var keys = {}; michael@0: for (var i = 0; i < array.length; ++i) { michael@0: keys[array[i]] = true; michael@0: } michael@0: return keys; michael@0: } michael@0: michael@0: let domUtils = Cc["@mozilla.org/inspector/dom-utils;1"] michael@0: .getService(Ci.inIDOMUtils); michael@0: let cssProperties = domUtils.getCSSPropertyNames(domUtils.INCLUDE_ALIASES); michael@0: let cssColors = {}; michael@0: let cssValues = {}; michael@0: cssProperties.forEach(property => { michael@0: if (property.contains("color")) { michael@0: domUtils.getCSSValuesForProperty(property).forEach(value => { michael@0: cssColors[value] = true; michael@0: }); michael@0: } michael@0: else { michael@0: domUtils.getCSSValuesForProperty(property).forEach(value => { michael@0: cssValues[value] = true; michael@0: }); michael@0: } michael@0: }); michael@0: return { michael@0: cssProperties: keySet(cssProperties), michael@0: cssValues: cssValues, michael@0: cssColors: cssColors michael@0: }; michael@0: } michael@0: michael@0: /** michael@0: * Returns a controller object that can be used for michael@0: * editor-specific commands such as find, jump to line, michael@0: * copy/paste, etc. michael@0: */ michael@0: function controller(ed) { michael@0: return { michael@0: supportsCommand: function (cmd) { michael@0: switch (cmd) { michael@0: case "cmd_find": michael@0: case "cmd_findAgain": michael@0: case "cmd_findPrevious": michael@0: case "cmd_gotoLine": michael@0: case "cmd_undo": michael@0: case "cmd_redo": michael@0: case "cmd_delete": michael@0: case "cmd_selectAll": michael@0: return true; michael@0: } michael@0: michael@0: return false; michael@0: }, michael@0: michael@0: isCommandEnabled: function (cmd) { michael@0: let cm = editors.get(ed); michael@0: michael@0: switch (cmd) { michael@0: case "cmd_find": michael@0: case "cmd_gotoLine": michael@0: case "cmd_selectAll": michael@0: return true; michael@0: case "cmd_findAgain": michael@0: return cm.state.search != null && cm.state.search.query != null; michael@0: case "cmd_undo": michael@0: return ed.canUndo(); michael@0: case "cmd_redo": michael@0: return ed.canRedo(); michael@0: case "cmd_delete": michael@0: return ed.somethingSelected(); michael@0: } michael@0: michael@0: return false; michael@0: }, michael@0: michael@0: doCommand: function (cmd) { michael@0: let cm = editors.get(ed); michael@0: let map = { michael@0: "cmd_selectAll": "selectAll", michael@0: "cmd_find": "find", michael@0: "cmd_undo": "undo", michael@0: "cmd_redo": "redo", michael@0: "cmd_delete": "delCharAfter", michael@0: "cmd_findAgain": "findNext" michael@0: }; michael@0: michael@0: if (map[cmd]) { michael@0: cm.execCommand(map[cmd]); michael@0: return; michael@0: } michael@0: michael@0: if (cmd == "cmd_gotoLine") michael@0: ed.jumpToLine(); michael@0: }, michael@0: michael@0: onEvent: function () {} michael@0: }; michael@0: } michael@0: michael@0: /** michael@0: * Detect the indentation used in an editor. Returns an object michael@0: * with 'tabs' - whether this is tab-indented and 'spaces' - the michael@0: * width of one indent in spaces. Or `null` if it's inconclusive. michael@0: */ michael@0: function detectIndentation(ed) { michael@0: let cm = editors.get(ed); michael@0: michael@0: let spaces = {}; // # spaces indent -> # lines with that indent michael@0: let last = 0; // indentation width of the last line we saw michael@0: let tabs = 0; // # of lines that start with a tab michael@0: let total = 0; // # of indented lines (non-zero indent) michael@0: michael@0: cm.eachLine(0, DETECT_INDENT_MAX_LINES, (line) => { michael@0: let text = line.text; michael@0: michael@0: if (text.startsWith("\t")) { michael@0: tabs++; michael@0: total++; michael@0: return; michael@0: } michael@0: let width = 0; michael@0: while (text[width] === " ") { michael@0: width++; michael@0: } michael@0: // don't count lines that are all spaces michael@0: if (width == text.length) { michael@0: last = 0; michael@0: return; michael@0: } michael@0: if (width > 1) { michael@0: total++; michael@0: } michael@0: michael@0: // see how much this line is offset from the line above it michael@0: let indent = Math.abs(width - last); michael@0: if (indent > 1 && indent <= 8) { michael@0: spaces[indent] = (spaces[indent] || 0) + 1; michael@0: } michael@0: last = width; michael@0: }); michael@0: michael@0: // this file is not indented at all michael@0: if (total == 0) { michael@0: return null; michael@0: } michael@0: michael@0: // mark as tabs if they start more than half the lines michael@0: if (tabs >= total / 2) { michael@0: return { tabs: true }; michael@0: } michael@0: michael@0: // find most frequent non-zero width difference between adjacent lines michael@0: let freqIndent = null, max = 1; michael@0: for (let width in spaces) { michael@0: width = parseInt(width, 10); michael@0: let tally = spaces[width]; michael@0: if (tally > max) { michael@0: max = tally; michael@0: freqIndent = width; michael@0: } michael@0: } michael@0: if (!freqIndent) { michael@0: return null; michael@0: } michael@0: michael@0: return { tabs: false, spaces: freqIndent }; michael@0: } michael@0: michael@0: module.exports = Editor;