Bug 1888033 - [Menu Redesign] Add a secret setting and feature flag for the menu...
[gecko.git] / devtools / client / shared / sourceeditor / editor.js
blob3487acffa454505a6db04dca22ee5cbe8ef8bb5f
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
7 const {
8   EXPAND_TAB,
9   TAB_SIZE,
10   DETECT_INDENT,
11   getIndentationFromIteration,
12 } = require("resource://devtools/shared/indentation.js");
14 const ENABLE_CODE_FOLDING = "devtools.editor.enableCodeFolding";
15 const KEYMAP_PREF = "devtools.editor.keymap";
16 const AUTO_CLOSE = "devtools.editor.autoclosebrackets";
17 const AUTOCOMPLETE = "devtools.editor.autocomplete";
18 const CARET_BLINK_TIME = "ui.caretBlinkTime";
19 const XHTML_NS = "http://www.w3.org/1999/xhtml";
21 const VALID_KEYMAPS = new Map([
22   [
23     "emacs",
24     "chrome://devtools/content/shared/sourceeditor/codemirror/keymap/emacs.js",
25   ],
26   [
27     "vim",
28     "chrome://devtools/content/shared/sourceeditor/codemirror/keymap/vim.js",
29   ],
30   [
31     "sublime",
32     "chrome://devtools/content/shared/sourceeditor/codemirror/keymap/sublime.js",
33   ],
34 ]);
36 // Maximum allowed margin (in number of lines) from top or bottom of the editor
37 // while shifting to a line which was initially out of view.
38 const MAX_VERTICAL_OFFSET = 3;
40 const RE_JUMP_TO_LINE = /^(\d+):?(\d+)?/;
41 const AUTOCOMPLETE_MARK_CLASSNAME = "cm-auto-complete-shadow-text";
43 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
44 const { PrefObserver } = require("resource://devtools/client/shared/prefs.js");
45 const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
47 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
48 const L10N = new LocalizationHelper(
49   "devtools/client/locales/sourceeditor.properties"
52 loader.lazyRequireGetter(
53   this,
54   "wasm",
55   "resource://devtools/client/shared/sourceeditor/wasm.js"
58 const { OS } = Services.appinfo;
60 // CM_BUNDLE and CM_IFRAME represent the HTML and JavaScript that is
61 // injected into an iframe in order to initialize a CodeMirror instance.
63 const CM_BUNDLE =
64   "chrome://devtools/content/shared/sourceeditor/codemirror/codemirror.bundle.js";
66 const CM_IFRAME =
67   "chrome://devtools/content/shared/sourceeditor/codemirror/cmiframe.html";
69 const CM_MAPPING = [
70   "clearHistory",
71   "defaultCharWidth",
72   "extendSelection",
73   "focus",
74   "getCursor",
75   "getLine",
76   "getScrollInfo",
77   "getSelection",
78   "getViewport",
79   "hasFocus",
80   "lineCount",
81   "openDialog",
82   "redo",
83   "refresh",
84   "replaceSelection",
85   "setSelection",
86   "somethingSelected",
87   "undo",
90 const editors = new WeakMap();
92 /**
93  * A very thin wrapper around CodeMirror. Provides a number
94  * of helper methods to make our use of CodeMirror easier and
95  * another method, appendTo, to actually create and append
96  * the CodeMirror instance.
97  *
98  * Note that Editor doesn't expose CodeMirror instance to the
99  * outside world.
101  * Constructor accepts one argument, config. It is very
102  * similar to the CodeMirror configuration object so for most
103  * properties go to CodeMirror's documentation (see below).
105  * Other than that, it accepts one additional and optional
106  * property contextMenu. This property should be an element, or
107  * an ID of an element that we can use as a context menu.
109  * This object is also an event emitter.
111  * CodeMirror docs: http://codemirror.net/doc/manual.html
112  */
113 class Editor extends EventEmitter {
114   // Static methods on the Editor object itself.
116   /**
117    * Returns a string representation of a shortcut 'key' with
118    * a OS specific modifier. Cmd- for Macs, Ctrl- for other
119    * platforms. Useful with extraKeys configuration option.
120    *
121    * CodeMirror defines all keys with modifiers in the following
122    * order: Shift - Ctrl/Cmd - Alt - Key
123    */
124   static accel(key, modifiers = {}) {
125     return (
126       (modifiers.shift ? "Shift-" : "") +
127       (Services.appinfo.OS == "Darwin" ? "Cmd-" : "Ctrl-") +
128       (modifiers.alt ? "Alt-" : "") +
129       key
130     );
131   }
133   /**
134    * Returns a string representation of a shortcut for a
135    * specified command 'cmd'. Append Cmd- for macs, Ctrl- for other
136    * platforms unless noaccel is specified in the options. Useful when overwriting
137    * or disabling default shortcuts.
138    */
139   static keyFor(cmd, opts = { noaccel: false }) {
140     const key = L10N.getStr(cmd + ".commandkey");
141     return opts.noaccel ? key : Editor.accel(key);
142   }
144   static modes = {
145     cljs: { name: "text/x-clojure" },
146     css: { name: "css" },
147     fs: { name: "x-shader/x-fragment" },
148     haxe: { name: "haxe" },
149     http: { name: "http" },
150     html: { name: "htmlmixed" },
151     js: { name: "javascript" },
152     text: { name: "text" },
153     vs: { name: "x-shader/x-vertex" },
154     wasm: { name: "wasm" },
155   };
157   container = null;
158   version = null;
159   config = null;
160   Doc = null;
162   #CodeMirror6;
163   #compartments;
164   #lastDirty;
165   #loadedKeyMaps;
166   #ownerDoc;
167   #prefObserver;
168   #win;
169   #lineGutterMarkers = new Map();
170   #lineContentMarkers = new Map();
172   #updateListener = null;
174   constructor(config) {
175     super();
177     const tabSize = Services.prefs.getIntPref(TAB_SIZE);
178     const useTabs = !Services.prefs.getBoolPref(EXPAND_TAB);
179     const useAutoClose = Services.prefs.getBoolPref(AUTO_CLOSE);
181     this.version = null;
182     this.config = {
183       cm6: false,
184       value: "",
185       mode: Editor.modes.text,
186       indentUnit: tabSize,
187       tabSize,
188       contextMenu: null,
189       matchBrackets: true,
190       highlightSelectionMatches: {
191         wordsOnly: true,
192       },
193       extraKeys: {},
194       indentWithTabs: useTabs,
195       inputStyle: "accessibleTextArea",
196       // This is set to the biggest value for setTimeout (See https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout#Maximum_delay_value)
197       // This is because codeMirror queries the underlying textArea for some things that
198       // can't be retrieved with events in some browser (but we're fine in Firefox).
199       pollInterval: Math.pow(2, 31) - 1,
200       styleActiveLine: true,
201       autoCloseBrackets: "()[]{}''\"\"``",
202       autoCloseEnabled: useAutoClose,
203       theme: "mozilla",
204       themeSwitching: true,
205       autocomplete: false,
206       autocompleteOpts: {},
207       // Expect a CssProperties object (see devtools/client/fronts/css-properties.js)
208       cssProperties: null,
209       // Set to true to prevent the search addon to be activated.
210       disableSearchAddon: false,
211       maxHighlightLength: 1000,
212       // Disable codeMirror setTimeout-based cursor blinking (will be replaced by a CSS animation)
213       cursorBlinkRate: 0,
214       // List of non-printable chars that will be displayed in the editor, showing their
215       // unicode version. We only add a few characters to the default list:
216       // - \u202d LEFT-TO-RIGHT OVERRIDE
217       // - \u202e RIGHT-TO-LEFT OVERRIDE
218       // - \u2066 LEFT-TO-RIGHT ISOLATE
219       // - \u2067 RIGHT-TO-LEFT ISOLATE
220       // - \u2069 POP DIRECTIONAL ISOLATE
221       specialChars:
222         // eslint-disable-next-line no-control-regex
223         /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200f\u2028\u2029\u202d\u202e\u2066\u2067\u2069\ufeff\ufff9-\ufffc]/,
224       specialCharPlaceholder: char => {
225         // Use the doc provided to the setup function if we don't have a reference to a codeMirror
226         // editor yet (this can happen when an Editor is being created with existing content)
227         const doc = this.#ownerDoc;
228         const el = doc.createElement("span");
229         el.classList.add("cm-non-printable-char");
230         el.append(doc.createTextNode(`\\u${char.codePointAt(0).toString(16)}`));
231         return el;
232       },
233     };
235     // Additional shortcuts.
236     this.config.extraKeys[Editor.keyFor("jumpToLine")] = () =>
237       this.jumpToLine();
238     this.config.extraKeys[Editor.keyFor("moveLineUp", { noaccel: true })] =
239       () => this.moveLineUp();
240     this.config.extraKeys[Editor.keyFor("moveLineDown", { noaccel: true })] =
241       () => this.moveLineDown();
242     this.config.extraKeys[Editor.keyFor("toggleComment")] = "toggleComment";
244     // Disable ctrl-[ and ctrl-] because toolbox uses those shortcuts.
245     this.config.extraKeys[Editor.keyFor("indentLess")] = false;
246     this.config.extraKeys[Editor.keyFor("indentMore")] = false;
248     // Disable Alt-B and Alt-F to navigate groups (respectively previous and next) since:
249     // - it's not standard in input fields
250     // - it also inserts a character which feels weird
251     this.config.extraKeys["Alt-B"] = false;
252     this.config.extraKeys["Alt-F"] = false;
254     // Disable Ctrl/Cmd + U as it's used for "View Source". It's okay to disable Ctrl+U as
255     // the underlying command, `undoSelection`, isn't standard in input fields and isn't
256     // widely known.
257     this.config.extraKeys[Editor.accel("U")] = false;
259     // Disable keys that trigger events with a null-string `which` property.
260     // It looks like some of those (e.g. the Function key), can trigger a poll
261     // which fails to see that there's a selection, which end up replacing the
262     // selected text with an empty string.
263     // TODO: We should investigate the root cause.
264     this.config.extraKeys["'\u0000'"] = false;
266     // Overwrite default config with user-provided, if needed.
267     Object.keys(config).forEach(k => {
268       if (k != "extraKeys") {
269         this.config[k] = config[k];
270         return;
271       }
273       if (!config.extraKeys) {
274         return;
275       }
277       Object.keys(config.extraKeys).forEach(key => {
278         this.config.extraKeys[key] = config.extraKeys[key];
279       });
280     });
282     if (!this.config.gutters) {
283       this.config.gutters = [];
284     }
285     if (
286       this.config.lineNumbers &&
287       !this.config.gutters.includes("CodeMirror-linenumbers")
288     ) {
289       this.config.gutters.push("CodeMirror-linenumbers");
290     }
292     // Remember the initial value of autoCloseBrackets.
293     this.config.autoCloseBracketsSaved = this.config.autoCloseBrackets;
295     // Overwrite default tab behavior. If something is selected,
296     // indent those lines. If nothing is selected and we're
297     // indenting with tabs, insert one tab. Otherwise insert N
298     // whitespaces where N == indentUnit option.
299     this.config.extraKeys.Tab = cm => {
300       if (config.extraKeys?.Tab) {
301         // If a consumer registers its own extraKeys.Tab, we execute it before doing
302         // anything else. If it returns false, that mean that all the key handling work is
303         // done, so we can do an early return.
304         const res = config.extraKeys.Tab(cm);
305         if (res === false) {
306           return;
307         }
308       }
310       if (cm.somethingSelected()) {
311         cm.indentSelection("add");
312         return;
313       }
315       if (this.config.indentWithTabs) {
316         cm.replaceSelection("\t", "end", "+input");
317         return;
318       }
320       let num = cm.getOption("indentUnit");
321       if (cm.getCursor().ch !== 0) {
322         num -= cm.getCursor().ch % num;
323       }
324       cm.replaceSelection(" ".repeat(num), "end", "+input");
325     };
327     if (this.config.cssProperties) {
328       // Ensure that autocompletion has cssProperties if it's passed in via the options.
329       this.config.autocompleteOpts.cssProperties = this.config.cssProperties;
330     }
331   }
333   /**
334    * Exposes the CodeMirror class. We want to be able to
335    * invoke static commands such as runMode for syntax highlighting.
336    */
337   get CodeMirror() {
338     const codeMirror = editors.get(this);
339     return codeMirror?.constructor;
340   }
342   /**
343    * Exposes the CodeMirror instance. We want to get away from trying to
344    * abstract away the API entirely, and this makes it easier to integrate in
345    * various environments and do complex things.
346    */
347   get codeMirror() {
348     if (!editors.has(this)) {
349       throw new Error(
350         "CodeMirror instance does not exist. You must wait " +
351           "for it to be appended to the DOM."
352       );
353     }
354     return editors.get(this);
355   }
357   /**
358    * Return whether there is a CodeMirror instance associated with this Editor.
359    */
360   get hasCodeMirror() {
361     return editors.has(this);
362   }
364   /**
365    * Appends the current Editor instance to the element specified by
366    * 'el'. You can also provide your own iframe to host the editor as
367    * an optional second parameter. This method actually creates and
368    * loads CodeMirror and all its dependencies.
369    *
370    * This method is asynchronous and returns a promise.
371    */
372   appendTo(el, env) {
373     return new Promise(resolve => {
374       const cm = editors.get(this);
376       if (!env) {
377         env = el.ownerDocument.createElementNS(XHTML_NS, "iframe");
378         env.className = "source-editor-frame";
379       }
381       if (cm) {
382         throw new Error("You can append an editor only once.");
383       }
385       const onLoad = () => {
386         // Prevent flickering by showing the iframe once loaded.
387         // See https://github.com/w3c/csswg-drafts/issues/9624
388         env.style.visibility = "";
389         const win = env.contentWindow.wrappedJSObject;
390         this.container = env;
392         const editorEl = win.document.body;
393         const editorDoc = el.ownerDocument;
394         if (this.config.cm6) {
395           this.#setupCm6(editorEl, editorDoc);
396         } else {
397           this.#setup(editorEl, editorDoc);
398         }
399         resolve();
400       };
402       env.style.visibility = "hidden";
403       env.addEventListener("load", onLoad, { capture: true, once: true });
404       env.src = CM_IFRAME;
405       el.appendChild(env);
407       this.once("destroy", () => el.removeChild(env));
408     });
409   }
411   appendToLocalElement(el) {
412     if (this.config.cm6) {
413       this.#setupCm6(el);
414     } else {
415       this.#setup(el);
416     }
417   }
419   // This update listener allows listening to the changes
420   // to the codemiror editor.
421   setUpdateListener(listener = null) {
422     this.#updateListener = listener;
423   }
425   /**
426    * Do the actual appending and configuring of the CodeMirror instance. This is
427    * used by both append functions above, and does all the hard work to
428    * configure CodeMirror with all the right options/modes/etc.
429    */
430   #setup(el, doc) {
431     this.#ownerDoc = doc || el.ownerDocument;
432     const win = el.ownerDocument.defaultView;
434     Services.scriptloader.loadSubScript(CM_BUNDLE, win);
435     this.#win = win;
437     if (this.config.cssProperties) {
438       // Replace the propertyKeywords, colorKeywords and valueKeywords
439       // properties of the CSS MIME type with the values provided by the CSS properties
440       // database.
441       const { propertyKeywords, colorKeywords, valueKeywords } = getCSSKeywords(
442         this.config.cssProperties
443       );
445       const cssSpec = win.CodeMirror.resolveMode("text/css");
446       cssSpec.propertyKeywords = propertyKeywords;
447       cssSpec.colorKeywords = colorKeywords;
448       cssSpec.valueKeywords = valueKeywords;
449       win.CodeMirror.defineMIME("text/css", cssSpec);
451       const scssSpec = win.CodeMirror.resolveMode("text/x-scss");
452       scssSpec.propertyKeywords = propertyKeywords;
453       scssSpec.colorKeywords = colorKeywords;
454       scssSpec.valueKeywords = valueKeywords;
455       win.CodeMirror.defineMIME("text/x-scss", scssSpec);
456     }
458     win.CodeMirror.commands.save = () => this.emit("saveRequested");
460     // Create a CodeMirror instance add support for context menus,
461     // overwrite the default controller (otherwise items in the top and
462     // context menus won't work).
464     const cm = win.CodeMirror(el, this.config);
465     this.Doc = win.CodeMirror.Doc;
467     // Disable APZ for source editors. It currently causes the line numbers to
468     // "tear off" and swim around on top of the content. Bug 1160601 tracks
469     // finding a solution that allows APZ to work with CodeMirror.
470     cm.getScrollerElement().addEventListener("wheel", ev => {
471       // By handling the wheel events ourselves, we force the platform to
472       // scroll synchronously, like it did before APZ. However, we lose smooth
473       // scrolling for users with mouse wheels. This seems acceptible vs.
474       // doing nothing and letting the gutter slide around.
475       ev.preventDefault();
477       let { deltaX, deltaY } = ev;
479       if (ev.deltaMode == ev.DOM_DELTA_LINE) {
480         deltaX *= cm.defaultCharWidth();
481         deltaY *= cm.defaultTextHeight();
482       } else if (ev.deltaMode == ev.DOM_DELTA_PAGE) {
483         deltaX *= cm.getWrapperElement().clientWidth;
484         deltaY *= cm.getWrapperElement().clientHeight;
485       }
487       cm.getScrollerElement().scrollBy(deltaX, deltaY);
488     });
490     cm.getWrapperElement().addEventListener("contextmenu", ev => {
491       if (!this.config.contextMenu) {
492         return;
493       }
495       ev.stopPropagation();
496       ev.preventDefault();
498       let popup = this.config.contextMenu;
499       if (typeof popup == "string") {
500         popup = this.#ownerDoc.getElementById(this.config.contextMenu);
501       }
503       this.emit("popupOpen", ev, popup);
504       popup.openPopupAtScreen(ev.screenX, ev.screenY, true);
505     });
507     const pipedEvents = [
508       "beforeChange",
509       "blur",
510       "changes",
511       "cursorActivity",
512       "focus",
513       "keyHandled",
514       "scroll",
515     ];
516     for (const eventName of pipedEvents) {
517       cm.on(eventName, (...args) => this.emit(eventName, ...args));
518     }
520     cm.on("change", () => {
521       this.emit("change");
522       if (!this.#lastDirty) {
523         this.#lastDirty = true;
524         this.emit("dirty-change");
525       }
526     });
528     cm.on("gutterClick", (cmArg, line, gutter, ev) => {
529       const lineOrOffset = !this.isWasm ? line : this.lineToWasmOffset(line);
530       this.emit("gutterClick", lineOrOffset, ev.button);
531     });
533     win.CodeMirror.defineExtension("l10n", name => {
534       return L10N.getStr(name);
535     });
537     if (!this.config.disableSearchAddon) {
538       this.#initSearchShortcuts(win);
539     } else {
540       // Hotfix for Bug 1527898. We should remove those overrides as part of Bug 1527903.
541       Object.assign(win.CodeMirror.commands, {
542         find: null,
543         findPersistent: null,
544         findPersistentNext: null,
545         findPersistentPrev: null,
546         findNext: null,
547         findPrev: null,
548         clearSearch: null,
549         replace: null,
550         replaceAll: null,
551       });
552     }
554     // Retrieve the cursor blink rate from user preference, or fall back to CodeMirror's
555     // default value.
556     let cursorBlinkingRate = win.CodeMirror.defaults.cursorBlinkRate;
557     if (Services.prefs.prefHasUserValue(CARET_BLINK_TIME)) {
558       cursorBlinkingRate = Services.prefs.getIntPref(
559         CARET_BLINK_TIME,
560         cursorBlinkingRate
561       );
562     }
563     // This will be used in the animation-duration property we set on the cursor to
564     // implement the blinking animation. If cursorBlinkingRate is 0 or less, the cursor
565     // won't blink.
566     cm.getWrapperElement().style.setProperty(
567       "--caret-blink-time",
568       `${Math.max(0, cursorBlinkingRate)}ms`
569     );
571     editors.set(this, cm);
573     this.reloadPreferences = this.reloadPreferences.bind(this);
574     this.setKeyMap = this.setKeyMap.bind(this, win);
576     this.#prefObserver = new PrefObserver("devtools.editor.");
577     this.#prefObserver.on(TAB_SIZE, this.reloadPreferences);
578     this.#prefObserver.on(EXPAND_TAB, this.reloadPreferences);
579     this.#prefObserver.on(AUTO_CLOSE, this.reloadPreferences);
580     this.#prefObserver.on(AUTOCOMPLETE, this.reloadPreferences);
581     this.#prefObserver.on(DETECT_INDENT, this.reloadPreferences);
582     this.#prefObserver.on(ENABLE_CODE_FOLDING, this.reloadPreferences);
584     this.reloadPreferences();
586     // Init a map of the loaded keymap files. Should be of the form Map<String->Boolean>.
587     this.#loadedKeyMaps = new Set();
588     this.#prefObserver.on(KEYMAP_PREF, this.setKeyMap);
589     this.setKeyMap();
591     win.editor = this;
592     const editorReadyEvent = new win.CustomEvent("editorReady");
593     win.dispatchEvent(editorReadyEvent);
594   }
596   /**
597    * Do the actual appending and configuring of the CodeMirror 6 instance.
598    * This is used by appendTo and appendToLocalElement, and does all the hard work to
599    * configure CodeMirror 6 with all the right options/modes/etc.
600    * This should be kept in sync with #setup.
601    *
602    * @param {Element} el: Element into which the codeMirror editor should be appended.
603    * @param {Document} document: Optional document, if not set, will default to el.ownerDocument
604    */
605   #setupCm6(el, doc) {
606     this.#ownerDoc = doc || el.ownerDocument;
607     const win = el.ownerDocument.defaultView;
608     this.#win = win;
610     this.#CodeMirror6 = this.#win.ChromeUtils.importESModule(
611       "resource://devtools/client/shared/sourceeditor/codemirror6/codemirror6.bundle.mjs",
612       { global: "current" }
613     );
615     const {
616       codemirror,
617       codemirrorView: { EditorView, lineNumbers },
618       codemirrorState: { EditorState, Compartment },
619       codemirrorLanguage,
620       codemirrorLangJavascript,
621       lezerHighlight,
622     } = this.#CodeMirror6;
624     const tabSizeCompartment = new Compartment();
625     const indentCompartment = new Compartment();
626     const lineWrapCompartment = new Compartment();
627     const lineNumberCompartment = new Compartment();
628     const lineNumberMarkersCompartment = new Compartment();
629     const lineContentMarkerCompartment = new Compartment();
631     this.#compartments = {
632       tabSizeCompartment,
633       indentCompartment,
634       lineWrapCompartment,
635       lineNumberCompartment,
636       lineNumberMarkersCompartment,
637       lineContentMarkerCompartment,
638     };
640     const indentStr = (this.config.indentWithTabs ? "\t" : " ").repeat(
641       this.config.indentUnit || 2
642     );
644     const extensions = [
645       indentCompartment.of(codemirrorLanguage.indentUnit.of(indentStr)),
646       tabSizeCompartment.of(EditorState.tabSize.of(this.config.tabSize)),
647       lineWrapCompartment.of(
648         this.config.lineWrapping ? EditorView.lineWrapping : []
649       ),
650       EditorState.readOnly.of(this.config.readOnly),
651       lineNumberCompartment.of(this.config.lineNumbers ? lineNumbers() : []),
652       codemirrorLanguage.codeFolding({
653         placeholderText: "↔",
654       }),
655       codemirrorLanguage.foldGutter({
656         class: "cm6-dt-foldgutter",
657         markerDOM: open => {
658           const button = this.#ownerDoc.createElement("button");
659           button.classList.add("cm6-dt-foldgutter__toggle-button");
660           button.setAttribute("aria-expanded", open);
661           return button;
662         },
663       }),
664       codemirrorLanguage.syntaxHighlighting(lezerHighlight.classHighlighter),
665       EditorView.updateListener.of(v => {
666         if (v.viewportChanged || v.docChanged) {
667           // reset line gutter markers for the new visible ranges
668           // when the viewport changes(e.g when the page is scrolled).
669           if (this.#lineGutterMarkers.size > 0) {
670             this.setLineGutterMarkers();
671           }
672         }
673         // Any custom defined update listener should be called
674         if (typeof this.#updateListener == "function") {
675           this.#updateListener(v);
676         }
677       }),
678       lineNumberMarkersCompartment.of([]),
679       lineContentMarkerCompartment.of(this.#lineContentMarkersExtension([])),
680       // keep last so other extension take precedence
681       codemirror.minimalSetup,
682     ];
684     if (this.config.mode === Editor.modes.js) {
685       extensions.push(codemirrorLangJavascript.javascript());
686     }
688     const cm = new EditorView({
689       parent: el,
690       extensions,
691     });
693     editors.set(this, cm);
694   }
696   /**
697    * This creates the extension used to manage the rendering of markers
698    * for in editor line content.
699    * @param   {Array}             markers                    - The current list of markers
700    * @returns {Array<ViewPlugin>} showLineContentDecorations - An extension which is an array containing the view
701    *                                                           which manages the rendering of the line content markers.
702    */
703   #lineContentMarkersExtension(markers) {
704     const {
705       codemirrorView: { Decoration, ViewPlugin },
706       codemirrorState: { RangeSetBuilder },
707     } = this.#CodeMirror6;
709     // Build and return the decoration set
710     function buildDecorations(view) {
711       const builder = new RangeSetBuilder();
712       for (const { from, to } of view.visibleRanges) {
713         for (let pos = from; pos <= to; ) {
714           const line = view.state.doc.lineAt(pos);
715           for (const { lineClassName, condition } of markers) {
716             if (condition(line.number)) {
717               builder.add(
718                 line.from,
719                 line.from,
720                 Decoration.line({ class: lineClassName })
721               );
722             }
723           }
724           pos = line.to + 1;
725         }
726       }
727       return builder.finish();
728     }
730     // The view which handles rendering and updating the
731     // markers decorations
732     const showLineContentDecorations = ViewPlugin.fromClass(
733       class {
734         decorations;
735         constructor(view) {
736           this.decorations = buildDecorations(view);
737         }
738         update(update) {
739           if (update.docChanged || update.viewportChanged) {
740             this.decorations = buildDecorations(update.view);
741           }
742         }
743       },
744       { decorations: v => v.decorations }
745     );
747     return [showLineContentDecorations];
748   }
750   /**
751    * This adds a marker used to add classes to editor line based on a condition.
752    *   @property {object}     marker           - The rule rendering a marker or class.
753    *   @property {object}     marker.id       - The unique identifier for this marker
754    *   @property {string}     marker.lineClassName - The css class to add to the line
755    *   @property {function}   marker.condition - The condition that decides if the marker/class gets added or removed.
756    *                                             The line is passed as an argument.
757    */
758   setLineContentMarker(marker) {
759     const cm = editors.get(this);
760     this.#lineContentMarkers.set(marker.id, marker);
762     cm.dispatch({
763       effects: this.#compartments.lineContentMarkerCompartment.reconfigure(
764         this.#lineContentMarkersExtension(
765           Array.from(this.#lineContentMarkers.values())
766         )
767       ),
768     });
769   }
771   /**
772    * This removes the marker which has the specified className
773    * @param {string} markerId - The unique identifier for this marker
774    */
775   removeLineContentMarker(markerId) {
776     const cm = editors.get(this);
777     this.#lineContentMarkers.delete(markerId);
779     cm.dispatch({
780       effects: this.#compartments.lineContentMarkerCompartment.reconfigure(
781         this.#lineContentMarkersExtension(
782           Array.from(this.#lineContentMarkers.values())
783         )
784       ),
785     });
786   }
788   /**
789    * Set event listeners for the line gutter
790    * @param {Object} domEventHandlers
791    *
792    * example usage:
793    *  const domEventHandlers = { click(event) { console.log(event);} }
794    */
795   setGutterEventListeners(domEventHandlers) {
796     const cm = editors.get(this);
797     const {
798       codemirrorView: { lineNumbers },
799     } = this.#CodeMirror6;
801     for (const eventName in domEventHandlers) {
802       const handler = domEventHandlers[eventName];
803       domEventHandlers[eventName] = (view, line, event) => {
804         line = view.state.doc.lineAt(line.from);
805         handler(event, view, line.number);
806       };
807     }
809     cm.dispatch({
810       effects: this.#compartments.lineWrapCompartment.reconfigure(
811         lineNumbers({ domEventHandlers })
812       ),
813     });
814   }
816   /**
817    * This supports adding/removing of line classes or markers on the
818    * line number gutter based on the defined conditions. This only supports codemirror 6.
819    *
820    *   @param {Array<Marker>} markers         - The list of marker objects which defines the rules
821    *                                            for rendering each marker.
822    *   @property {object}     marker - The rule rendering a marker or class. This is required.
823    *   @property {string}     marker.id - The unique identifier for this marker.
824    *   @property {string}     marker.lineClassName - The css class to add to the line. This is required.
825    *   @property {function}   marker.condition - The condition that decides if the marker/class  gets added or removed.
826    *   @property {function=}  marker.createLineElementNode - This gets the line as an argument and should return the DOM element which
827    *                                            is used for the marker. This is optional.
828    */
829   setLineGutterMarkers(markers) {
830     const cm = editors.get(this);
832     if (markers) {
833       // Cache the markers for use later. See next comment
834       for (const marker of markers) {
835         if (!marker.id) {
836           throw new Error("Marker has no unique identifier");
837         }
838         this.#lineGutterMarkers.set(marker.id, marker);
839       }
840     }
841     // When no markers are passed, the cached markers are used to update the line gutters.
842     // This is useful for re-rendering the line gutters when the viewport changes
843     // (note: the visible ranges will be different) in this case, mainly when the editor is scrolled.
844     else if (!this.#lineGutterMarkers.size) {
845       return;
846     }
847     markers = Array.from(this.#lineGutterMarkers.values());
849     const {
850       codemirrorView: { lineNumberMarkers, GutterMarker },
851       codemirrorState: { RangeSetBuilder },
852     } = this.#CodeMirror6;
854     // This creates a new GutterMarker https://codemirror.net/docs/ref/#view.GutterMarker
855     // to represents how each line gutter is rendered in the view.
856     // This is set as the value for the Range https://codemirror.net/docs/ref/#state.Range
857     // which represents the line.
858     class LineGutterMarker extends GutterMarker {
859       constructor(className, lineNumber, createElementNode) {
860         super();
861         this.elementClass = className || null;
862         this.toDOM = createElementNode
863           ? () => createElementNode(lineNumber)
864           : null;
865       }
866     }
868     // Loop through the visible ranges https://codemirror.net/docs/ref/#view.EditorView.visibleRanges
869     // (representing the lines in the current viewport) and generate a new rangeset for updating the line gutter
870     // based on the conditions defined in the markers(for each line) provided.
871     const builder = new RangeSetBuilder();
872     for (const { from, to } of cm.visibleRanges) {
873       for (let pos = from; pos <= to; ) {
874         const line = cm.state.doc.lineAt(pos);
875         for (const {
876           lineClassName,
877           condition,
878           createLineElementNode,
879         } of markers) {
880           if (typeof condition !== "function") {
881             throw new Error("The `condition` is not a valid function");
882           }
883           if (condition(line.number)) {
884             builder.add(
885               line.from,
886               line.to,
887               new LineGutterMarker(
888                 lineClassName,
889                 line.number,
890                 createLineElementNode
891               )
892             );
893           }
894         }
895         pos = line.to + 1;
896       }
897     }
899     // To update the state with the newly generated marker range set, a dispatch is called on the view
900     // with an transaction effect created by the lineNumberMarkersCompartment, which is used to update the
901     // lineNumberMarkers extension configuration.
902     cm.dispatch({
903       effects: this.#compartments.lineNumberMarkersCompartment.reconfigure(
904         lineNumberMarkers.of(builder.finish())
905       ),
906     });
907   }
909   /**
910    * Returns a boolean indicating whether the editor is ready to
911    * use. Use appendTo(el).then(() => {}) for most cases
912    */
913   isAppended() {
914     return editors.has(this);
915   }
917   /**
918    * Returns the currently active highlighting mode.
919    * See Editor.modes for the list of all suppoert modes.
920    */
921   getMode() {
922     return this.getOption("mode");
923   }
925   /**
926    * Loads a script into editor's containing window.
927    */
928   loadScript(url) {
929     if (!this.container) {
930       throw new Error("Can't load a script until the editor is loaded.");
931     }
932     const win = this.container.contentWindow.wrappedJSObject;
933     Services.scriptloader.loadSubScript(url, win);
934   }
936   /**
937    * Creates a CodeMirror Document
938    *
939    * @param {String} text: Initial text of the document
940    * @param {Object|String} mode: Mode of the document. See https://codemirror.net/5/doc/manual.html#option_mode
941    * @returns CodeMirror.Doc
942    */
943   createDocument(text = "", mode) {
944     return new this.Doc(text, mode);
945   }
947   /**
948    * Replaces the current document with a new source document
949    */
950   replaceDocument(doc) {
951     const cm = editors.get(this);
952     cm.swapDoc(doc);
953   }
955   /**
956    * Changes the value of a currently used highlighting mode.
957    * See Editor.modes for the list of all supported modes.
958    */
959   setMode(value) {
960     this.setOption("mode", value);
962     // If autocomplete was set up and the mode is changing, then
963     // turn it off and back on again so the proper mode can be used.
964     if (this.config.autocomplete) {
965       this.setOption("autocomplete", false);
966       this.setOption("autocomplete", true);
967     }
968   }
970   /**
971    * The source editor can expose several commands linked from system and context menus.
972    * Kept for backward compatibility with styleeditor.
973    */
974   insertCommandsController() {
975     const {
976       insertCommandsController,
977     } = require("resource://devtools/client/shared/sourceeditor/editor-commands-controller.js");
978     insertCommandsController(this);
979   }
981   /**
982    * Returns text from the text area. If line argument is provided
983    * the method returns only that line.
984    */
985   getText(line) {
986     const cm = editors.get(this);
988     if (line == null) {
989       return this.config.cm6 ? cm.state.doc.toString() : cm.getValue();
990     }
992     const info = this.lineInfo(line);
993     return info ? info.text : "";
994   }
996   getDoc() {
997     const cm = editors.get(this);
998     return cm.getDoc();
999   }
1001   get isWasm() {
1002     return wasm.isWasm(this.getDoc());
1003   }
1005   wasmOffsetToLine(offset) {
1006     return wasm.wasmOffsetToLine(this.getDoc(), offset);
1007   }
1009   lineToWasmOffset(number) {
1010     return wasm.lineToWasmOffset(this.getDoc(), number);
1011   }
1013   toLineIfWasmOffset(maybeOffset) {
1014     if (typeof maybeOffset !== "number" || !this.isWasm) {
1015       return maybeOffset;
1016     }
1017     return this.wasmOffsetToLine(maybeOffset);
1018   }
1020   lineInfo(lineOrOffset) {
1021     const line = this.toLineIfWasmOffset(lineOrOffset);
1022     if (line == undefined) {
1023       return null;
1024     }
1025     const cm = editors.get(this);
1027     if (this.config.cm6) {
1028       return {
1029         // cm6 lines are 1-based, while cm5 are 0-based
1030         text: cm.state.doc.lineAt(line + 1)?.text,
1031         // TODO: Expose those, or see usage for those and do things differently
1032         line: null,
1033         handle: null,
1034         gutterMarkers: null,
1035         textClass: null,
1036         bgClass: null,
1037         wrapClass: null,
1038         widgets: null,
1039       };
1040     }
1042     return cm.lineInfo(line);
1043   }
1045   getLineOrOffset(line) {
1046     return this.isWasm ? this.lineToWasmOffset(line) : line;
1047   }
1049   /**
1050    * Replaces whatever is in the text area with the contents of
1051    * the 'value' argument.
1052    */
1053   setText(value) {
1054     const cm = editors.get(this);
1056     if (typeof value !== "string" && "binary" in value) {
1057       // wasm?
1058       // binary does not survive as Uint8Array, converting from string
1059       const binary = value.binary;
1060       const data = new Uint8Array(binary.length);
1061       for (let i = 0; i < data.length; i++) {
1062         data[i] = binary.charCodeAt(i);
1063       }
1064       const { lines, done } = wasm.getWasmText(this.getDoc(), data);
1065       const MAX_LINES = 10000000;
1066       if (lines.length > MAX_LINES) {
1067         lines.splice(MAX_LINES, lines.length - MAX_LINES);
1068         lines.push(";; .... text is truncated due to the size");
1069       }
1070       if (!done) {
1071         lines.push(";; .... possible error during wast conversion");
1072       }
1073       // cm will try to split into lines anyway, saving memory
1074       value = { split: () => lines };
1075     }
1077     if (this.config.cm6) {
1078       cm.dispatch({
1079         changes: { from: 0, to: cm.state.doc.length, insert: value },
1080       });
1081     } else {
1082       cm.setValue(value);
1083     }
1085     this.resetIndentUnit();
1086   }
1088   /**
1089    * Reloads the state of the editor based on all current preferences.
1090    * This is called automatically when any of the relevant preferences
1091    * change.
1092    */
1093   reloadPreferences() {
1094     // Restore the saved autoCloseBrackets value if it is preffed on.
1095     const useAutoClose = Services.prefs.getBoolPref(AUTO_CLOSE);
1096     this.setOption(
1097       "autoCloseBrackets",
1098       useAutoClose ? this.config.autoCloseBracketsSaved : false
1099     );
1101     this.updateCodeFoldingGutter();
1103     this.resetIndentUnit();
1104     this.setupAutoCompletion();
1105   }
1107   /**
1108    * Set the current keyMap for CodeMirror, and load the support file if needed.
1109    *
1110    * @param {Window} win: The window on which the keymap files should be loaded.
1111    */
1112   setKeyMap(win) {
1113     if (this.config.isReadOnly) {
1114       return;
1115     }
1117     const keyMap = Services.prefs.getCharPref(KEYMAP_PREF);
1119     // If alternative keymap is provided, use it.
1120     if (VALID_KEYMAPS.has(keyMap)) {
1121       if (!this.#loadedKeyMaps.has(keyMap)) {
1122         Services.scriptloader.loadSubScript(VALID_KEYMAPS.get(keyMap), win);
1123         this.#loadedKeyMaps.add(keyMap);
1124       }
1125       this.setOption("keyMap", keyMap);
1126     } else {
1127       this.setOption("keyMap", "default");
1128     }
1129   }
1131   /**
1132    * Sets the editor's indentation based on the current prefs and
1133    * re-detect indentation if we should.
1134    */
1135   resetIndentUnit() {
1136     const cm = editors.get(this);
1138     const iterFn = (start, maxEnd, callback) => {
1139       if (!this.config.cm6) {
1140         cm.eachLine(start, maxEnd, line => {
1141           return callback(line.text);
1142         });
1143       } else {
1144         const iterator = cm.state.doc.iterLines(
1145           start + 1,
1146           Math.min(cm.state.doc.lines, maxEnd) + 1
1147         );
1148         let callbackRes;
1149         do {
1150           iterator.next();
1151           callbackRes = callback(iterator.value);
1152         } while (iterator.done !== true && !callbackRes);
1153       }
1154     };
1156     const { indentUnit, indentWithTabs } = getIndentationFromIteration(iterFn);
1158     if (!this.config.cm6) {
1159       cm.setOption("tabSize", indentUnit);
1160       cm.setOption("indentUnit", indentUnit);
1161       cm.setOption("indentWithTabs", indentWithTabs);
1162     } else {
1163       const {
1164         codemirrorState: { EditorState },
1165         codemirrorLanguage,
1166       } = this.#CodeMirror6;
1168       cm.dispatch({
1169         effects: this.#compartments.tabSizeCompartment.reconfigure(
1170           EditorState.tabSize.of(indentUnit)
1171         ),
1172       });
1173       cm.dispatch({
1174         effects: this.#compartments.indentCompartment.reconfigure(
1175           codemirrorLanguage.indentUnit.of(
1176             (indentWithTabs ? "\t" : " ").repeat(indentUnit)
1177           )
1178         ),
1179       });
1180     }
1181   }
1183   /**
1184    * Replaces contents of a text area within the from/to {line, ch}
1185    * range. If neither `from` nor `to` arguments are provided works
1186    * exactly like setText. If only `from` object is provided, inserts
1187    * text at that point, *overwriting* as many characters as needed.
1188    */
1189   replaceText(value, from, to) {
1190     const cm = editors.get(this);
1192     if (!from) {
1193       this.setText(value);
1194       return;
1195     }
1197     if (!to) {
1198       const text = cm.getRange({ line: 0, ch: 0 }, from);
1199       this.setText(text + value);
1200       return;
1201     }
1203     cm.replaceRange(value, from, to);
1204   }
1206   /**
1207    * Inserts text at the specified {line, ch} position, shifting existing
1208    * contents as necessary.
1209    */
1210   insertText(value, at) {
1211     const cm = editors.get(this);
1212     cm.replaceRange(value, at, at);
1213   }
1215   /**
1216    * Deselects contents of the text area.
1217    */
1218   dropSelection() {
1219     if (!this.somethingSelected()) {
1220       return;
1221     }
1223     this.setCursor(this.getCursor());
1224   }
1226   /**
1227    * Returns true if there is more than one selection in the editor.
1228    */
1229   hasMultipleSelections() {
1230     const cm = editors.get(this);
1231     return cm.listSelections().length > 1;
1232   }
1234   /**
1235    * Gets the first visible line number in the editor.
1236    */
1237   getFirstVisibleLine() {
1238     const cm = editors.get(this);
1239     return cm.lineAtHeight(0, "local");
1240   }
1242   /**
1243    * Scrolls the view such that the given line number is the first visible line.
1244    */
1245   setFirstVisibleLine(line) {
1246     const cm = editors.get(this);
1247     const { top } = cm.charCoords({ line, ch: 0 }, "local");
1248     cm.scrollTo(0, top);
1249   }
1251   /**
1252    * Sets the cursor to the specified {line, ch} position with an additional
1253    * option to align the line at the "top", "center" or "bottom" of the editor
1254    * with "top" being default value.
1255    */
1256   setCursor({ line, ch }, align) {
1257     const cm = editors.get(this);
1258     this.alignLine(line, align);
1259     cm.setCursor({ line, ch });
1260     this.emit("cursorActivity");
1261   }
1263   /**
1264    * Aligns the provided line to either "top", "center" or "bottom" of the
1265    * editor view with a maximum margin of MAX_VERTICAL_OFFSET lines from top or
1266    * bottom.
1267    */
1268   alignLine(line, align) {
1269     const cm = editors.get(this);
1270     const from = cm.lineAtHeight(0, "page");
1271     const to = cm.lineAtHeight(cm.getWrapperElement().clientHeight, "page");
1272     const linesVisible = to - from;
1273     const halfVisible = Math.round(linesVisible / 2);
1275     // If the target line is in view, skip the vertical alignment part.
1276     if (line <= to && line >= from) {
1277       return;
1278     }
1280     // Setting the offset so that the line always falls in the upper half
1281     // of visible lines (lower half for bottom aligned).
1282     // MAX_VERTICAL_OFFSET is the maximum allowed value.
1283     const offset = Math.min(halfVisible, MAX_VERTICAL_OFFSET);
1285     let topLine =
1286       {
1287         center: Math.max(line - halfVisible, 0),
1288         bottom: Math.max(line - linesVisible + offset, 0),
1289         top: Math.max(line - offset, 0),
1290       }[align || "top"] || offset;
1292     // Bringing down the topLine to total lines in the editor if exceeding.
1293     topLine = Math.min(topLine, this.lineCount());
1294     this.setFirstVisibleLine(topLine);
1295   }
1297   /**
1298    * Returns whether a marker of a specified class exists in a line's gutter.
1299    */
1300   hasMarker(line, gutterName, markerClass) {
1301     const marker = this.getMarker(line, gutterName);
1302     if (!marker) {
1303       return false;
1304     }
1306     return marker.classList.contains(markerClass);
1307   }
1309   /**
1310    * Adds a marker with a specified class to a line's gutter. If another marker
1311    * exists on that line, the new marker class is added to its class list.
1312    */
1313   addMarker(line, gutterName, markerClass) {
1314     const cm = editors.get(this);
1315     const info = this.lineInfo(line);
1316     if (!info) {
1317       return;
1318     }
1320     const gutterMarkers = info.gutterMarkers;
1321     let marker;
1322     if (gutterMarkers) {
1323       marker = gutterMarkers[gutterName];
1324       if (marker) {
1325         marker.classList.add(markerClass);
1326         return;
1327       }
1328     }
1330     marker = cm.getWrapperElement().ownerDocument.createElement("div");
1331     marker.className = markerClass;
1332     cm.setGutterMarker(info.line, gutterName, marker);
1333   }
1335   /**
1336    * The reverse of addMarker. Removes a marker of a specified class from a
1337    * line's gutter.
1338    */
1339   removeMarker(line, gutterName, markerClass) {
1340     if (!this.hasMarker(line, gutterName, markerClass)) {
1341       return;
1342     }
1344     this.lineInfo(line).gutterMarkers[gutterName].classList.remove(markerClass);
1345   }
1347   /**
1348    * Adds a marker with a specified class and an HTML content to a line's
1349    * gutter. If another marker exists on that line, it is overwritten by a new
1350    * marker.
1351    */
1352   addContentMarker(line, gutterName, markerClass, content) {
1353     const cm = editors.get(this);
1354     const info = this.lineInfo(line);
1355     if (!info) {
1356       return;
1357     }
1359     const marker = cm.getWrapperElement().ownerDocument.createElement("div");
1360     marker.className = markerClass;
1361     // eslint-disable-next-line no-unsanitized/property
1362     marker.innerHTML = content;
1363     cm.setGutterMarker(info.line, gutterName, marker);
1364   }
1366   /**
1367    * The reverse of addContentMarker. Removes any line's markers in the
1368    * specified gutter.
1369    */
1370   removeContentMarker(line, gutterName) {
1371     const cm = editors.get(this);
1372     const info = this.lineInfo(line);
1373     if (!info) {
1374       return;
1375     }
1377     cm.setGutterMarker(info.line, gutterName, null);
1378   }
1380   getMarker(line, gutterName) {
1381     const info = this.lineInfo(line);
1382     if (!info) {
1383       return null;
1384     }
1386     const gutterMarkers = info.gutterMarkers;
1387     if (!gutterMarkers) {
1388       return null;
1389     }
1391     return gutterMarkers[gutterName];
1392   }
1394   /**
1395    * Removes all gutter markers in the gutter with the given name.
1396    */
1397   removeAllMarkers(gutterName) {
1398     const cm = editors.get(this);
1399     cm.clearGutter(gutterName);
1400   }
1402   /**
1403    * Handles attaching a set of events listeners on a marker. They should
1404    * be passed as an object literal with keys as event names and values as
1405    * function listeners. The line number, marker node and optional data
1406    * will be passed as arguments to the function listener.
1407    *
1408    * You don't need to worry about removing these event listeners.
1409    * They're automatically orphaned when clearing markers.
1410    */
1411   setMarkerListeners(line, gutterName, markerClass, eventsArg, data) {
1412     if (!this.hasMarker(line, gutterName, markerClass)) {
1413       return;
1414     }
1416     const cm = editors.get(this);
1417     const marker = cm.lineInfo(line).gutterMarkers[gutterName];
1419     for (const name in eventsArg) {
1420       const listener = eventsArg[name].bind(this, line, marker, data);
1421       marker.addEventListener(name, listener);
1422     }
1423   }
1425   /**
1426    * Returns whether a line is decorated using the specified class name.
1427    */
1428   hasLineClass(line, className) {
1429     const info = this.lineInfo(line);
1431     if (!info || !info.wrapClass) {
1432       return false;
1433     }
1435     return info.wrapClass.split(" ").includes(className);
1436   }
1438   /**
1439    * Sets a CSS class name for the given line, including the text and gutter.
1440    */
1441   addLineClass(lineOrOffset, className) {
1442     const cm = editors.get(this);
1443     const line = this.toLineIfWasmOffset(lineOrOffset);
1444     cm.addLineClass(line, "wrap", className);
1445   }
1447   /**
1448    * The reverse of addLineClass.
1449    */
1450   removeLineClass(lineOrOffset, className) {
1451     const cm = editors.get(this);
1452     const line = this.toLineIfWasmOffset(lineOrOffset);
1453     cm.removeLineClass(line, "wrap", className);
1454   }
1456   /**
1457    * Mark a range of text inside the two {line, ch} bounds. Since the range may
1458    * be modified, for example, when typing text, this method returns a function
1459    * that can be used to remove the mark.
1460    */
1461   markText(from, to, className = "marked-text") {
1462     const cm = editors.get(this);
1463     const text = cm.getRange(from, to);
1464     const span = cm.getWrapperElement().ownerDocument.createElement("span");
1465     span.className = className;
1466     span.textContent = text;
1468     const mark = cm.markText(from, to, { replacedWith: span });
1469     return {
1470       anchor: span,
1471       clear: () => mark.clear(),
1472     };
1473   }
1475   /**
1476    * Calculates and returns one or more {line, ch} objects for
1477    * a zero-based index who's value is relative to the start of
1478    * the editor's text.
1479    *
1480    * If only one argument is given, this method returns a single
1481    * {line,ch} object. Otherwise it returns an array.
1482    */
1483   getPosition(...args) {
1484     const cm = editors.get(this);
1485     const res = args.map(ind => cm.posFromIndex(ind));
1486     return args.length === 1 ? res[0] : res;
1487   }
1489   /**
1490    * The reverse of getPosition. Similarly to getPosition this
1491    * method returns a single value if only one argument was given
1492    * and an array otherwise.
1493    */
1494   getOffset(...args) {
1495     const cm = editors.get(this);
1496     const res = args.map(pos => cm.indexFromPos(pos));
1497     return args.length > 1 ? res : res[0];
1498   }
1500   /**
1501    * Returns a {line, ch} object that corresponds to the
1502    * left, top coordinates.
1503    */
1504   getPositionFromCoords({ left, top }) {
1505     const cm = editors.get(this);
1506     return cm.coordsChar({ left, top });
1507   }
1509   /**
1510    * The reverse of getPositionFromCoords. Similarly, returns a {left, top}
1511    * object that corresponds to the specified line and character number.
1512    */
1513   getCoordsFromPosition({ line, ch }) {
1514     const cm = editors.get(this);
1515     return cm.charCoords({ line: ~~line, ch: ~~ch });
1516   }
1518   /**
1519    * Returns true if there's something to undo and false otherwise.
1520    */
1521   canUndo() {
1522     const cm = editors.get(this);
1523     return cm.historySize().undo > 0;
1524   }
1526   /**
1527    * Returns true if there's something to redo and false otherwise.
1528    */
1529   canRedo() {
1530     const cm = editors.get(this);
1531     return cm.historySize().redo > 0;
1532   }
1534   /**
1535    * Marks the contents as clean and returns the current
1536    * version number.
1537    */
1538   setClean() {
1539     const cm = editors.get(this);
1540     this.version = cm.changeGeneration();
1541     this.#lastDirty = false;
1542     this.emit("dirty-change");
1543     return this.version;
1544   }
1546   /**
1547    * Returns true if contents of the text area are
1548    * clean i.e. no changes were made since the last version.
1549    */
1550   isClean() {
1551     const cm = editors.get(this);
1552     return cm.isClean(this.version);
1553   }
1555   /**
1556    * This method opens an in-editor dialog asking for a line to
1557    * jump to. Once given, it changes cursor to that line.
1558    */
1559   jumpToLine() {
1560     const doc = editors.get(this).getWrapperElement().ownerDocument;
1561     const div = doc.createElement("div");
1562     const inp = doc.createElement("input");
1563     const txt = doc.createTextNode(L10N.getStr("gotoLineCmd.promptTitle"));
1565     inp.type = "text";
1566     inp.style.width = "10em";
1567     inp.style.marginInlineStart = "1em";
1569     div.appendChild(txt);
1570     div.appendChild(inp);
1572     this.openDialog(div, line => {
1573       // Handle LINE:COLUMN as well as LINE
1574       const match = line.toString().match(RE_JUMP_TO_LINE);
1575       if (match) {
1576         const [, matchLine, column] = match;
1577         this.setCursor({ line: matchLine - 1, ch: column ? column - 1 : 0 });
1578       }
1579     });
1580   }
1582   /**
1583    * Moves the content of the current line or the lines selected up a line.
1584    */
1585   moveLineUp() {
1586     const cm = editors.get(this);
1587     const start = cm.getCursor("start");
1588     const end = cm.getCursor("end");
1590     if (start.line === 0) {
1591       return;
1592     }
1594     // Get the text in the lines selected or the current line of the cursor
1595     // and append the text of the previous line.
1596     let value;
1597     if (start.line !== end.line) {
1598       value =
1599         cm.getRange(
1600           { line: start.line, ch: 0 },
1601           { line: end.line, ch: cm.getLine(end.line).length }
1602         ) + "\n";
1603     } else {
1604       value = cm.getLine(start.line) + "\n";
1605     }
1606     value += cm.getLine(start.line - 1);
1608     // Replace the previous line and the currently selected lines with the new
1609     // value and maintain the selection of the text.
1610     cm.replaceRange(
1611       value,
1612       { line: start.line - 1, ch: 0 },
1613       { line: end.line, ch: cm.getLine(end.line).length }
1614     );
1615     cm.setSelection(
1616       { line: start.line - 1, ch: start.ch },
1617       { line: end.line - 1, ch: end.ch }
1618     );
1619   }
1621   /**
1622    * Moves the content of the current line or the lines selected down a line.
1623    */
1624   moveLineDown() {
1625     const cm = editors.get(this);
1626     const start = cm.getCursor("start");
1627     const end = cm.getCursor("end");
1629     if (end.line + 1 === cm.lineCount()) {
1630       return;
1631     }
1633     // Get the text of next line and append the text in the lines selected
1634     // or the current line of the cursor.
1635     let value = cm.getLine(end.line + 1) + "\n";
1636     if (start.line !== end.line) {
1637       value += cm.getRange(
1638         { line: start.line, ch: 0 },
1639         { line: end.line, ch: cm.getLine(end.line).length }
1640       );
1641     } else {
1642       value += cm.getLine(start.line);
1643     }
1645     // Replace the currently selected lines and the next line with the new
1646     // value and maintain the selection of the text.
1647     cm.replaceRange(
1648       value,
1649       { line: start.line, ch: 0 },
1650       { line: end.line + 1, ch: cm.getLine(end.line + 1).length }
1651     );
1652     cm.setSelection(
1653       { line: start.line + 1, ch: start.ch },
1654       { line: end.line + 1, ch: end.ch }
1655     );
1656   }
1658   /**
1659    * Intercept CodeMirror's Find and replace key shortcut to select the search input
1660    */
1661   findOrReplace(node, isReplaceAll) {
1662     const cm = editors.get(this);
1663     const isInput = node.tagName === "INPUT";
1664     const isSearchInput = isInput && node.type === "search";
1665     // replace box is a different input instance than search, and it is
1666     // located in a code mirror dialog
1667     const isDialogInput =
1668       isInput &&
1669       node.parentNode &&
1670       node.parentNode.classList.contains("CodeMirror-dialog");
1671     if (!(isSearchInput || isDialogInput)) {
1672       return;
1673     }
1675     if (isSearchInput || isReplaceAll) {
1676       // select the search input
1677       // it's the precise reason why we reimplement these key shortcuts
1678       node.select();
1679     }
1681     // need to call it since we prevent the propagation of the event and
1682     // cancel codemirror's key handling
1683     cm.execCommand("find");
1684   }
1686   /**
1687    * Intercept CodeMirror's findNext and findPrev key shortcut to allow
1688    * immediately search for next occurance after typing a word to search.
1689    */
1690   findNextOrPrev(node, isFindPrev) {
1691     const cm = editors.get(this);
1692     const isInput = node.tagName === "INPUT";
1693     const isSearchInput = isInput && node.type === "search";
1694     if (!isSearchInput) {
1695       return;
1696     }
1697     const query = node.value;
1698     // cm.state.search allows to automatically start searching for the next occurance
1699     // it's the precise reason why we reimplement these key shortcuts
1700     if (!cm.state.search || cm.state.search.query !== query) {
1701       cm.state.search = {
1702         posFrom: null,
1703         posTo: null,
1704         overlay: null,
1705         query,
1706       };
1707     }
1709     // need to call it since we prevent the propagation of the event and
1710     // cancel codemirror's key handling
1711     if (isFindPrev) {
1712       cm.execCommand("findPrev");
1713     } else {
1714       cm.execCommand("findNext");
1715     }
1716   }
1718   /**
1719    * Returns current font size for the editor area, in pixels.
1720    */
1721   getFontSize() {
1722     const cm = editors.get(this);
1723     const el = cm.getWrapperElement();
1724     const win = el.ownerDocument.defaultView;
1726     return parseInt(win.getComputedStyle(el).getPropertyValue("font-size"), 10);
1727   }
1729   /**
1730    * Sets font size for the editor area.
1731    */
1732   setFontSize(size) {
1733     const cm = editors.get(this);
1734     cm.getWrapperElement().style.fontSize = parseInt(size, 10) + "px";
1735     cm.refresh();
1736   }
1738   setLineWrapping(value) {
1739     const cm = editors.get(this);
1740     if (this.config.cm6) {
1741       const {
1742         codemirrorView: { EditorView },
1743       } = this.#CodeMirror6;
1744       cm.dispatch({
1745         effects: this.#compartments.lineWrapCompartment.reconfigure(
1746           value ? EditorView.lineWrapping : []
1747         ),
1748       });
1749     } else {
1750       cm.setOption("lineWrapping", value);
1751     }
1752     this.config.lineWrapping = value;
1753   }
1755   /**
1756    * Sets an option for the editor.  For most options it just defers to
1757    * CodeMirror.setOption, but certain ones are maintained within the editor
1758    * instance.
1759    */
1760   setOption(o, v) {
1761     const cm = editors.get(this);
1763     // Save the state of a valid autoCloseBrackets string, so we can reset
1764     // it if it gets preffed off and back on.
1765     if (o === "autoCloseBrackets" && v) {
1766       this.config.autoCloseBracketsSaved = v;
1767     }
1769     if (o === "autocomplete") {
1770       this.config.autocomplete = v;
1771       this.setupAutoCompletion();
1772     } else {
1773       cm.setOption(o, v);
1774       this.config[o] = v;
1775     }
1777     if (o === "enableCodeFolding") {
1778       // The new value maybe explicitly force foldGUtter on or off, ignoring
1779       // the prefs service.
1780       this.updateCodeFoldingGutter();
1781     }
1782   }
1784   /**
1785    * Gets an option for the editor.  For most options it just defers to
1786    * CodeMirror.getOption, but certain ones are maintained within the editor
1787    * instance.
1788    */
1789   getOption(o) {
1790     const cm = editors.get(this);
1791     if (o === "autocomplete") {
1792       return this.config.autocomplete;
1793     }
1795     return cm.getOption(o);
1796   }
1798   /**
1799    * Sets up autocompletion for the editor. Lazily imports the required
1800    * dependencies because they vary by editor mode.
1801    *
1802    * Autocompletion is special, because we don't want to automatically use
1803    * it just because it is preffed on (it still needs to be requested by the
1804    * editor), but we do want to always disable it if it is preffed off.
1805    */
1806   setupAutoCompletion() {
1807     if (!this.config.autocomplete && !this.initializeAutoCompletion) {
1808       // Do nothing since there is no autocomplete config and no autocompletion have
1809       // been initialized.
1810       return;
1811     }
1812     // The autocomplete module will overwrite this.initializeAutoCompletion
1813     // with a mode specific autocompletion handler.
1814     if (!this.initializeAutoCompletion) {
1815       this.extend(
1816         require("resource://devtools/client/shared/sourceeditor/autocomplete.js")
1817       );
1818     }
1820     if (this.config.autocomplete && Services.prefs.getBoolPref(AUTOCOMPLETE)) {
1821       this.initializeAutoCompletion(this.config.autocompleteOpts);
1822     } else {
1823       this.destroyAutoCompletion();
1824     }
1825   }
1827   getAutoCompletionText() {
1828     const cm = editors.get(this);
1829     const mark = cm
1830       .getAllMarks()
1831       .find(m => m.className === AUTOCOMPLETE_MARK_CLASSNAME);
1832     if (!mark) {
1833       return "";
1834     }
1836     return mark.attributes["data-completion"] || "";
1837   }
1839   setAutoCompletionText(text) {
1840     const cursor = this.getCursor();
1841     const cm = editors.get(this);
1842     const className = AUTOCOMPLETE_MARK_CLASSNAME;
1844     cm.operation(() => {
1845       cm.getAllMarks().forEach(mark => {
1846         if (mark.className === className) {
1847           mark.clear();
1848         }
1849       });
1851       if (text) {
1852         cm.markText({ ...cursor, ch: cursor.ch - 1 }, cursor, {
1853           className,
1854           attributes: {
1855             "data-completion": text,
1856           },
1857         });
1858       }
1859     });
1860   }
1862   /**
1863    * Extends an instance of the Editor object with additional
1864    * functions. Each function will be called with context as
1865    * the first argument. Context is a {ed, cm} object where
1866    * 'ed' is an instance of the Editor object and 'cm' is an
1867    * instance of the CodeMirror object. Example:
1868    *
1869    * function hello(ctx, name) {
1870    *   let { cm, ed } = ctx;
1871    *   cm;   // CodeMirror instance
1872    *   ed;   // Editor instance
1873    *   name; // 'Mozilla'
1874    * }
1875    *
1876    * editor.extend({ hello: hello });
1877    * editor.hello('Mozilla');
1878    */
1879   extend(funcs) {
1880     Object.keys(funcs).forEach(name => {
1881       const cm = editors.get(this);
1882       const ctx = { ed: this, cm, Editor };
1884       if (name === "initialize") {
1885         funcs[name](ctx);
1886         return;
1887       }
1889       this[name] = funcs[name].bind(null, ctx);
1890     });
1891   }
1893   isDestroyed() {
1894     return !editors.get(this);
1895   }
1897   destroy() {
1898     this.container = null;
1899     this.config = null;
1900     this.version = null;
1901     this.#ownerDoc = null;
1902     this.#updateListener = null;
1903     this.#lineGutterMarkers.clear();
1905     if (this.#prefObserver) {
1906       this.#prefObserver.off(KEYMAP_PREF, this.setKeyMap);
1907       this.#prefObserver.off(TAB_SIZE, this.reloadPreferences);
1908       this.#prefObserver.off(EXPAND_TAB, this.reloadPreferences);
1909       this.#prefObserver.off(AUTO_CLOSE, this.reloadPreferences);
1910       this.#prefObserver.off(AUTOCOMPLETE, this.reloadPreferences);
1911       this.#prefObserver.off(DETECT_INDENT, this.reloadPreferences);
1912       this.#prefObserver.off(ENABLE_CODE_FOLDING, this.reloadPreferences);
1913       this.#prefObserver.destroy();
1914     }
1916     // Remove the link between the document and code-mirror.
1917     const cm = editors.get(this);
1918     if (cm?.doc) {
1919       cm.doc.cm = null;
1920     }
1922     this.emit("destroy");
1923   }
1925   updateCodeFoldingGutter() {
1926     let shouldFoldGutter = this.config.enableCodeFolding;
1927     const foldGutterIndex = this.config.gutters.indexOf(
1928       "CodeMirror-foldgutter"
1929     );
1930     const cm = editors.get(this);
1932     if (shouldFoldGutter === undefined) {
1933       shouldFoldGutter = Services.prefs.getBoolPref(ENABLE_CODE_FOLDING);
1934     }
1936     if (shouldFoldGutter) {
1937       // Add the gutter before enabling foldGutter
1938       if (foldGutterIndex === -1) {
1939         const gutters = this.config.gutters.slice();
1940         gutters.push("CodeMirror-foldgutter");
1941         this.setOption("gutters", gutters);
1942       }
1944       this.setOption("foldGutter", true);
1945     } else {
1946       // No code should remain folded when folding is off.
1947       if (cm) {
1948         cm.execCommand("unfoldAll");
1949       }
1951       // Remove the gutter so it doesn't take up space
1952       if (foldGutterIndex !== -1) {
1953         const gutters = this.config.gutters.slice();
1954         gutters.splice(foldGutterIndex, 1);
1955         this.setOption("gutters", gutters);
1956       }
1958       this.setOption("foldGutter", false);
1959     }
1960   }
1962   /**
1963    * Register all key shortcuts.
1964    */
1965   #initSearchShortcuts(win) {
1966     const shortcuts = new KeyShortcuts({
1967       window: win,
1968     });
1969     const keys = ["find.key", "findNext.key", "findPrev.key"];
1971     if (OS === "Darwin") {
1972       keys.push("replaceAllMac.key");
1973     } else {
1974       keys.push("replaceAll.key");
1975     }
1976     // Process generic keys:
1977     keys.forEach(name => {
1978       const key = L10N.getStr(name);
1979       shortcuts.on(key, event => this.#onSearchShortcut(name, event));
1980     });
1981   }
1982   /**
1983    * Key shortcut listener.
1984    */
1985   #onSearchShortcut = (name, event) => {
1986     if (!this.#isInputOrTextarea(event.target)) {
1987       return;
1988     }
1989     const node = event.originalTarget;
1991     switch (name) {
1992       // replaceAll.key is Alt + find.key
1993       case "replaceAllMac.key":
1994         this.findOrReplace(node, true);
1995         break;
1996       // replaceAll.key is Shift + find.key
1997       case "replaceAll.key":
1998         this.findOrReplace(node, true);
1999         break;
2000       case "find.key":
2001         this.findOrReplace(node, false);
2002         break;
2003       // findPrev.key is Shift + findNext.key
2004       case "findPrev.key":
2005         this.findNextOrPrev(node, true);
2006         break;
2007       case "findNext.key":
2008         this.findNextOrPrev(node, false);
2009         break;
2010       default:
2011         console.error("Unexpected editor key shortcut", name);
2012         return;
2013     }
2014     // Prevent default for this action
2015     event.stopPropagation();
2016     event.preventDefault();
2017   };
2019   /**
2020    * Check if a node is an input or textarea
2021    */
2022   #isInputOrTextarea(element) {
2023     const name = element.tagName.toLowerCase();
2024     return name === "input" || name === "textarea";
2025   }
2028 // Since Editor is a thin layer over CodeMirror some methods
2029 // are mapped directly—without any changes.
2031 CM_MAPPING.forEach(name => {
2032   Editor.prototype[name] = function (...args) {
2033     const cm = editors.get(this);
2034     return cm[name].apply(cm, args);
2035   };
2039  * We compute the CSS property names, values, and color names to be used with
2040  * CodeMirror to more closely reflect what is supported by the target platform.
2041  * The database is used to replace the values used in CodeMirror while initiating
2042  * an editor object. This is done here instead of the file codemirror/css.js so
2043  * as to leave that file untouched and easily upgradable.
2044  */
2045 function getCSSKeywords(cssProperties) {
2046   function keySet(array) {
2047     const keys = {};
2048     for (let i = 0; i < array.length; ++i) {
2049       keys[array[i]] = true;
2050     }
2051     return keys;
2052   }
2054   const propertyKeywords = cssProperties.getNames();
2055   const colorKeywords = {};
2056   const valueKeywords = {};
2058   propertyKeywords.forEach(property => {
2059     if (property.includes("color")) {
2060       cssProperties.getValues(property).forEach(value => {
2061         colorKeywords[value] = true;
2062       });
2063     } else {
2064       cssProperties.getValues(property).forEach(value => {
2065         valueKeywords[value] = true;
2066       });
2067     }
2068   });
2070   return {
2071     propertyKeywords: keySet(propertyKeywords),
2072     colorKeywords,
2073     valueKeywords,
2074   };
2077 module.exports = Editor;