|
1 /* vim:set ts=2 sw=2 sts=2 et tw=80: |
|
2 * This Source Code Form is subject to the terms of the Mozilla Public |
|
3 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
5 |
|
6 "use strict"; |
|
7 |
|
8 const { Cu, Cc, Ci, components } = require("chrome"); |
|
9 |
|
10 const TAB_SIZE = "devtools.editor.tabsize"; |
|
11 const EXPAND_TAB = "devtools.editor.expandtab"; |
|
12 const KEYMAP = "devtools.editor.keymap"; |
|
13 const AUTO_CLOSE = "devtools.editor.autoclosebrackets"; |
|
14 const DETECT_INDENT = "devtools.editor.detectindentation"; |
|
15 const DETECT_INDENT_MAX_LINES = 500; |
|
16 const L10N_BUNDLE = "chrome://browser/locale/devtools/sourceeditor.properties"; |
|
17 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; |
|
18 |
|
19 // Maximum allowed margin (in number of lines) from top or bottom of the editor |
|
20 // while shifting to a line which was initially out of view. |
|
21 const MAX_VERTICAL_OFFSET = 3; |
|
22 |
|
23 const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); |
|
24 const events = require("devtools/toolkit/event-emitter"); |
|
25 |
|
26 Cu.import("resource://gre/modules/Services.jsm"); |
|
27 const L10N = Services.strings.createBundle(L10N_BUNDLE); |
|
28 |
|
29 // CM_STYLES, CM_SCRIPTS and CM_IFRAME represent the HTML, |
|
30 // JavaScript and CSS that is injected into an iframe in |
|
31 // order to initialize a CodeMirror instance. |
|
32 |
|
33 const CM_STYLES = [ |
|
34 "chrome://browser/skin/devtools/common.css", |
|
35 "chrome://browser/content/devtools/codemirror/codemirror.css", |
|
36 "chrome://browser/content/devtools/codemirror/dialog.css", |
|
37 "chrome://browser/content/devtools/codemirror/mozilla.css" |
|
38 ]; |
|
39 |
|
40 const CM_SCRIPTS = [ |
|
41 "chrome://browser/content/devtools/theme-switching.js", |
|
42 "chrome://browser/content/devtools/codemirror/codemirror.js", |
|
43 "chrome://browser/content/devtools/codemirror/dialog.js", |
|
44 "chrome://browser/content/devtools/codemirror/searchcursor.js", |
|
45 "chrome://browser/content/devtools/codemirror/search.js", |
|
46 "chrome://browser/content/devtools/codemirror/matchbrackets.js", |
|
47 "chrome://browser/content/devtools/codemirror/closebrackets.js", |
|
48 "chrome://browser/content/devtools/codemirror/comment.js", |
|
49 "chrome://browser/content/devtools/codemirror/javascript.js", |
|
50 "chrome://browser/content/devtools/codemirror/xml.js", |
|
51 "chrome://browser/content/devtools/codemirror/css.js", |
|
52 "chrome://browser/content/devtools/codemirror/htmlmixed.js", |
|
53 "chrome://browser/content/devtools/codemirror/clike.js", |
|
54 "chrome://browser/content/devtools/codemirror/activeline.js", |
|
55 "chrome://browser/content/devtools/codemirror/trailingspace.js", |
|
56 "chrome://browser/content/devtools/codemirror/emacs.js", |
|
57 "chrome://browser/content/devtools/codemirror/vim.js", |
|
58 "chrome://browser/content/devtools/codemirror/sublime.js", |
|
59 "chrome://browser/content/devtools/codemirror/foldcode.js", |
|
60 "chrome://browser/content/devtools/codemirror/brace-fold.js", |
|
61 "chrome://browser/content/devtools/codemirror/comment-fold.js", |
|
62 "chrome://browser/content/devtools/codemirror/xml-fold.js", |
|
63 "chrome://browser/content/devtools/codemirror/foldgutter.js" |
|
64 ]; |
|
65 |
|
66 const CM_IFRAME = |
|
67 "data:text/html;charset=utf8,<!DOCTYPE html>" + |
|
68 "<html dir='ltr'>" + |
|
69 " <head>" + |
|
70 " <style>" + |
|
71 " html, body { height: 100%; }" + |
|
72 " body { margin: 0; overflow: hidden; }" + |
|
73 " .CodeMirror { width: 100%; height: 100% !important; line-height: 1.25 !important;}" + |
|
74 " </style>" + |
|
75 [ " <link rel='stylesheet' href='" + style + "'>" for (style of CM_STYLES) ].join("\n") + |
|
76 " </head>" + |
|
77 " <body class='theme-body devtools-monospace'></body>" + |
|
78 "</html>"; |
|
79 |
|
80 const CM_MAPPING = [ |
|
81 "focus", |
|
82 "hasFocus", |
|
83 "lineCount", |
|
84 "somethingSelected", |
|
85 "getCursor", |
|
86 "setSelection", |
|
87 "getSelection", |
|
88 "replaceSelection", |
|
89 "extendSelection", |
|
90 "undo", |
|
91 "redo", |
|
92 "clearHistory", |
|
93 "openDialog", |
|
94 "refresh", |
|
95 "getScrollInfo", |
|
96 "getOption", |
|
97 "setOption" |
|
98 ]; |
|
99 |
|
100 const { cssProperties, cssValues, cssColors } = getCSSKeywords(); |
|
101 |
|
102 const editors = new WeakMap(); |
|
103 |
|
104 Editor.modes = { |
|
105 text: { name: "text" }, |
|
106 html: { name: "htmlmixed" }, |
|
107 css: { name: "css" }, |
|
108 js: { name: "javascript" }, |
|
109 vs: { name: "x-shader/x-vertex" }, |
|
110 fs: { name: "x-shader/x-fragment" } |
|
111 }; |
|
112 |
|
113 /** |
|
114 * A very thin wrapper around CodeMirror. Provides a number |
|
115 * of helper methods to make our use of CodeMirror easier and |
|
116 * another method, appendTo, to actually create and append |
|
117 * the CodeMirror instance. |
|
118 * |
|
119 * Note that Editor doesn't expose CodeMirror instance to the |
|
120 * outside world. |
|
121 * |
|
122 * Constructor accepts one argument, config. It is very |
|
123 * similar to the CodeMirror configuration object so for most |
|
124 * properties go to CodeMirror's documentation (see below). |
|
125 * |
|
126 * Other than that, it accepts one additional and optional |
|
127 * property contextMenu. This property should be an ID of |
|
128 * an element we can use as a context menu. |
|
129 * |
|
130 * This object is also an event emitter. |
|
131 * |
|
132 * CodeMirror docs: http://codemirror.net/doc/manual.html |
|
133 */ |
|
134 function Editor(config) { |
|
135 const tabSize = Services.prefs.getIntPref(TAB_SIZE); |
|
136 const useTabs = !Services.prefs.getBoolPref(EXPAND_TAB); |
|
137 const keyMap = Services.prefs.getCharPref(KEYMAP); |
|
138 const useAutoClose = Services.prefs.getBoolPref(AUTO_CLOSE); |
|
139 |
|
140 this.version = null; |
|
141 this.config = { |
|
142 value: "", |
|
143 mode: Editor.modes.text, |
|
144 indentUnit: tabSize, |
|
145 tabSize: tabSize, |
|
146 contextMenu: null, |
|
147 matchBrackets: true, |
|
148 extraKeys: {}, |
|
149 indentWithTabs: useTabs, |
|
150 styleActiveLine: true, |
|
151 autoCloseBrackets: "()[]{}''\"\"", |
|
152 autoCloseEnabled: useAutoClose, |
|
153 theme: "mozilla" |
|
154 }; |
|
155 |
|
156 // Additional shortcuts. |
|
157 this.config.extraKeys[Editor.keyFor("jumpToLine")] = () => this.jumpToLine(); |
|
158 this.config.extraKeys[Editor.keyFor("moveLineUp", { noaccel: true })] = () => this.moveLineUp(); |
|
159 this.config.extraKeys[Editor.keyFor("moveLineDown", { noaccel: true })] = () => this.moveLineDown(); |
|
160 this.config.extraKeys[Editor.keyFor("toggleComment")] = "toggleComment"; |
|
161 |
|
162 // Disable ctrl-[ and ctrl-] because toolbox uses those shortcuts. |
|
163 this.config.extraKeys[Editor.keyFor("indentLess")] = false; |
|
164 this.config.extraKeys[Editor.keyFor("indentMore")] = false; |
|
165 |
|
166 // If alternative keymap is provided, use it. |
|
167 if (keyMap === "emacs" || keyMap === "vim" || keyMap === "sublime") |
|
168 this.config.keyMap = keyMap; |
|
169 |
|
170 // Overwrite default config with user-provided, if needed. |
|
171 Object.keys(config).forEach((k) => { |
|
172 if (k != "extraKeys") { |
|
173 this.config[k] = config[k]; |
|
174 return; |
|
175 } |
|
176 |
|
177 if (!config.extraKeys) |
|
178 return; |
|
179 |
|
180 Object.keys(config.extraKeys).forEach((key) => { |
|
181 this.config.extraKeys[key] = config.extraKeys[key]; |
|
182 }); |
|
183 }); |
|
184 |
|
185 // Set the code folding gutter, if needed. |
|
186 if (this.config.enableCodeFolding) { |
|
187 this.config.foldGutter = true; |
|
188 |
|
189 if (!this.config.gutters) { |
|
190 this.config.gutters = this.config.lineNumbers ? ["CodeMirror-linenumbers"] : []; |
|
191 this.config.gutters.push("CodeMirror-foldgutter"); |
|
192 } |
|
193 } |
|
194 |
|
195 // Configure automatic bracket closing. |
|
196 if (!this.config.autoCloseEnabled) |
|
197 this.config.autoCloseBrackets = false; |
|
198 |
|
199 // Overwrite default tab behavior. If something is selected, |
|
200 // indent those lines. If nothing is selected and we're |
|
201 // indenting with tabs, insert one tab. Otherwise insert N |
|
202 // whitespaces where N == indentUnit option. |
|
203 this.config.extraKeys.Tab = (cm) => { |
|
204 if (cm.somethingSelected()) { |
|
205 cm.indentSelection("add"); |
|
206 return; |
|
207 } |
|
208 |
|
209 if (this.config.indentWithTabs) { |
|
210 cm.replaceSelection("\t", "end", "+input"); |
|
211 return; |
|
212 } |
|
213 |
|
214 var num = cm.getOption("indentUnit"); |
|
215 if (cm.getCursor().ch !== 0) num -= 1; |
|
216 cm.replaceSelection(" ".repeat(num), "end", "+input"); |
|
217 }; |
|
218 |
|
219 events.decorate(this); |
|
220 } |
|
221 |
|
222 Editor.prototype = { |
|
223 container: null, |
|
224 version: null, |
|
225 config: null, |
|
226 |
|
227 /** |
|
228 * Appends the current Editor instance to the element specified by |
|
229 * 'el'. You can also provide your won iframe to host the editor as |
|
230 * an optional second parameter. This method actually creates and |
|
231 * loads CodeMirror and all its dependencies. |
|
232 * |
|
233 * This method is asynchronous and returns a promise. |
|
234 */ |
|
235 appendTo: function (el, env) { |
|
236 let def = promise.defer(); |
|
237 let cm = editors.get(this); |
|
238 |
|
239 if (!env) |
|
240 env = el.ownerDocument.createElementNS(XUL_NS, "iframe"); |
|
241 |
|
242 env.flex = 1; |
|
243 |
|
244 if (cm) |
|
245 throw new Error("You can append an editor only once."); |
|
246 |
|
247 let onLoad = () => { |
|
248 // Once the iframe is loaded, we can inject CodeMirror |
|
249 // and its dependencies into its DOM. |
|
250 |
|
251 env.removeEventListener("load", onLoad, true); |
|
252 let win = env.contentWindow.wrappedJSObject; |
|
253 |
|
254 CM_SCRIPTS.forEach((url) => |
|
255 Services.scriptloader.loadSubScript(url, win, "utf8")); |
|
256 |
|
257 // Replace the propertyKeywords, colorKeywords and valueKeywords |
|
258 // properties of the CSS MIME type with the values provided by Gecko. |
|
259 let cssSpec = win.CodeMirror.resolveMode("text/css"); |
|
260 cssSpec.propertyKeywords = cssProperties; |
|
261 cssSpec.colorKeywords = cssColors; |
|
262 cssSpec.valueKeywords = cssValues; |
|
263 win.CodeMirror.defineMIME("text/css", cssSpec); |
|
264 |
|
265 let scssSpec = win.CodeMirror.resolveMode("text/x-scss"); |
|
266 scssSpec.propertyKeywords = cssProperties; |
|
267 scssSpec.colorKeywords = cssColors; |
|
268 scssSpec.valueKeywords = cssValues; |
|
269 win.CodeMirror.defineMIME("text/x-scss", scssSpec); |
|
270 |
|
271 win.CodeMirror.commands.save = () => this.emit("save"); |
|
272 |
|
273 // Create a CodeMirror instance add support for context menus, |
|
274 // overwrite the default controller (otherwise items in the top and |
|
275 // context menus won't work). |
|
276 |
|
277 cm = win.CodeMirror(win.document.body, this.config); |
|
278 cm.getWrapperElement().addEventListener("contextmenu", (ev) => { |
|
279 ev.preventDefault(); |
|
280 if (!this.config.contextMenu) return; |
|
281 let popup = el.ownerDocument.getElementById(this.config.contextMenu); |
|
282 popup.openPopupAtScreen(ev.screenX, ev.screenY, true); |
|
283 }, false); |
|
284 |
|
285 cm.on("focus", () => this.emit("focus")); |
|
286 cm.on("scroll", () => this.emit("scroll")); |
|
287 cm.on("change", () => { |
|
288 this.emit("change"); |
|
289 if (!this._lastDirty) { |
|
290 this._lastDirty = true; |
|
291 this.emit("dirty-change"); |
|
292 } |
|
293 }); |
|
294 cm.on("cursorActivity", (cm) => this.emit("cursorActivity")); |
|
295 |
|
296 cm.on("gutterClick", (cm, line, gutter, ev) => { |
|
297 let head = { line: line, ch: 0 }; |
|
298 let tail = { line: line, ch: this.getText(line).length }; |
|
299 |
|
300 // Shift-click on a gutter selects the whole line. |
|
301 if (ev.shiftKey) { |
|
302 cm.setSelection(head, tail); |
|
303 return; |
|
304 } |
|
305 |
|
306 this.emit("gutterClick", line); |
|
307 }); |
|
308 |
|
309 win.CodeMirror.defineExtension("l10n", (name) => { |
|
310 return L10N.GetStringFromName(name); |
|
311 }); |
|
312 |
|
313 cm.getInputField().controllers.insertControllerAt(0, controller(this)); |
|
314 |
|
315 this.container = env; |
|
316 editors.set(this, cm); |
|
317 |
|
318 this.resetIndentUnit(); |
|
319 |
|
320 def.resolve(); |
|
321 }; |
|
322 |
|
323 env.addEventListener("load", onLoad, true); |
|
324 env.setAttribute("src", CM_IFRAME); |
|
325 el.appendChild(env); |
|
326 |
|
327 this.once("destroy", () => el.removeChild(env)); |
|
328 return def.promise; |
|
329 }, |
|
330 |
|
331 /** |
|
332 * Returns the currently active highlighting mode. |
|
333 * See Editor.modes for the list of all suppoert modes. |
|
334 */ |
|
335 getMode: function () { |
|
336 return this.getOption("mode"); |
|
337 }, |
|
338 |
|
339 /** |
|
340 * Changes the value of a currently used highlighting mode. |
|
341 * See Editor.modes for the list of all suppoert modes. |
|
342 */ |
|
343 setMode: function (value) { |
|
344 this.setOption("mode", value); |
|
345 }, |
|
346 |
|
347 /** |
|
348 * Returns text from the text area. If line argument is provided |
|
349 * the method returns only that line. |
|
350 */ |
|
351 getText: function (line) { |
|
352 let cm = editors.get(this); |
|
353 |
|
354 if (line == null) |
|
355 return cm.getValue(); |
|
356 |
|
357 let info = cm.lineInfo(line); |
|
358 return info ? cm.lineInfo(line).text : ""; |
|
359 }, |
|
360 |
|
361 /** |
|
362 * Replaces whatever is in the text area with the contents of |
|
363 * the 'value' argument. |
|
364 */ |
|
365 setText: function (value) { |
|
366 let cm = editors.get(this); |
|
367 cm.setValue(value); |
|
368 |
|
369 this.resetIndentUnit(); |
|
370 }, |
|
371 |
|
372 /** |
|
373 * Set the editor's indentation based on the current prefs and |
|
374 * re-detect indentation if we should. |
|
375 */ |
|
376 resetIndentUnit: function() { |
|
377 let cm = editors.get(this); |
|
378 |
|
379 let indentWithTabs = !Services.prefs.getBoolPref(EXPAND_TAB); |
|
380 let indentUnit = Services.prefs.getIntPref(TAB_SIZE); |
|
381 let shouldDetect = Services.prefs.getBoolPref(DETECT_INDENT); |
|
382 |
|
383 cm.setOption("tabSize", indentUnit); |
|
384 |
|
385 if (shouldDetect) { |
|
386 let indent = detectIndentation(this); |
|
387 if (indent != null) { |
|
388 indentWithTabs = indent.tabs; |
|
389 indentUnit = indent.spaces ? indent.spaces : indentUnit; |
|
390 } |
|
391 } |
|
392 |
|
393 cm.setOption("indentUnit", indentUnit); |
|
394 cm.setOption("indentWithTabs", indentWithTabs); |
|
395 }, |
|
396 |
|
397 /** |
|
398 * Replaces contents of a text area within the from/to {line, ch} |
|
399 * range. If neither from nor to arguments are provided works |
|
400 * exactly like setText. If only from object is provided, inserts |
|
401 * text at that point, *overwriting* as many characters as needed. |
|
402 */ |
|
403 replaceText: function (value, from, to) { |
|
404 let cm = editors.get(this); |
|
405 |
|
406 if (!from) { |
|
407 this.setText(value); |
|
408 return; |
|
409 } |
|
410 |
|
411 if (!to) { |
|
412 let text = cm.getRange({ line: 0, ch: 0 }, from); |
|
413 this.setText(text + value); |
|
414 return; |
|
415 } |
|
416 |
|
417 cm.replaceRange(value, from, to); |
|
418 }, |
|
419 |
|
420 /** |
|
421 * Inserts text at the specified {line, ch} position, shifting existing |
|
422 * contents as necessary. |
|
423 */ |
|
424 insertText: function (value, at) { |
|
425 let cm = editors.get(this); |
|
426 cm.replaceRange(value, at, at); |
|
427 }, |
|
428 |
|
429 /** |
|
430 * Deselects contents of the text area. |
|
431 */ |
|
432 dropSelection: function () { |
|
433 if (!this.somethingSelected()) |
|
434 return; |
|
435 |
|
436 this.setCursor(this.getCursor()); |
|
437 }, |
|
438 |
|
439 /** |
|
440 * Returns true if there is more than one selection in the editor. |
|
441 */ |
|
442 hasMultipleSelections: function () { |
|
443 let cm = editors.get(this); |
|
444 return cm.listSelections().length > 1; |
|
445 }, |
|
446 |
|
447 /** |
|
448 * Gets the first visible line number in the editor. |
|
449 */ |
|
450 getFirstVisibleLine: function () { |
|
451 let cm = editors.get(this); |
|
452 return cm.lineAtHeight(0, "local"); |
|
453 }, |
|
454 |
|
455 /** |
|
456 * Scrolls the view such that the given line number is the first visible line. |
|
457 */ |
|
458 setFirstVisibleLine: function (line) { |
|
459 let cm = editors.get(this); |
|
460 let { top } = cm.charCoords({line: line, ch: 0}, "local"); |
|
461 cm.scrollTo(0, top); |
|
462 }, |
|
463 |
|
464 /** |
|
465 * Sets the cursor to the specified {line, ch} position with an additional |
|
466 * option to align the line at the "top", "center" or "bottom" of the editor |
|
467 * with "top" being default value. |
|
468 */ |
|
469 setCursor: function ({line, ch}, align) { |
|
470 let cm = editors.get(this); |
|
471 this.alignLine(line, align); |
|
472 cm.setCursor({line: line, ch: ch}); |
|
473 }, |
|
474 |
|
475 /** |
|
476 * Aligns the provided line to either "top", "center" or "bottom" of the |
|
477 * editor view with a maximum margin of MAX_VERTICAL_OFFSET lines from top or |
|
478 * bottom. |
|
479 */ |
|
480 alignLine: function(line, align) { |
|
481 let cm = editors.get(this); |
|
482 let from = cm.lineAtHeight(0, "page"); |
|
483 let to = cm.lineAtHeight(cm.getWrapperElement().clientHeight, "page"); |
|
484 let linesVisible = to - from; |
|
485 let halfVisible = Math.round(linesVisible/2); |
|
486 |
|
487 // If the target line is in view, skip the vertical alignment part. |
|
488 if (line <= to && line >= from) { |
|
489 return; |
|
490 } |
|
491 |
|
492 // Setting the offset so that the line always falls in the upper half |
|
493 // of visible lines (lower half for bottom aligned). |
|
494 // MAX_VERTICAL_OFFSET is the maximum allowed value. |
|
495 let offset = Math.min(halfVisible, MAX_VERTICAL_OFFSET); |
|
496 |
|
497 let topLine = { |
|
498 "center": Math.max(line - halfVisible, 0), |
|
499 "bottom": Math.max(line - linesVisible + offset, 0), |
|
500 "top": Math.max(line - offset, 0) |
|
501 }[align || "top"] || offset; |
|
502 |
|
503 // Bringing down the topLine to total lines in the editor if exceeding. |
|
504 topLine = Math.min(topLine, this.lineCount()); |
|
505 this.setFirstVisibleLine(topLine); |
|
506 }, |
|
507 |
|
508 /** |
|
509 * Returns whether a marker of a specified class exists in a line's gutter. |
|
510 */ |
|
511 hasMarker: function (line, gutterName, markerClass) { |
|
512 let cm = editors.get(this); |
|
513 let info = cm.lineInfo(line); |
|
514 if (!info) |
|
515 return false; |
|
516 |
|
517 let gutterMarkers = info.gutterMarkers; |
|
518 if (!gutterMarkers) |
|
519 return false; |
|
520 |
|
521 let marker = gutterMarkers[gutterName]; |
|
522 if (!marker) |
|
523 return false; |
|
524 |
|
525 return marker.classList.contains(markerClass); |
|
526 }, |
|
527 |
|
528 /** |
|
529 * Adds a marker with a specified class to a line's gutter. If another marker |
|
530 * exists on that line, the new marker class is added to its class list. |
|
531 */ |
|
532 addMarker: function (line, gutterName, markerClass) { |
|
533 let cm = editors.get(this); |
|
534 let info = cm.lineInfo(line); |
|
535 if (!info) |
|
536 return; |
|
537 |
|
538 let gutterMarkers = info.gutterMarkers; |
|
539 if (gutterMarkers) { |
|
540 let marker = gutterMarkers[gutterName]; |
|
541 if (marker) { |
|
542 marker.classList.add(markerClass); |
|
543 return; |
|
544 } |
|
545 } |
|
546 |
|
547 let marker = cm.getWrapperElement().ownerDocument.createElement("div"); |
|
548 marker.className = markerClass; |
|
549 cm.setGutterMarker(info.line, gutterName, marker); |
|
550 }, |
|
551 |
|
552 /** |
|
553 * The reverse of addMarker. Removes a marker of a specified class from a |
|
554 * line's gutter. |
|
555 */ |
|
556 removeMarker: function (line, gutterName, markerClass) { |
|
557 if (!this.hasMarker(line, gutterName, markerClass)) |
|
558 return; |
|
559 |
|
560 let cm = editors.get(this); |
|
561 cm.lineInfo(line).gutterMarkers[gutterName].classList.remove(markerClass); |
|
562 }, |
|
563 |
|
564 /** |
|
565 * Remove all gutter markers in the gutter with the given name. |
|
566 */ |
|
567 removeAllMarkers: function (gutterName) { |
|
568 let cm = editors.get(this); |
|
569 cm.clearGutter(gutterName); |
|
570 }, |
|
571 |
|
572 /** |
|
573 * Handles attaching a set of events listeners on a marker. They should |
|
574 * be passed as an object literal with keys as event names and values as |
|
575 * function listeners. The line number, marker node and optional data |
|
576 * will be passed as arguments to the function listener. |
|
577 * |
|
578 * You don't need to worry about removing these event listeners. |
|
579 * They're automatically orphaned when clearing markers. |
|
580 */ |
|
581 setMarkerListeners: function(line, gutterName, markerClass, events, data) { |
|
582 if (!this.hasMarker(line, gutterName, markerClass)) |
|
583 return; |
|
584 |
|
585 let cm = editors.get(this); |
|
586 let marker = cm.lineInfo(line).gutterMarkers[gutterName]; |
|
587 |
|
588 for (let name in events) { |
|
589 let listener = events[name].bind(this, line, marker, data); |
|
590 marker.addEventListener(name, listener); |
|
591 } |
|
592 }, |
|
593 |
|
594 /** |
|
595 * Returns whether a line is decorated using the specified class name. |
|
596 */ |
|
597 hasLineClass: function (line, className) { |
|
598 let cm = editors.get(this); |
|
599 let info = cm.lineInfo(line); |
|
600 |
|
601 if (!info || !info.wrapClass) |
|
602 return false; |
|
603 |
|
604 return info.wrapClass.split(" ").indexOf(className) != -1; |
|
605 }, |
|
606 |
|
607 /** |
|
608 * Set a CSS class name for the given line, including the text and gutter. |
|
609 */ |
|
610 addLineClass: function (line, className) { |
|
611 let cm = editors.get(this); |
|
612 cm.addLineClass(line, "wrap", className); |
|
613 }, |
|
614 |
|
615 /** |
|
616 * The reverse of addLineClass. |
|
617 */ |
|
618 removeLineClass: function (line, className) { |
|
619 let cm = editors.get(this); |
|
620 cm.removeLineClass(line, "wrap", className); |
|
621 }, |
|
622 |
|
623 /** |
|
624 * Mark a range of text inside the two {line, ch} bounds. Since the range may |
|
625 * be modified, for example, when typing text, this method returns a function |
|
626 * that can be used to remove the mark. |
|
627 */ |
|
628 markText: function(from, to, className = "marked-text") { |
|
629 let cm = editors.get(this); |
|
630 let text = cm.getRange(from, to); |
|
631 let span = cm.getWrapperElement().ownerDocument.createElement("span"); |
|
632 span.className = className; |
|
633 span.textContent = text; |
|
634 |
|
635 let mark = cm.markText(from, to, { replacedWith: span }); |
|
636 return { |
|
637 anchor: span, |
|
638 clear: () => mark.clear() |
|
639 }; |
|
640 }, |
|
641 |
|
642 /** |
|
643 * Calculates and returns one or more {line, ch} objects for |
|
644 * a zero-based index who's value is relative to the start of |
|
645 * the editor's text. |
|
646 * |
|
647 * If only one argument is given, this method returns a single |
|
648 * {line,ch} object. Otherwise it returns an array. |
|
649 */ |
|
650 getPosition: function (...args) { |
|
651 let cm = editors.get(this); |
|
652 let res = args.map((ind) => cm.posFromIndex(ind)); |
|
653 return args.length === 1 ? res[0] : res; |
|
654 }, |
|
655 |
|
656 /** |
|
657 * The reverse of getPosition. Similarly to getPosition this |
|
658 * method returns a single value if only one argument was given |
|
659 * and an array otherwise. |
|
660 */ |
|
661 getOffset: function (...args) { |
|
662 let cm = editors.get(this); |
|
663 let res = args.map((pos) => cm.indexFromPos(pos)); |
|
664 return args.length > 1 ? res : res[0]; |
|
665 }, |
|
666 |
|
667 /** |
|
668 * Returns a {line, ch} object that corresponds to the |
|
669 * left, top coordinates. |
|
670 */ |
|
671 getPositionFromCoords: function ({left, top}) { |
|
672 let cm = editors.get(this); |
|
673 return cm.coordsChar({ left: left, top: top }); |
|
674 }, |
|
675 |
|
676 /** |
|
677 * The reverse of getPositionFromCoords. Similarly, returns a {left, top} |
|
678 * object that corresponds to the specified line and character number. |
|
679 */ |
|
680 getCoordsFromPosition: function ({line, ch}) { |
|
681 let cm = editors.get(this); |
|
682 return cm.charCoords({ line: ~~line, ch: ~~ch }); |
|
683 }, |
|
684 |
|
685 /** |
|
686 * Returns true if there's something to undo and false otherwise. |
|
687 */ |
|
688 canUndo: function () { |
|
689 let cm = editors.get(this); |
|
690 return cm.historySize().undo > 0; |
|
691 }, |
|
692 |
|
693 /** |
|
694 * Returns true if there's something to redo and false otherwise. |
|
695 */ |
|
696 canRedo: function () { |
|
697 let cm = editors.get(this); |
|
698 return cm.historySize().redo > 0; |
|
699 }, |
|
700 |
|
701 /** |
|
702 * Marks the contents as clean and returns the current |
|
703 * version number. |
|
704 */ |
|
705 setClean: function () { |
|
706 let cm = editors.get(this); |
|
707 this.version = cm.changeGeneration(); |
|
708 this._lastDirty = false; |
|
709 this.emit("dirty-change"); |
|
710 return this.version; |
|
711 }, |
|
712 |
|
713 /** |
|
714 * Returns true if contents of the text area are |
|
715 * clean i.e. no changes were made since the last version. |
|
716 */ |
|
717 isClean: function () { |
|
718 let cm = editors.get(this); |
|
719 return cm.isClean(this.version); |
|
720 }, |
|
721 |
|
722 /** |
|
723 * This method opens an in-editor dialog asking for a line to |
|
724 * jump to. Once given, it changes cursor to that line. |
|
725 */ |
|
726 jumpToLine: function () { |
|
727 let doc = editors.get(this).getWrapperElement().ownerDocument; |
|
728 let div = doc.createElement("div"); |
|
729 let inp = doc.createElement("input"); |
|
730 let txt = doc.createTextNode(L10N.GetStringFromName("gotoLineCmd.promptTitle")); |
|
731 |
|
732 inp.type = "text"; |
|
733 inp.style.width = "10em"; |
|
734 inp.style.MozMarginStart = "1em"; |
|
735 |
|
736 div.appendChild(txt); |
|
737 div.appendChild(inp); |
|
738 |
|
739 this.openDialog(div, (line) => this.setCursor({ line: line - 1, ch: 0 })); |
|
740 }, |
|
741 |
|
742 /** |
|
743 * Moves the content of the current line or the lines selected up a line. |
|
744 */ |
|
745 moveLineUp: function () { |
|
746 let cm = editors.get(this); |
|
747 let start = cm.getCursor("start"); |
|
748 let end = cm.getCursor("end"); |
|
749 |
|
750 if (start.line === 0) |
|
751 return; |
|
752 |
|
753 // Get the text in the lines selected or the current line of the cursor |
|
754 // and append the text of the previous line. |
|
755 let value; |
|
756 if (start.line !== end.line) { |
|
757 value = cm.getRange({ line: start.line, ch: 0 }, |
|
758 { line: end.line, ch: cm.getLine(end.line).length }) + "\n"; |
|
759 } else { |
|
760 value = cm.getLine(start.line) + "\n"; |
|
761 } |
|
762 value += cm.getLine(start.line - 1); |
|
763 |
|
764 // Replace the previous line and the currently selected lines with the new |
|
765 // value and maintain the selection of the text. |
|
766 cm.replaceRange(value, { line: start.line - 1, ch: 0 }, |
|
767 { line: end.line, ch: cm.getLine(end.line).length }); |
|
768 cm.setSelection({ line: start.line - 1, ch: start.ch }, |
|
769 { line: end.line - 1, ch: end.ch }); |
|
770 }, |
|
771 |
|
772 /** |
|
773 * Moves the content of the current line or the lines selected down a line. |
|
774 */ |
|
775 moveLineDown: function () { |
|
776 let cm = editors.get(this); |
|
777 let start = cm.getCursor("start"); |
|
778 let end = cm.getCursor("end"); |
|
779 |
|
780 if (end.line + 1 === cm.lineCount()) |
|
781 return; |
|
782 |
|
783 // Get the text of next line and append the text in the lines selected |
|
784 // or the current line of the cursor. |
|
785 let value = cm.getLine(end.line + 1) + "\n"; |
|
786 if (start.line !== end.line) { |
|
787 value += cm.getRange({ line: start.line, ch: 0 }, |
|
788 { line: end.line, ch: cm.getLine(end.line).length }); |
|
789 } else { |
|
790 value += cm.getLine(start.line); |
|
791 } |
|
792 |
|
793 // Replace the currently selected lines and the next line with the new |
|
794 // value and maintain the selection of the text. |
|
795 cm.replaceRange(value, { line: start.line, ch: 0 }, |
|
796 { line: end.line + 1, ch: cm.getLine(end.line + 1).length}); |
|
797 cm.setSelection({ line: start.line + 1, ch: start.ch }, |
|
798 { line: end.line + 1, ch: end.ch }); |
|
799 }, |
|
800 |
|
801 /** |
|
802 * Returns current font size for the editor area, in pixels. |
|
803 */ |
|
804 getFontSize: function () { |
|
805 let cm = editors.get(this); |
|
806 let el = cm.getWrapperElement(); |
|
807 let win = el.ownerDocument.defaultView; |
|
808 |
|
809 return parseInt(win.getComputedStyle(el).getPropertyValue("font-size"), 10); |
|
810 }, |
|
811 |
|
812 /** |
|
813 * Sets font size for the editor area. |
|
814 */ |
|
815 setFontSize: function (size) { |
|
816 let cm = editors.get(this); |
|
817 cm.getWrapperElement().style.fontSize = parseInt(size, 10) + "px"; |
|
818 cm.refresh(); |
|
819 }, |
|
820 |
|
821 /** |
|
822 * Extends an instance of the Editor object with additional |
|
823 * functions. Each function will be called with context as |
|
824 * the first argument. Context is a {ed, cm} object where |
|
825 * 'ed' is an instance of the Editor object and 'cm' is an |
|
826 * instance of the CodeMirror object. Example: |
|
827 * |
|
828 * function hello(ctx, name) { |
|
829 * let { cm, ed } = ctx; |
|
830 * cm; // CodeMirror instance |
|
831 * ed; // Editor instance |
|
832 * name; // 'Mozilla' |
|
833 * } |
|
834 * |
|
835 * editor.extend({ hello: hello }); |
|
836 * editor.hello('Mozilla'); |
|
837 */ |
|
838 extend: function (funcs) { |
|
839 Object.keys(funcs).forEach((name) => { |
|
840 let cm = editors.get(this); |
|
841 let ctx = { ed: this, cm: cm, Editor: Editor}; |
|
842 |
|
843 if (name === "initialize") { |
|
844 funcs[name](ctx); |
|
845 return; |
|
846 } |
|
847 |
|
848 this[name] = funcs[name].bind(null, ctx); |
|
849 }); |
|
850 }, |
|
851 |
|
852 destroy: function () { |
|
853 this.container = null; |
|
854 this.config = null; |
|
855 this.version = null; |
|
856 this.emit("destroy"); |
|
857 } |
|
858 }; |
|
859 |
|
860 // Since Editor is a thin layer over CodeMirror some methods |
|
861 // are mapped directly—without any changes. |
|
862 |
|
863 CM_MAPPING.forEach(function (name) { |
|
864 Editor.prototype[name] = function (...args) { |
|
865 let cm = editors.get(this); |
|
866 return cm[name].apply(cm, args); |
|
867 }; |
|
868 }); |
|
869 |
|
870 // Static methods on the Editor object itself. |
|
871 |
|
872 /** |
|
873 * Returns a string representation of a shortcut 'key' with |
|
874 * a OS specific modifier. Cmd- for Macs, Ctrl- for other |
|
875 * platforms. Useful with extraKeys configuration option. |
|
876 * |
|
877 * CodeMirror defines all keys with modifiers in the following |
|
878 * order: Shift - Ctrl/Cmd - Alt - Key |
|
879 */ |
|
880 Editor.accel = function (key, modifiers={}) { |
|
881 return (modifiers.shift ? "Shift-" : "") + |
|
882 (Services.appinfo.OS == "Darwin" ? "Cmd-" : "Ctrl-") + |
|
883 (modifiers.alt ? "Alt-" : "") + key; |
|
884 }; |
|
885 |
|
886 /** |
|
887 * Returns a string representation of a shortcut for a |
|
888 * specified command 'cmd'. Append Cmd- for macs, Ctrl- for other |
|
889 * platforms unless noaccel is specified in the options. Useful when overwriting |
|
890 * or disabling default shortcuts. |
|
891 */ |
|
892 Editor.keyFor = function (cmd, opts={ noaccel: false }) { |
|
893 let key = L10N.GetStringFromName(cmd + ".commandkey"); |
|
894 return opts.noaccel ? key : Editor.accel(key); |
|
895 }; |
|
896 |
|
897 // Since Gecko already provide complete and up to date list of CSS property |
|
898 // names, values and color names, we compute them so that they can replace |
|
899 // the ones used in CodeMirror while initiating an editor object. This is done |
|
900 // here instead of the file codemirror/css.js so as to leave that file untouched |
|
901 // and easily upgradable. |
|
902 function getCSSKeywords() { |
|
903 function keySet(array) { |
|
904 var keys = {}; |
|
905 for (var i = 0; i < array.length; ++i) { |
|
906 keys[array[i]] = true; |
|
907 } |
|
908 return keys; |
|
909 } |
|
910 |
|
911 let domUtils = Cc["@mozilla.org/inspector/dom-utils;1"] |
|
912 .getService(Ci.inIDOMUtils); |
|
913 let cssProperties = domUtils.getCSSPropertyNames(domUtils.INCLUDE_ALIASES); |
|
914 let cssColors = {}; |
|
915 let cssValues = {}; |
|
916 cssProperties.forEach(property => { |
|
917 if (property.contains("color")) { |
|
918 domUtils.getCSSValuesForProperty(property).forEach(value => { |
|
919 cssColors[value] = true; |
|
920 }); |
|
921 } |
|
922 else { |
|
923 domUtils.getCSSValuesForProperty(property).forEach(value => { |
|
924 cssValues[value] = true; |
|
925 }); |
|
926 } |
|
927 }); |
|
928 return { |
|
929 cssProperties: keySet(cssProperties), |
|
930 cssValues: cssValues, |
|
931 cssColors: cssColors |
|
932 }; |
|
933 } |
|
934 |
|
935 /** |
|
936 * Returns a controller object that can be used for |
|
937 * editor-specific commands such as find, jump to line, |
|
938 * copy/paste, etc. |
|
939 */ |
|
940 function controller(ed) { |
|
941 return { |
|
942 supportsCommand: function (cmd) { |
|
943 switch (cmd) { |
|
944 case "cmd_find": |
|
945 case "cmd_findAgain": |
|
946 case "cmd_findPrevious": |
|
947 case "cmd_gotoLine": |
|
948 case "cmd_undo": |
|
949 case "cmd_redo": |
|
950 case "cmd_delete": |
|
951 case "cmd_selectAll": |
|
952 return true; |
|
953 } |
|
954 |
|
955 return false; |
|
956 }, |
|
957 |
|
958 isCommandEnabled: function (cmd) { |
|
959 let cm = editors.get(ed); |
|
960 |
|
961 switch (cmd) { |
|
962 case "cmd_find": |
|
963 case "cmd_gotoLine": |
|
964 case "cmd_selectAll": |
|
965 return true; |
|
966 case "cmd_findAgain": |
|
967 return cm.state.search != null && cm.state.search.query != null; |
|
968 case "cmd_undo": |
|
969 return ed.canUndo(); |
|
970 case "cmd_redo": |
|
971 return ed.canRedo(); |
|
972 case "cmd_delete": |
|
973 return ed.somethingSelected(); |
|
974 } |
|
975 |
|
976 return false; |
|
977 }, |
|
978 |
|
979 doCommand: function (cmd) { |
|
980 let cm = editors.get(ed); |
|
981 let map = { |
|
982 "cmd_selectAll": "selectAll", |
|
983 "cmd_find": "find", |
|
984 "cmd_undo": "undo", |
|
985 "cmd_redo": "redo", |
|
986 "cmd_delete": "delCharAfter", |
|
987 "cmd_findAgain": "findNext" |
|
988 }; |
|
989 |
|
990 if (map[cmd]) { |
|
991 cm.execCommand(map[cmd]); |
|
992 return; |
|
993 } |
|
994 |
|
995 if (cmd == "cmd_gotoLine") |
|
996 ed.jumpToLine(); |
|
997 }, |
|
998 |
|
999 onEvent: function () {} |
|
1000 }; |
|
1001 } |
|
1002 |
|
1003 /** |
|
1004 * Detect the indentation used in an editor. Returns an object |
|
1005 * with 'tabs' - whether this is tab-indented and 'spaces' - the |
|
1006 * width of one indent in spaces. Or `null` if it's inconclusive. |
|
1007 */ |
|
1008 function detectIndentation(ed) { |
|
1009 let cm = editors.get(ed); |
|
1010 |
|
1011 let spaces = {}; // # spaces indent -> # lines with that indent |
|
1012 let last = 0; // indentation width of the last line we saw |
|
1013 let tabs = 0; // # of lines that start with a tab |
|
1014 let total = 0; // # of indented lines (non-zero indent) |
|
1015 |
|
1016 cm.eachLine(0, DETECT_INDENT_MAX_LINES, (line) => { |
|
1017 let text = line.text; |
|
1018 |
|
1019 if (text.startsWith("\t")) { |
|
1020 tabs++; |
|
1021 total++; |
|
1022 return; |
|
1023 } |
|
1024 let width = 0; |
|
1025 while (text[width] === " ") { |
|
1026 width++; |
|
1027 } |
|
1028 // don't count lines that are all spaces |
|
1029 if (width == text.length) { |
|
1030 last = 0; |
|
1031 return; |
|
1032 } |
|
1033 if (width > 1) { |
|
1034 total++; |
|
1035 } |
|
1036 |
|
1037 // see how much this line is offset from the line above it |
|
1038 let indent = Math.abs(width - last); |
|
1039 if (indent > 1 && indent <= 8) { |
|
1040 spaces[indent] = (spaces[indent] || 0) + 1; |
|
1041 } |
|
1042 last = width; |
|
1043 }); |
|
1044 |
|
1045 // this file is not indented at all |
|
1046 if (total == 0) { |
|
1047 return null; |
|
1048 } |
|
1049 |
|
1050 // mark as tabs if they start more than half the lines |
|
1051 if (tabs >= total / 2) { |
|
1052 return { tabs: true }; |
|
1053 } |
|
1054 |
|
1055 // find most frequent non-zero width difference between adjacent lines |
|
1056 let freqIndent = null, max = 1; |
|
1057 for (let width in spaces) { |
|
1058 width = parseInt(width, 10); |
|
1059 let tally = spaces[width]; |
|
1060 if (tally > max) { |
|
1061 max = tally; |
|
1062 freqIndent = width; |
|
1063 } |
|
1064 } |
|
1065 if (!freqIndent) { |
|
1066 return null; |
|
1067 } |
|
1068 |
|
1069 return { tabs: false, spaces: freqIndent }; |
|
1070 } |
|
1071 |
|
1072 module.exports = Editor; |