editor.js (130044B)
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/. */ 4 5 "use strict"; 6 7 const { 8 EXPAND_TAB, 9 TAB_SIZE, 10 DETECT_INDENT, 11 getIndentationFromIteration, 12 } = require("resource://devtools/shared/indentation.js"); 13 14 const { debounce } = require("resource://devtools/shared/debounce.js"); 15 const nodeConstants = require("resource://devtools/shared/dom-node-constants.js"); 16 17 const ENABLE_CODE_FOLDING = "devtools.editor.enableCodeFolding"; 18 const KEYMAP_PREF = "devtools.editor.keymap"; 19 const AUTO_CLOSE = "devtools.editor.autoclosebrackets"; 20 const AUTOCOMPLETE = "devtools.editor.autocomplete"; 21 const CARET_BLINK_TIME = "ui.caretBlinkTime"; 22 const XHTML_NS = "http://www.w3.org/1999/xhtml"; 23 24 const VALID_KEYMAPS = new Map([ 25 [ 26 "emacs", 27 "chrome://devtools/content/shared/sourceeditor/codemirror/keymap/emacs.js", 28 ], 29 [ 30 "vim", 31 "chrome://devtools/content/shared/sourceeditor/codemirror/keymap/vim.js", 32 ], 33 [ 34 "sublime", 35 "chrome://devtools/content/shared/sourceeditor/codemirror/keymap/sublime.js", 36 ], 37 ]); 38 39 // Maximum allowed margin (in number of lines) from top or bottom of the editor 40 // while shifting to a line which was initially out of view. 41 const MAX_VERTICAL_OFFSET = 3; 42 43 const RE_JUMP_TO_LINE = /^(\d+):?(\d+)?/; 44 const AUTOCOMPLETE_MARK_CLASSNAME = "cm-auto-complete-shadow-text"; 45 46 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 47 const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); 48 const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js"); 49 50 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 51 const L10N = new LocalizationHelper( 52 "devtools/client/locales/sourceeditor.properties" 53 ); 54 55 loader.lazyRequireGetter( 56 this, 57 "wasm", 58 "resource://devtools/client/shared/sourceeditor/wasm.js" 59 ); 60 61 loader.lazyRequireGetter( 62 this, 63 "scopeUtils", 64 "resource://devtools/client/shared/sourceeditor/scope-utils.js" 65 ); 66 67 loader.lazyRequireGetter( 68 this, 69 "lezerUtils", 70 "resource://devtools/client/shared/sourceeditor/lezer-utils.js" 71 ); 72 73 const { OS } = Services.appinfo; 74 75 // CM_BUNDLE and CM_IFRAME represent the HTML and JavaScript that is 76 // injected into an iframe in order to initialize a CodeMirror instance. 77 78 const CM_BUNDLE = 79 "chrome://devtools/content/shared/sourceeditor/codemirror/codemirror.bundle.js"; 80 81 const CM_IFRAME = 82 "chrome://devtools/content/shared/sourceeditor/codemirror/cmiframe.html"; 83 84 const CM_MAPPING = [ 85 "clearHistory", 86 "defaultCharWidth", 87 "extendSelection", 88 "getCursor", 89 "getLine", 90 "getScrollInfo", 91 "getSelection", 92 "getViewport", 93 "hasFocus", 94 "lineCount", 95 "openDialog", 96 "redo", 97 "refresh", 98 "replaceSelection", 99 "setSelection", 100 "somethingSelected", 101 "undo", 102 ]; 103 104 const ONLY_SPACES_REGEXP = /^\s*$/; 105 106 const editors = new WeakMap(); 107 108 /** 109 * A very thin wrapper around CodeMirror. Provides a number 110 * of helper methods to make our use of CodeMirror easier and 111 * another method, appendTo, to actually create and append 112 * the CodeMirror instance. 113 * 114 * Note that Editor doesn't expose CodeMirror instance to the 115 * outside world. 116 * 117 * Constructor accepts one argument, config. It is very 118 * similar to the CodeMirror configuration object so for most 119 * properties go to CodeMirror's documentation (see below). 120 * 121 * Other than that, it accepts one additional and optional 122 * property contextMenu. This property should be an element, or 123 * an ID of an element that we can use as a context menu. 124 * 125 * This object is also an event emitter. 126 * 127 * CodeMirror docs: http://codemirror.net/doc/manual.html 128 */ 129 class Editor extends EventEmitter { 130 // Static methods on the Editor object itself. 131 132 /** 133 * Returns a string representation of a shortcut 'key' with 134 * a OS specific modifier. Cmd- for Macs, Ctrl- for other 135 * platforms. Useful with extraKeys configuration option. 136 * 137 * CodeMirror defines all keys with modifiers in the following 138 * order: Shift - Ctrl/Cmd - Alt - Key 139 */ 140 static accel(key, modifiers = {}) { 141 return ( 142 (modifiers.shift ? "Shift-" : "") + 143 (Services.appinfo.OS == "Darwin" ? "Cmd-" : "Ctrl-") + 144 (modifiers.alt ? "Alt-" : "") + 145 key 146 ); 147 } 148 149 /** 150 * Returns a string representation of a shortcut for a 151 * specified command 'cmd'. Append Cmd- for macs, Ctrl- for other 152 * platforms unless noaccel is specified in the options. Useful when overwriting 153 * or disabling default shortcuts. 154 */ 155 static keyFor(cmd, opts = { noaccel: false }) { 156 const key = L10N.getStr(cmd + ".commandkey"); 157 return opts.noaccel ? key : Editor.accel(key); 158 } 159 160 static modes = { 161 cljs: { name: "text/x-clojure" }, 162 css: { name: "css" }, 163 fs: { name: "x-shader/x-fragment" }, 164 haxe: { name: "haxe" }, 165 http: { name: "http" }, 166 html: { name: "htmlmixed" }, 167 xml: { name: "xml" }, 168 javascript: { name: "javascript" }, 169 json: { name: "json" }, 170 text: { name: "text" }, 171 vs: { name: "x-shader/x-vertex" }, 172 wasm: { name: "wasm" }, 173 }; 174 175 markerTypes = { 176 /* Line Markers */ 177 CONDITIONAL_BP_MARKER: "conditional-breakpoint-panel-marker", 178 TRACE_MARKER: "trace-panel-marker", 179 DEBUG_LINE_MARKER: "debug-line-marker", 180 LINE_EXCEPTION_MARKER: "line-exception-marker", 181 HIGHLIGHT_LINE_MARKER: "highlight-line-marker", 182 MULTI_HIGHLIGHT_LINE_MARKER: "multi-highlight-line-marker", 183 BLACKBOX_LINE_MARKER: "blackbox-line-marker", 184 INLINE_PREVIEW_MARKER: "inline-preview-marker", 185 /* Position Markers */ 186 COLUMN_BREAKPOINT_MARKER: "column-breakpoint-marker", 187 DEBUG_POSITION_MARKER: "debug-position-marker", 188 EXCEPTION_POSITION_MARKER: "exception-position-marker", 189 ACTIVE_SELECTION_MARKER: "active-selection-marker", 190 PAUSED_LOCATION_MARKER: "paused-location-marker", 191 /* Gutter Markers */ 192 EMPTY_LINE_MARKER: "empty-line-marker", 193 BLACKBOX_LINE_GUTTER_MARKER: "blackbox-line-gutter-marker", 194 GUTTER_BREAKPOINT_MARKER: "gutter-breakpoint-marker", 195 }; 196 197 container = null; 198 version = null; 199 config = null; 200 Doc = null; 201 searchState = { 202 cursors: [], 203 currentCursorIndex: -1, 204 query: "", 205 }; 206 207 #abortController; 208 209 // The id for the current source in the editor (selected source). This is used to: 210 // * cache the scroll snapshot for tracking scroll positions and the symbols, 211 // * know when an actual source is displayed (and not only a loading/error message) 212 #currentDocumentId = null; 213 214 #currentDocument = null; 215 #CodeMirror6; 216 #compartments; 217 #effects; 218 #lastDirty; 219 #loadedKeyMaps; 220 #ownerDoc; 221 #prefObserver; 222 #win; 223 #lineGutterMarkers = new Map(); 224 #lineContentMarkers = new Map(); 225 #posContentMarkers = new Map(); 226 #editorDOMEventHandlers = {}; 227 #gutterDOMEventHandlers = {}; 228 // A cache of all the scroll snapshots for the all the sources that 229 // are currently open in the editor. The keys for the Map are the id's 230 // for the source and the values are the scroll snapshots for the sources. 231 #scrollSnapshots = new Map(); 232 #updateListener = null; 233 234 // This stores the language support objects used to syntax highlight code, 235 // These are keyed of the modes. 236 #languageModes = new Map(); 237 238 #sources = new Map(); 239 240 constructor(config) { 241 super(); 242 243 const tabSize = Services.prefs.getIntPref(TAB_SIZE); 244 const useTabs = !Services.prefs.getBoolPref(EXPAND_TAB); 245 const useAutoClose = Services.prefs.getBoolPref(AUTO_CLOSE); 246 247 this.version = null; 248 this.config = { 249 cm6: false, 250 value: "", 251 mode: Editor.modes.text, 252 indentUnit: tabSize, 253 tabSize, 254 contextMenu: null, 255 matchBrackets: true, 256 highlightSelectionMatches: { 257 wordsOnly: true, 258 }, 259 extraKeys: {}, 260 indentWithTabs: useTabs, 261 inputStyle: "accessibleTextArea", 262 // This is set to the biggest value for setTimeout (See https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout#Maximum_delay_value) 263 // This is because codeMirror queries the underlying textArea for some things that 264 // can't be retrieved with events in some browser (but we're fine in Firefox). 265 pollInterval: Math.pow(2, 31) - 1, 266 styleActiveLine: true, 267 autoCloseBrackets: "()[]{}''\"\"``", 268 autoCloseEnabled: useAutoClose, 269 theme: "mozilla", 270 themeSwitching: true, 271 autocomplete: false, 272 autocompleteOpts: {}, 273 // Expect a CssProperties object (see devtools/client/fronts/css-properties.js) 274 cssProperties: null, 275 // Set to `true` to prevent the search addon to be activated. 276 disableSearchAddon: false, 277 // When the search addon is activated (i.e disableSearchAddon == false), 278 // `useSearchAddonPanel` determines if the default search panel for the search addon should be used. 279 // Set to `false` when a custom search panel is used. 280 // Note: This can probably be removed when Bug 1941575 is fixed, and custom search panel is used everywhere 281 useSearchAddonPanel: true, 282 maxHighlightLength: 1000, 283 // Disable codeMirror setTimeout-based cursor blinking (will be replaced by a CSS animation) 284 cursorBlinkRate: 0, 285 // List of non-printable chars that will be displayed in the editor, showing their 286 // unicode version. We only add a few characters to the default list: 287 // - \u202d LEFT-TO-RIGHT OVERRIDE 288 // - \u202e RIGHT-TO-LEFT OVERRIDE 289 // - \u2066 LEFT-TO-RIGHT ISOLATE 290 // - \u2067 RIGHT-TO-LEFT ISOLATE 291 // - \u2069 POP DIRECTIONAL ISOLATE 292 specialChars: 293 // eslint-disable-next-line no-control-regex 294 /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b-\u200f\u2028\u2029\u202d\u202e\u2066\u2067\u2069\ufeff\ufff9-\ufffc]/, 295 specialCharPlaceholder: char => { 296 // Use the doc provided to the setup function if we don't have a reference to a codeMirror 297 // editor yet (this can happen when an Editor is being created with existing content) 298 const doc = this.#ownerDoc; 299 const el = doc.createElement("span"); 300 el.classList.add("cm-non-printable-char"); 301 el.append(doc.createTextNode(`\\u${char.codePointAt(0).toString(16)}`)); 302 return el; 303 }, 304 // In CodeMirror 5, adds a `CodeMirror-selectedtext` class on selected text that 305 // can be used to set the selected text color, which isn't possible by default. 306 // This is especially useful for High Contrast Mode where we do need to adjust the 307 // selection text color 308 styleSelectedText: true, 309 }; 310 311 // Additional shortcuts. 312 this.config.extraKeys[Editor.keyFor("jumpToLine")] = () => 313 this.jumpToLine(); 314 this.config.extraKeys[Editor.keyFor("moveLineUp", { noaccel: true })] = 315 () => this.moveLineUp(); 316 this.config.extraKeys[Editor.keyFor("moveLineDown", { noaccel: true })] = 317 () => this.moveLineDown(); 318 this.config.extraKeys[Editor.keyFor("toggleComment")] = "toggleComment"; 319 320 // Disable ctrl-[ and ctrl-] because toolbox uses those shortcuts. 321 this.config.extraKeys[Editor.keyFor("indentLess")] = false; 322 this.config.extraKeys[Editor.keyFor("indentMore")] = false; 323 324 // Disable Alt-B and Alt-F to navigate groups (respectively previous and next) since: 325 // - it's not standard in input fields 326 // - it also inserts a character which feels weird 327 this.config.extraKeys["Alt-B"] = false; 328 this.config.extraKeys["Alt-F"] = false; 329 330 // Disable Ctrl/Cmd + U as it's used for "View Source". It's okay to disable Ctrl+U as 331 // the underlying command, `undoSelection`, isn't standard in input fields and isn't 332 // widely known. 333 this.config.extraKeys[Editor.accel("U")] = false; 334 335 if (!config.disableSearchAddon) { 336 // Override the default search shortcut so the built-in UI doesn't get hidden 337 // when hitting Enter (so the user can cycle through results). 338 this.config.extraKeys[Editor.accel("F")] = () => 339 editors.get(this).execCommand("findPersistent"); 340 } 341 342 // Disable keys that trigger events with a null-string `which` property. 343 // It looks like some of those (e.g. the Function key), can trigger a poll 344 // which fails to see that there's a selection, which end up replacing the 345 // selected text with an empty string. 346 // TODO: We should investigate the root cause. 347 this.config.extraKeys["'\u0000'"] = false; 348 349 // Overwrite default config with user-provided, if needed. 350 Object.keys(config).forEach(k => { 351 if (k != "extraKeys") { 352 this.config[k] = config[k]; 353 return; 354 } 355 356 if (!config.extraKeys) { 357 return; 358 } 359 360 Object.keys(config.extraKeys).forEach(key => { 361 this.config.extraKeys[key] = config.extraKeys[key]; 362 }); 363 }); 364 365 if (!this.config.gutters) { 366 this.config.gutters = []; 367 } 368 if ( 369 this.config.lineNumbers && 370 !this.config.gutters.includes("CodeMirror-linenumbers") 371 ) { 372 this.config.gutters.push("CodeMirror-linenumbers"); 373 } 374 375 // Remember the initial value of autoCloseBrackets. 376 this.config.autoCloseBracketsSaved = this.config.autoCloseBrackets; 377 378 // If the tab behaviour is not explicitly set to `false` from the config, set a tab behavior. 379 // If something is selected, indent those lines. If nothing is selected and we're 380 // indenting with tabs, insert one tab. Otherwise insert N 381 // whitespaces where N == indentUnit option. 382 if (this.config.extraKeys.Tab !== false) { 383 this.config.extraKeys.Tab = cm => { 384 if (config.extraKeys?.Tab) { 385 // If a consumer registers its own extraKeys.Tab, we execute it before doing 386 // anything else. If it returns false, that mean that all the key handling work is 387 // done, so we can do an early return. 388 const res = config.extraKeys.Tab(cm); 389 if (res === false) { 390 return; 391 } 392 } 393 394 if (cm.somethingSelected()) { 395 cm.indentSelection("add"); 396 return; 397 } 398 399 if (this.config.indentWithTabs) { 400 cm.replaceSelection("\t", "end", "+input"); 401 return; 402 } 403 404 let num = cm.getOption("indentUnit"); 405 if (cm.getCursor().ch !== 0) { 406 num -= cm.getCursor().ch % num; 407 } 408 cm.replaceSelection(" ".repeat(num), "end", "+input"); 409 }; 410 411 if (this.config.cssProperties) { 412 // Ensure that autocompletion has cssProperties if it's passed in via the options. 413 this.config.autocompleteOpts.cssProperties = this.config.cssProperties; 414 } 415 } 416 } 417 418 /** 419 * Exposes the CodeMirror class. We want to be able to 420 * invoke static commands such as runMode for syntax highlighting. 421 */ 422 get CodeMirror() { 423 const codeMirror = editors.get(this); 424 return codeMirror?.constructor; 425 } 426 427 /** 428 * Exposes the CodeMirror instance. We want to get away from trying to 429 * abstract away the API entirely, and this makes it easier to integrate in 430 * various environments and do complex things. 431 */ 432 get codeMirror() { 433 if (!editors.has(this)) { 434 throw new Error( 435 "CodeMirror instance does not exist. You must wait " + 436 "for it to be appended to the DOM." 437 ); 438 } 439 return editors.get(this); 440 } 441 442 /** 443 * Return whether there is a CodeMirror instance associated with this Editor. 444 */ 445 get hasCodeMirror() { 446 return editors.has(this); 447 } 448 449 /** 450 * Appends the current Editor instance to the element specified by 451 * 'el'. You can also provide your own iframe to host the editor as 452 * an optional second parameter. This method actually creates and 453 * loads CodeMirror and all its dependencies. 454 * 455 * This method is asynchronous and returns a promise. 456 */ 457 appendTo(el, env) { 458 return new Promise(resolve => { 459 const cm = editors.get(this); 460 461 if (!env) { 462 env = el.ownerDocument.createElementNS(XHTML_NS, "iframe"); 463 env.className = "source-editor-frame"; 464 } 465 466 if (cm) { 467 throw new Error("You can append an editor only once."); 468 } 469 470 const onLoad = () => { 471 // Prevent flickering by showing the iframe once loaded. 472 // See https://github.com/w3c/csswg-drafts/issues/9624 473 env.style.visibility = ""; 474 const win = env.contentWindow.wrappedJSObject; 475 this.container = env; 476 477 const editorEl = win.document.body; 478 const editorDoc = el.ownerDocument; 479 if (this.config.cm6) { 480 this.#setupCm6(editorEl, editorDoc); 481 } else { 482 this.#setup(editorEl, editorDoc); 483 } 484 resolve(); 485 }; 486 487 env.style.visibility = "hidden"; 488 env.addEventListener("load", onLoad, { 489 capture: true, 490 once: true, 491 signal: this.#abortController?.signal, 492 }); 493 env.src = CM_IFRAME; 494 el.appendChild(env); 495 496 this.once("destroy", () => el.removeChild(env)); 497 }); 498 } 499 500 appendToLocalElement(el) { 501 const win = el.ownerDocument.defaultView; 502 this.#abortController = new win.AbortController(); 503 if (this.config.cm6) { 504 this.#setupCm6(el); 505 } else { 506 this.#setup(el); 507 } 508 } 509 510 // This update listener allows listening to the changes 511 // to the codemiror editor. 512 setUpdateListener(listener = null) { 513 this.#updateListener = listener; 514 } 515 516 /** 517 * Do the actual appending and configuring of the CodeMirror instance. This is 518 * used by both append functions above, and does all the hard work to 519 * configure CodeMirror with all the right options/modes/etc. 520 */ 521 #setup(el, doc) { 522 this.#ownerDoc = doc || el.ownerDocument; 523 const win = el.ownerDocument.defaultView; 524 525 Services.scriptloader.loadSubScript(CM_BUNDLE, win); 526 this.#win = win; 527 528 if (this.config.cssProperties) { 529 // Replace the propertyKeywords, colorKeywords and valueKeywords 530 // properties of the CSS MIME type with the values provided by the CSS properties 531 // database. 532 const { propertyKeywords, colorKeywords, valueKeywords } = getCSSKeywords( 533 this.config.cssProperties 534 ); 535 536 const cssSpec = win.CodeMirror.resolveMode("text/css"); 537 cssSpec.propertyKeywords = propertyKeywords; 538 cssSpec.colorKeywords = colorKeywords; 539 cssSpec.valueKeywords = valueKeywords; 540 win.CodeMirror.defineMIME("text/css", cssSpec); 541 542 const scssSpec = win.CodeMirror.resolveMode("text/x-scss"); 543 scssSpec.propertyKeywords = propertyKeywords; 544 scssSpec.colorKeywords = colorKeywords; 545 scssSpec.valueKeywords = valueKeywords; 546 win.CodeMirror.defineMIME("text/x-scss", scssSpec); 547 } 548 549 win.CodeMirror.commands.save = () => this.emit("saveRequested"); 550 551 // Create a CodeMirror instance add support for context menus, 552 // overwrite the default controller (otherwise items in the top and 553 // context menus won't work). 554 555 const cm = win.CodeMirror(el, this.config); 556 this.Doc = win.CodeMirror.Doc; 557 558 // Disable APZ for source editors. It currently causes the line numbers to 559 // "tear off" and swim around on top of the content. Bug 1160601 tracks 560 // finding a solution that allows APZ to work with CodeMirror. 561 cm.getScrollerElement().addEventListener( 562 "wheel", 563 ev => { 564 // By handling the wheel events ourselves, we force the platform to 565 // scroll synchronously, like it did before APZ. However, we lose smooth 566 // scrolling for users with mouse wheels. This seems acceptible vs. 567 // doing nothing and letting the gutter slide around. 568 ev.preventDefault(); 569 570 let { deltaX, deltaY } = ev; 571 572 if (ev.deltaMode == ev.DOM_DELTA_LINE) { 573 deltaX *= cm.defaultCharWidth(); 574 deltaY *= cm.defaultTextHeight(); 575 } else if (ev.deltaMode == ev.DOM_DELTA_PAGE) { 576 deltaX *= cm.getWrapperElement().clientWidth; 577 deltaY *= cm.getWrapperElement().clientHeight; 578 } 579 580 cm.getScrollerElement().scrollBy(deltaX, deltaY); 581 }, 582 { signal: this.#abortController?.signal } 583 ); 584 585 cm.getWrapperElement().addEventListener( 586 "contextmenu", 587 ev => { 588 if (!this.config.contextMenu) { 589 return; 590 } 591 592 ev.stopPropagation(); 593 ev.preventDefault(); 594 595 let popup = this.config.contextMenu; 596 if (typeof popup == "string") { 597 popup = this.#ownerDoc.getElementById(this.config.contextMenu); 598 } 599 600 this.emit("popupOpen", ev, popup); 601 popup.openPopupAtScreen(ev.screenX, ev.screenY, true); 602 }, 603 { signal: this.#abortController?.signal } 604 ); 605 606 const pipedEvents = [ 607 "beforeChange", 608 "blur", 609 "changes", 610 "cursorActivity", 611 "focus", 612 "keyHandled", 613 "scroll", 614 ]; 615 for (const eventName of pipedEvents) { 616 cm.on(eventName, (...args) => this.emit(eventName, ...args)); 617 } 618 619 cm.on("change", () => { 620 this.emit("change"); 621 if (!this.#lastDirty) { 622 this.#lastDirty = true; 623 this.emit("dirty-change"); 624 } 625 }); 626 627 cm.on("gutterClick", (cmArg, line, gutter, ev) => { 628 const lineOrOffset = !this.isWasm ? line : this.lineToWasmOffset(line); 629 this.emit("gutterClick", lineOrOffset, ev.button); 630 }); 631 632 win.CodeMirror.defineExtension("l10n", name => { 633 return L10N.getStr(name); 634 }); 635 636 if (!this.config.disableSearchAddon) { 637 this.#initSearchShortcuts(win); 638 } else { 639 // Hotfix for Bug 1527898. We should remove those overrides as part of Bug 1527903. 640 Object.assign(win.CodeMirror.commands, { 641 find: null, 642 findPersistent: null, 643 findPersistentNext: null, 644 findPersistentPrev: null, 645 findNext: null, 646 findPrev: null, 647 clearSearch: null, 648 replace: null, 649 replaceAll: null, 650 }); 651 } 652 653 // Retrieve the cursor blink rate from user preference, or fall back to CodeMirror's 654 // default value. 655 let cursorBlinkingRate = win.CodeMirror.defaults.cursorBlinkRate; 656 if (Services.prefs.prefHasUserValue(CARET_BLINK_TIME)) { 657 cursorBlinkingRate = Services.prefs.getIntPref( 658 CARET_BLINK_TIME, 659 cursorBlinkingRate 660 ); 661 } 662 // This will be used in the animation-duration property we set on the cursor to 663 // implement the blinking animation. If cursorBlinkingRate is 0 or less, the cursor 664 // won't blink. 665 cm.getWrapperElement().style.setProperty( 666 "--caret-blink-time", 667 `${Math.max(0, cursorBlinkingRate)}ms` 668 ); 669 670 editors.set(this, cm); 671 672 this.reloadPreferences = this.reloadPreferences.bind(this); 673 this.setKeyMap = this.setKeyMap.bind(this, win); 674 675 this.#prefObserver = new PrefObserver("devtools.editor."); 676 this.#prefObserver.on(TAB_SIZE, this.reloadPreferences); 677 this.#prefObserver.on(EXPAND_TAB, this.reloadPreferences); 678 this.#prefObserver.on(AUTO_CLOSE, this.reloadPreferences); 679 this.#prefObserver.on(AUTOCOMPLETE, this.reloadPreferences); 680 this.#prefObserver.on(DETECT_INDENT, this.reloadPreferences); 681 this.#prefObserver.on(ENABLE_CODE_FOLDING, this.reloadPreferences); 682 683 this.reloadPreferences(); 684 685 // Init a map of the loaded keymap files. Should be of the form Map<String->Boolean>. 686 this.#loadedKeyMaps = new Set(); 687 this.#prefObserver.on(KEYMAP_PREF, this.setKeyMap); 688 this.setKeyMap(); 689 690 win.editor = this; 691 const editorReadyEvent = new win.CustomEvent("editorReady"); 692 win.dispatchEvent(editorReadyEvent); 693 } 694 695 #setupLanguageModes() { 696 if (!this.config.cm6) { 697 return; 698 } 699 const { 700 codemirrorLangJavascript, 701 codemirrorLangJson, 702 codemirrorLangHtml, 703 codemirrorLangXml, 704 codemirrorLangCss, 705 } = this.#CodeMirror6; 706 707 this.#languageModes.set( 708 Editor.modes.javascript, 709 codemirrorLangJavascript.javascript() 710 ); 711 this.#languageModes.set(Editor.modes.json, codemirrorLangJson.json()); 712 this.#languageModes.set(Editor.modes.html, codemirrorLangHtml.html()); 713 this.#languageModes.set(Editor.modes.xml, codemirrorLangXml.xml()); 714 this.#languageModes.set(Editor.modes.css, codemirrorLangCss.css()); 715 } 716 717 /** 718 * Do the actual appending and configuring of the CodeMirror 6 instance. 719 * This is used by appendTo and appendToLocalElement, and does all the hard work to 720 * configure CodeMirror 6 with all the right options/modes/etc. 721 * This should be kept in sync with #setup. 722 * 723 * @param {Element} el: Element into which the codeMirror editor should be appended. 724 * @param {Document} document: Optional document, if not set, will default to el.ownerDocument 725 */ 726 #setupCm6(el, doc) { 727 this.#ownerDoc = doc || el.ownerDocument; 728 const win = el.ownerDocument.defaultView; 729 this.#win = win; 730 731 this.#CodeMirror6 = this.#win.ChromeUtils.importESModule( 732 "resource://devtools/client/shared/sourceeditor/codemirror6/codemirror6.bundle.mjs", 733 { global: "current" } 734 ); 735 736 const { 737 codemirror, 738 codemirrorView: { 739 drawSelection, 740 EditorView, 741 keymap, 742 lineNumbers, 743 placeholder, 744 }, 745 codemirrorState: { EditorState, Compartment, Prec }, 746 codemirrorSearch: { search, searchKeymap, highlightSelectionMatches }, 747 codemirrorLanguage: { 748 syntaxTreeAvailable, 749 indentUnit, 750 codeFolding, 751 syntaxHighlighting, 752 bracketMatching, 753 }, 754 lezerHighlight, 755 } = this.#CodeMirror6; 756 757 this.#compartments = { 758 tabSizeCompartment: new Compartment(), 759 indentCompartment: new Compartment(), 760 lineWrapCompartment: new Compartment(), 761 lineNumberCompartment: new Compartment(), 762 lineNumberMarkersCompartment: new Compartment(), 763 searchHighlightCompartment: new Compartment(), 764 domEventHandlersCompartment: new Compartment(), 765 foldGutterCompartment: new Compartment(), 766 languageCompartment: new Compartment(), 767 }; 768 769 const { lineContentMarkerEffect, lineContentMarkerExtension } = 770 this.#createlineContentMarkersExtension(); 771 772 const { positionContentMarkerEffect, positionContentMarkerExtension } = 773 this.#createPositionContentMarkersExtension(); 774 775 this.#effects = { lineContentMarkerEffect, positionContentMarkerEffect }; 776 777 const indentStr = (this.config.indentWithTabs ? "\t" : " ").repeat( 778 this.config.indentUnit || 2 779 ); 780 781 // Track the scroll snapshot for the current document at the end of the scroll 782 this.#editorDOMEventHandlers.scroll = [ 783 debounce(this.#cacheScrollSnapshot, 250), 784 ]; 785 786 this.#setupLanguageModes(); 787 788 const languageMode = []; 789 if (this.config.mode && this.#languageModes.has(this.config.mode)) { 790 languageMode.push(this.#languageModes.get(this.config.mode)); 791 } 792 793 const extensions = [ 794 bracketMatching(), 795 this.#compartments.indentCompartment.of(indentUnit.of(indentStr)), 796 this.#compartments.tabSizeCompartment.of( 797 EditorState.tabSize.of(this.config.tabSize) 798 ), 799 this.#compartments.lineWrapCompartment.of( 800 this.config.lineWrapping ? EditorView.lineWrapping : [] 801 ), 802 EditorState.readOnly.of(this.config.readOnly), 803 this.#compartments.lineNumberCompartment.of( 804 this.config.lineNumbers ? lineNumbers() : [] 805 ), 806 codeFolding({ 807 placeholderText: "↔", 808 }), 809 this.#compartments.foldGutterCompartment.of([]), 810 syntaxHighlighting(lezerHighlight.classHighlighter), 811 EditorView.updateListener.of(v => { 812 if (!cm.isDocumentLoadComplete) { 813 // Check that the full syntax tree is available the current viewport 814 if (syntaxTreeAvailable(v.state, v.view.viewState.viewport.to)) { 815 cm.isDocumentLoadComplete = true; 816 } 817 } 818 if (v.viewportChanged || v.docChanged) { 819 if (v.docChanged) { 820 cm.isDocumentLoadComplete = false; 821 } 822 // reset line gutter markers for the new visible ranges 823 // when the viewport changes(e.g when the page is scrolled). 824 if (this.#lineGutterMarkers.size > 0) { 825 this.setLineGutterMarkers(); 826 } 827 } 828 // Any custom defined update listener should be called 829 if (typeof this.#updateListener == "function") { 830 this.#updateListener(v); 831 } 832 }), 833 this.#compartments.domEventHandlersCompartment.of( 834 EditorView.domEventHandlers(this.#createEventHandlers()) 835 ), 836 this.#compartments.lineNumberMarkersCompartment.of([]), 837 lineContentMarkerExtension, 838 positionContentMarkerExtension, 839 this.#compartments.searchHighlightCompartment.of( 840 this.#searchHighlighterExtension([]) 841 ), 842 this.#compartments.languageCompartment.of(languageMode), 843 highlightSelectionMatches(), 844 // keep last so other extension take precedence 845 codemirror.minimalSetup, 846 ]; 847 848 if (!this.config.disableSearchAddon && this.config.useSearchAddonPanel) { 849 this.config.keyMap = this.config.keyMap 850 ? [...this.config.keyMap, ...searchKeymap] 851 : [...searchKeymap]; 852 extensions.push(search({ top: true })); 853 } 854 855 if (this.config.placeholder) { 856 extensions.push(placeholder(this.config.placeholder)); 857 } 858 859 if (this.config.keyMap) { 860 extensions.push(Prec.highest(keymap.of(this.config.keyMap))); 861 } 862 863 if (Services.prefs.prefHasUserValue(CARET_BLINK_TIME)) { 864 // We need to multiply the preference value by 2 to match Firefox cursor rate 865 const cursorBlinkRate = Services.prefs.getIntPref(CARET_BLINK_TIME) * 2; 866 extensions.push( 867 drawSelection({ 868 cursorBlinkRate, 869 }) 870 ); 871 } 872 873 const cm = new EditorView({ 874 parent: el, 875 extensions, 876 }); 877 878 cm.isDocumentLoadComplete = false; 879 editors.set(this, cm); 880 881 // For now, we only need to pipe the blur event 882 cm.contentDOM.addEventListener("blur", e => this.emit("blur", e), { 883 signal: this.#abortController?.signal, 884 }); 885 } 886 887 /** 888 * This creates the extension which handles marking of lines within the editor. 889 * 890 * @returns {object} The object contains an extension and effects which used to trigger updates to the extension 891 * {Object} - lineContentMarkerExtension - The line content marker extension 892 * {Object} - lineContentMarkerEffect - The effects to add and remove markers 893 */ 894 #createlineContentMarkersExtension() { 895 const { 896 codemirrorView: { Decoration, WidgetType, EditorView }, 897 codemirrorState: { StateField, StateEffect }, 898 } = this.#CodeMirror6; 899 900 const lineContentMarkers = this.#lineContentMarkers; 901 902 class LineContentWidget extends WidgetType { 903 constructor(line, value, markerId, createElementNode) { 904 super(); 905 this.line = line; 906 this.value = value; 907 this.markerId = markerId; 908 this.createElementNode = createElementNode; 909 } 910 911 toDOM() { 912 return this.createElementNode(this.line, this.value); 913 } 914 915 eq(widget) { 916 return ( 917 widget.line == this.line && 918 widget.markerId == this.markerId && 919 widget.value == this.value 920 ); 921 } 922 } 923 924 /** 925 * Uses the marker and current decoration list to create a new decoration list 926 * 927 * @param {object} marker - The marker to be used to create the new decoration 928 * @param {Transaction} transaction - The transaction object 929 * @param {Array} newMarkerDecorations - List of the new marker decorations being built 930 */ 931 function _buildDecorationsForMarker( 932 marker, 933 transaction, 934 newMarkerDecorations 935 ) { 936 const vStartLine = transaction.state.doc.lineAt( 937 marker._view.viewport.from 938 ); 939 const vEndLine = transaction.state.doc.lineAt(marker._view.viewport.to); 940 941 let decorationLines; 942 if (marker.shouldMarkAllLines) { 943 decorationLines = []; 944 for (let i = vStartLine.number; i <= vEndLine.number; i++) { 945 decorationLines.push({ line: i }); 946 } 947 } else { 948 decorationLines = marker.lines; 949 } 950 951 for (const { line, value } of decorationLines) { 952 // Make sure the position is within the viewport 953 if (line < vStartLine.number || line > vEndLine.number) { 954 continue; 955 } 956 957 const lo = transaction.state.doc.line(line); 958 if (marker.lineClassName) { 959 // Markers used: 960 // 1) blackboxed-line-marker 961 // 2) multi-highlight-line-marker 962 // 3) highlight-line-marker 963 // 4) line-exception-marker 964 // 5) debug-line-marker 965 const classDecoration = Decoration.line({ 966 class: marker.lineClassName, 967 }); 968 classDecoration.markerType = marker.id; 969 newMarkerDecorations.push(classDecoration.range(lo.from)); 970 } else if (marker.createLineElementNode) { 971 // Markers used: 972 // 1) conditional-breakpoint-panel-marker 973 // 2) inline-preview-marker 974 const nodeDecoration = Decoration.widget({ 975 widget: new LineContentWidget( 976 line, 977 value, 978 marker.id, 979 marker.createLineElementNode 980 ), 981 // Render the widget after the cursor 982 side: 1, 983 block: !!marker.renderAsBlock, 984 }); 985 nodeDecoration.markerType = marker.id; 986 newMarkerDecorations.push(nodeDecoration.range(lo.to)); 987 } 988 } 989 } 990 991 /** 992 * This updates the decorations for the marker specified 993 * 994 * @param {Array} markerDecorations - The current decorations displayed in the document 995 * @param {Array} marker - The current marker whose decoration should be update 996 * @param {Transaction} transaction 997 * @returns 998 */ 999 function updateDecorations(markerDecorations, marker, transaction) { 1000 const newDecorations = []; 1001 _buildDecorationsForMarker(marker, transaction, newDecorations); 1002 1003 return markerDecorations.update({ 1004 // Filter out old decorations for the specified marker 1005 filter: (from, to, decoration) => { 1006 return decoration.markerType !== marker.id; 1007 }, 1008 add: newDecorations, 1009 sort: true, 1010 }); 1011 } 1012 1013 /** 1014 * This updates all the decorations for all the markers. This 1015 * used in scenarios when an update to view (e.g vertically scrolling into a new viewport) 1016 * requires all the marker decoraions. 1017 * 1018 * @param {Array} markerDecorations - The current decorations displayed in the document 1019 * @param {Array} allMarkers - All the cached markers 1020 * @param {object} transaction 1021 * @returns 1022 */ 1023 function updateDecorationsForAllMarkers( 1024 markerDecorations, 1025 allMarkers, 1026 transaction 1027 ) { 1028 const allNewDecorations = []; 1029 1030 for (const marker of allMarkers) { 1031 _buildDecorationsForMarker(marker, transaction, allNewDecorations); 1032 } 1033 1034 return markerDecorations.update({ 1035 // This filters out all the old decorations 1036 filter: () => false, 1037 add: allNewDecorations, 1038 sort: true, 1039 }); 1040 } 1041 1042 function removeDecorations(markerDecorations, markerId) { 1043 return markerDecorations.update({ 1044 filter: (from, to, decoration) => { 1045 return decoration.markerType !== markerId; 1046 }, 1047 }); 1048 } 1049 1050 // The effects used to create the transaction when markers are 1051 // either added and removed. 1052 const addEffect = StateEffect.define(); 1053 const removeEffect = StateEffect.define(); 1054 1055 const lineContentMarkerExtension = StateField.define({ 1056 create() { 1057 return Decoration.none; 1058 }, 1059 update(markerDecorations, transaction) { 1060 // Map the decorations through the transaction changes, this is important 1061 // as it remaps the decorations from positions in the old document to 1062 // positions in the new document. 1063 markerDecorations = markerDecorations.map(transaction.changes); 1064 for (const effect of transaction.effects) { 1065 // When a new marker is added 1066 if (effect.is(addEffect)) { 1067 markerDecorations = updateDecorations( 1068 markerDecorations, 1069 effect.value, 1070 transaction 1071 ); 1072 } else if (effect.is(removeEffect)) { 1073 // when a marker is removed 1074 markerDecorations = removeDecorations( 1075 markerDecorations, 1076 effect.value 1077 ); 1078 } else { 1079 const cachedMarkers = lineContentMarkers.values(); 1080 // For updates that are not related to this marker decoration, 1081 // we want to update the decorations when the editor is scrolled 1082 // and a new viewport is loaded. 1083 markerDecorations = updateDecorationsForAllMarkers( 1084 markerDecorations, 1085 cachedMarkers, 1086 transaction 1087 ); 1088 } 1089 } 1090 return markerDecorations; 1091 }, 1092 provide: field => EditorView.decorations.from(field), 1093 }); 1094 1095 return { 1096 lineContentMarkerExtension, 1097 lineContentMarkerEffect: { addEffect, removeEffect }, 1098 }; 1099 } 1100 1101 #createEventHandlers() { 1102 const eventHandlers = {}; 1103 for (const eventName in this.#editorDOMEventHandlers) { 1104 const handlers = this.#editorDOMEventHandlers[eventName]; 1105 eventHandlers[eventName] = (event, editor) => { 1106 if (!event.target) { 1107 return; 1108 } 1109 for (const handler of handlers) { 1110 // Wait a cycle so the codemirror updates to the current cursor position, 1111 // information, TODO: Currently noticed this issue with CM6, not ideal but should 1112 // investigate further Bug 1890895. 1113 event.target.ownerGlobal.setTimeout(() => { 1114 const view = editor.viewState; 1115 const cursorPos = lezerUtils.positionToLocation( 1116 view.state.doc, 1117 view.state.selection.main.head 1118 ); 1119 handler(event, view, cursorPos.line, cursorPos.column); 1120 }, 0); 1121 } 1122 }; 1123 } 1124 return eventHandlers; 1125 } 1126 1127 /** 1128 * Adds the DOM event handlers for the editor. 1129 * 1130 * @param {object} domEventHandlers - A dictionary of handlers for the DOM events 1131 * the handlers are getting called with the following arguments 1132 * - {Object} `event`: The DOM event 1133 * - {Object} `view`: The codemirror view 1134 * - {Number} cursorLine`: The line where the cursor is currently position 1135 * - {Number} `cursorColumn`: The column where the cursor is currently position 1136 * - {Number} `eventLine`: The line where the event was fired. 1137 * This might be different from the cursor line for mouse events. 1138 * - {Number} `eventColumn`: The column where the event was fired. 1139 * This might be different from the cursor column for mouse events. 1140 */ 1141 addEditorDOMEventListeners(domEventHandlers) { 1142 const cm = editors.get(this); 1143 const { 1144 codemirrorView: { EditorView }, 1145 } = this.#CodeMirror6; 1146 1147 // Update the cache of dom event handlers 1148 for (const eventName in domEventHandlers) { 1149 if (!this.#editorDOMEventHandlers[eventName]) { 1150 this.#editorDOMEventHandlers[eventName] = []; 1151 } 1152 this.#editorDOMEventHandlers[eventName].push(domEventHandlers[eventName]); 1153 } 1154 1155 cm.dispatch({ 1156 effects: this.#compartments.domEventHandlersCompartment.reconfigure( 1157 EditorView.domEventHandlers(this.#createEventHandlers()) 1158 ), 1159 }); 1160 } 1161 1162 #cacheScrollSnapshot = () => { 1163 const cm = editors.get(this); 1164 if (!this.#currentDocumentId) { 1165 return; 1166 } 1167 this.#scrollSnapshots.set(this.#currentDocumentId, cm.scrollSnapshot()); 1168 this.emitForTests("cm-editor-scrolled"); 1169 }; 1170 1171 /** 1172 * Remove specified DOM event handlers for the editor. 1173 * 1174 * @param {object} domEventHandlers - A dictionary of handlers for the DOM events 1175 */ 1176 removeEditorDOMEventListeners(domEventHandlers) { 1177 const cm = editors.get(this); 1178 const { 1179 codemirrorView: { EditorView }, 1180 } = this.#CodeMirror6; 1181 1182 for (const eventName in domEventHandlers) { 1183 const domEventHandler = domEventHandlers[eventName]; 1184 const cachedEventHandlers = this.#editorDOMEventHandlers[eventName]; 1185 if (!domEventHandler || !cachedEventHandlers) { 1186 continue; 1187 } 1188 const index = cachedEventHandlers.findIndex( 1189 handler => handler == domEventHandler 1190 ); 1191 this.#editorDOMEventHandlers[eventName].splice(index, 1); 1192 } 1193 1194 cm.dispatch({ 1195 effects: this.#compartments.domEventHandlersCompartment.reconfigure( 1196 EditorView.domEventHandlers(this.#createEventHandlers()) 1197 ), 1198 }); 1199 } 1200 1201 /** 1202 * Clear the DOM event handlers for the editor. 1203 */ 1204 #clearEditorDOMEventListeners() { 1205 const cm = editors.get(this); 1206 const { 1207 codemirrorView: { EditorView }, 1208 } = this.#CodeMirror6; 1209 1210 this.#editorDOMEventHandlers = {}; 1211 this.#gutterDOMEventHandlers = {}; 1212 cm.dispatch({ 1213 effects: this.#compartments.domEventHandlersCompartment.reconfigure( 1214 EditorView.domEventHandlers({}) 1215 ), 1216 }); 1217 } 1218 1219 /** 1220 * This adds a marker used to add classes to editor line based on a condition. 1221 * 1222 * @property {object} marker 1223 * The rule rendering a marker or class. 1224 * @property {object} marker.id 1225 * The unique identifier for this marker 1226 * @property {string} marker.lineClassName 1227 * The css class to apply to the line 1228 * @property {Array<object>} marker.lines 1229 * The lines to add markers to. Each line object has a `line` and `value` property. 1230 * @property {boolean} marker.renderAsBlock 1231 * The specifies that the widget should be rendered as a block element. defaults to `false`. This is optional. 1232 * @property {boolean} marker.shouldMarkAllLines 1233 * Set to true to apply the marker to all the lines. In such case, `positions` is ignored. This is optional. 1234 * @property {Function} marker.createLineElementNode 1235 * This should return the DOM element which is used for the marker. The line number is passed as a parameter. 1236 * This is optional. 1237 */ 1238 setLineContentMarker(marker) { 1239 const cm = editors.get(this); 1240 // We store the marker an the view state, this is gives access to view data 1241 // when defining updates to the StateField. 1242 marker._view = cm; 1243 this.#lineContentMarkers.set(marker.id, marker); 1244 cm.dispatch({ 1245 effects: this.#effects.lineContentMarkerEffect.addEffect.of(marker), 1246 }); 1247 } 1248 1249 /** 1250 * This removes the marker which has the specified className 1251 * 1252 * @param {string} markerId - The unique identifier for this marker 1253 */ 1254 removeLineContentMarker(markerId) { 1255 const cm = editors.get(this); 1256 this.#lineContentMarkers.delete(markerId); 1257 cm.dispatch({ 1258 effects: this.#effects.lineContentMarkerEffect.removeEffect.of(markerId), 1259 }); 1260 } 1261 1262 /** 1263 * This creates the extension used to manage the rendering of markers 1264 * at specific positions with the editor. e.g used for column breakpoints 1265 * 1266 * @returns {object} The object contains an extension and effects which used to trigger updates to the extension 1267 * {Object} - positionContentMarkerExtension - The position content marker extension 1268 * {Object} - positionContentMarkerEffect - The effects to add and remove markers 1269 */ 1270 #createPositionContentMarkersExtension() { 1271 const { 1272 codemirrorView: { Decoration, EditorView, WidgetType }, 1273 codemirrorState: { StateField, StateEffect }, 1274 codemirrorLanguage: { syntaxTree }, 1275 } = this.#CodeMirror6; 1276 1277 const cachedPositionContentMarkers = this.#posContentMarkers; 1278 1279 class NodeWidget extends WidgetType { 1280 constructor({ 1281 line, 1282 column, 1283 isFirstNonSpaceColumn, 1284 positionData, 1285 markerId, 1286 createElementNode, 1287 customEq, 1288 }) { 1289 super(); 1290 this.line = line; 1291 this.column = column; 1292 this.isFirstNonSpaceColumn = isFirstNonSpaceColumn; 1293 this.positionData = positionData; 1294 this.markerId = markerId; 1295 this.customEq = customEq; 1296 this.toDOM = () => 1297 createElementNode(line, column, isFirstNonSpaceColumn, positionData); 1298 } 1299 1300 eq(widget) { 1301 let eq = 1302 this.line == widget.line && 1303 this.column == widget.column && 1304 this.markerId == widget.markerId; 1305 if (this.positionData && this.customEq) { 1306 eq = eq && this.customEq(this.positionData, widget.positionData); 1307 } 1308 return eq; 1309 } 1310 } 1311 1312 function getIndentation(lineText) { 1313 if (!lineText) { 1314 return 0; 1315 } 1316 1317 const lineMatch = lineText.match(/^\s*/); 1318 if (!lineMatch) { 1319 return 0; 1320 } 1321 return lineMatch[0].length; 1322 } 1323 1324 function _buildDecorationsForPositionMarkers( 1325 marker, 1326 transaction, 1327 newMarkerDecorations 1328 ) { 1329 const viewport = marker._view.viewport; 1330 const vStartLine = transaction.state.doc.lineAt(viewport.from); 1331 const vEndLine = transaction.state.doc.lineAt(viewport.to); 1332 1333 for (const position of marker.positions) { 1334 // If codemirror positions are provided (e.g from search cursor) 1335 // compare that directly. 1336 if (position.from && position.to) { 1337 if (position.from >= viewport.from && position.to <= viewport.to) { 1338 if (marker.positionClassName) { 1339 // Markers used: 1340 // 1. active-selection-marker 1341 const classDecoration = Decoration.mark({ 1342 class: marker.positionClassName, 1343 }); 1344 classDecoration.markerType = marker.id; 1345 newMarkerDecorations.push( 1346 classDecoration.range(position.from, position.to) 1347 ); 1348 } 1349 } 1350 continue; 1351 } 1352 // If line and column are provided 1353 if ( 1354 position.line >= vStartLine.number && 1355 position.line <= vEndLine.number 1356 ) { 1357 const line = transaction.state.doc.line(position.line); 1358 // Make sure to track any indentation at the beginning of the line 1359 const column = Math.max(position.column, getIndentation(line.text)); 1360 const pos = line.from + column; 1361 1362 if (marker.createPositionElementNode) { 1363 // Markers used: 1364 // 1. column-breakpoint-marker 1365 const isFirstNonSpaceColumn = ONLY_SPACES_REGEXP.test( 1366 line.text.substr(0, column) 1367 ); 1368 const nodeDecoration = Decoration.widget({ 1369 widget: new NodeWidget({ 1370 line: position.line, 1371 column: position.column, 1372 isFirstNonSpaceColumn, 1373 positionData: position.positionData, 1374 markerId: marker.id, 1375 createElementNode: marker.createPositionElementNode, 1376 customEq: marker.customEq, 1377 }), 1378 // Make sure the widget is rendered after the cursor 1379 // see https://codemirror.net/docs/ref/#view.Decoration^widget^spec.side for details. 1380 side: 1, 1381 }); 1382 nodeDecoration.markerType = marker.id; 1383 newMarkerDecorations.push(nodeDecoration.range(pos, pos)); 1384 } 1385 if (marker.positionClassName) { 1386 // Markers used: 1387 // 1. exception-position-marker 1388 // 2. debug-position-marker 1389 const tokenAtPos = syntaxTree(transaction.state).resolve(pos, 1); 1390 // While trying to update the markers, during content changes, the syntax tree is not 1391 // guaranteed to be complete, so there is the possibility of getting wrong `from` and `to` values for the token. 1392 // To make sure we are handling a valid token, let's check that the `from` value (which is the start position of the retrieved token) 1393 // matches the position we want. 1394 if (tokenAtPos.from !== pos) { 1395 continue; 1396 } 1397 const tokenString = line.text.slice( 1398 position.column, 1399 tokenAtPos.to - line.from 1400 ); 1401 // Ignore any empty strings and opening braces 1402 if ( 1403 tokenString === "" || 1404 tokenString === "{" || 1405 tokenString === "[" 1406 ) { 1407 continue; 1408 } 1409 const classDecoration = Decoration.mark({ 1410 class: marker.positionClassName, 1411 }); 1412 classDecoration.markerType = marker.id; 1413 newMarkerDecorations.push( 1414 classDecoration.range(pos, tokenAtPos.to) 1415 ); 1416 } 1417 } 1418 } 1419 } 1420 1421 /** 1422 * This updates the decorations for the marker specified 1423 * 1424 * @param {Array} markerDecorations - The current decorations displayed in the document 1425 * @param {Array} marker - The current marker whose decoration should be update 1426 * @param {Transaction} transaction 1427 * @returns 1428 */ 1429 function updateDecorations(markerDecorations, marker, transaction) { 1430 const newDecorations = []; 1431 1432 _buildDecorationsForPositionMarkers(marker, transaction, newDecorations); 1433 return markerDecorations.update({ 1434 filter: (from, to, decoration) => { 1435 return decoration.markerType !== marker.id; 1436 }, 1437 add: newDecorations, 1438 sort: true, 1439 }); 1440 } 1441 1442 /** 1443 * This updates all the decorations for all the markers. This 1444 * used in scenarios when an update to view (e.g vertically scrolling into a new viewport) 1445 * requires all the marker decoraions. 1446 * 1447 * @param {Array} markerDecorations - The current decorations displayed in the document 1448 * @param {Array} markers - All the cached markers 1449 * @param {object} transaction 1450 * @returns 1451 */ 1452 function updateDecorationsForAllMarkers( 1453 markerDecorations, 1454 markers, 1455 transaction 1456 ) { 1457 const allNewDecorations = []; 1458 1459 // Sort the markers iterator thanks to `displayLast` boolean. 1460 // This is typically used by the paused location marker to be shown after the column breakpoints. 1461 markers = Array.from(markers).sort((a, b) => { 1462 if (a.displayLast) { 1463 return 1; 1464 } 1465 if (b.displayLast) { 1466 return -1; 1467 } 1468 return 0; 1469 }); 1470 1471 for (const marker of markers) { 1472 _buildDecorationsForPositionMarkers( 1473 marker, 1474 transaction, 1475 allNewDecorations 1476 ); 1477 } 1478 return markerDecorations.update({ 1479 filter: () => false, 1480 add: allNewDecorations, 1481 sort: true, 1482 }); 1483 } 1484 1485 function removeDecorations(markerDecorations, markerId) { 1486 return markerDecorations.update({ 1487 filter: (from, to, decoration) => { 1488 return decoration.markerType !== markerId; 1489 }, 1490 }); 1491 } 1492 1493 const addEffect = StateEffect.define(); 1494 const removeEffect = StateEffect.define(); 1495 1496 const positionContentMarkerExtension = StateField.define({ 1497 create() { 1498 return Decoration.none; 1499 }, 1500 update(markerDecorations, transaction) { 1501 // Map the decorations through the transaction changes, this is important 1502 // as it remaps the decorations from positions in the old document to 1503 // positions in the new document. 1504 markerDecorations = markerDecorations.map(transaction.changes); 1505 for (const effect of transaction.effects) { 1506 if (effect.is(addEffect)) { 1507 // When a new marker is added 1508 markerDecorations = updateDecorations( 1509 markerDecorations, 1510 effect.value, 1511 transaction 1512 ); 1513 } else if (effect.is(removeEffect)) { 1514 // When a marker is removed 1515 markerDecorations = removeDecorations( 1516 markerDecorations, 1517 effect.value 1518 ); 1519 } else { 1520 // For updates that are not related to this marker decoration, 1521 // we want to update the decorations when the editor is scrolled 1522 // and a new viewport is loaded. 1523 markerDecorations = updateDecorationsForAllMarkers( 1524 markerDecorations, 1525 cachedPositionContentMarkers.values(), 1526 transaction 1527 ); 1528 } 1529 } 1530 return markerDecorations; 1531 }, 1532 provide: field => EditorView.decorations.from(field), 1533 }); 1534 1535 return { 1536 positionContentMarkerExtension, 1537 positionContentMarkerEffect: { addEffect, removeEffect }, 1538 }; 1539 } 1540 1541 /** 1542 * This adds a marker used to decorate token / content at a specific position . 1543 * 1544 * @param {object} marker 1545 * @param {string} marker.id 1546 * @param {Array<object>} marker.positions - This includes the line / column and any optional positionData which defines each position. 1547 * @param {Function} marker.createPositionElementNode - This describes how to render the marker. 1548 * The position data (i.e line, column and positionData) are passed as arguments. 1549 * @param {Function} marker.customEq - A custom function to determine the equality of the marker. This allows the user define special conditions 1550 * for when position details have changed and an update of the marker should happen. 1551 * The positionData defined for the current and the previous instance of the marker are passed as arguments. 1552 */ 1553 setPositionContentMarker(marker) { 1554 const cm = editors.get(this); 1555 1556 // We store the marker an the view state, this is gives access to viewport data 1557 // when defining updates to the StateField. 1558 marker._view = cm; 1559 this.#posContentMarkers.set(marker.id, marker); 1560 cm.dispatch({ 1561 effects: this.#effects.positionContentMarkerEffect.addEffect.of(marker), 1562 }); 1563 } 1564 1565 /** 1566 * This removes the marker which has the specified id 1567 * 1568 * @param {string} markerId - The unique identifier for this marker 1569 */ 1570 removePositionContentMarker(markerId) { 1571 const cm = editors.get(this); 1572 this.#posContentMarkers.delete(markerId); 1573 cm.dispatch({ 1574 effects: 1575 this.#effects.positionContentMarkerEffect.removeEffect.of(markerId), 1576 }); 1577 } 1578 1579 /** 1580 * Set event listeners for the line gutter 1581 * 1582 * @param {object} domEventHandlers 1583 * 1584 * example usage: 1585 * const domEventHandlers = { click(event) { console.log(event);} } 1586 */ 1587 setGutterEventListeners(domEventHandlers) { 1588 const cm = editors.get(this); 1589 const { 1590 codemirrorView: { lineNumbers }, 1591 codemirrorLanguage: { foldGutter }, 1592 } = this.#CodeMirror6; 1593 1594 for (const eventName in domEventHandlers) { 1595 const handler = domEventHandlers[eventName]; 1596 this.#gutterDOMEventHandlers[eventName] = (view, line, event) => { 1597 line = view.state.doc.lineAt(line.from); 1598 handler(event, view, line.number); 1599 }; 1600 } 1601 1602 cm.dispatch({ 1603 effects: [ 1604 this.#compartments.lineNumberCompartment.reconfigure( 1605 lineNumbers({ domEventHandlers: this.#gutterDOMEventHandlers }) 1606 ), 1607 this.#compartments.foldGutterCompartment.reconfigure( 1608 foldGutter({ 1609 class: "cm6-dt-foldgutter", 1610 markerDOM: open => { 1611 if (!this.#ownerDoc) { 1612 return null; 1613 } 1614 const button = this.#ownerDoc.createElement("button"); 1615 button.classList.add("cm6-dt-foldgutter__toggle-button"); 1616 button.setAttribute("aria-expanded", open); 1617 return button; 1618 }, 1619 domEventHandlers: this.#gutterDOMEventHandlers, 1620 }) 1621 ), 1622 ], 1623 }); 1624 } 1625 1626 /** 1627 * This supports adding/removing of line classes or markers on the 1628 * line number gutter based on the defined conditions. This only supports codemirror 6. 1629 * 1630 * @param {Array<Marker>} markers - The list of marker objects which defines the rules 1631 * for rendering each marker. 1632 * @property {object} marker - The rule rendering a marker or class. This is required. 1633 * @property {string} marker.id - The unique identifier for this marker. 1634 * @property {string} marker.lineClassName - The css class to add to the line. This is required. 1635 * @property {function} marker.condition - The condition that decides if the marker/class gets added or removed. 1636 * This should return `false` for lines where the marker should not be added and the 1637 * result of the condition for any other line. 1638 * @property {Function=} marker.createLineElementNode - This gets the line and the result of the condition as arguments and should return the DOM element which 1639 * is used for the marker. This is optional. 1640 */ 1641 setLineGutterMarkers(markers) { 1642 const cm = editors.get(this); 1643 1644 if (markers) { 1645 // Cache the markers for use later. See next comment 1646 for (const marker of markers) { 1647 if (!marker.id) { 1648 throw new Error("Marker has no unique identifier"); 1649 } 1650 this.#lineGutterMarkers.set(marker.id, marker); 1651 } 1652 } 1653 // When no markers are passed, the cached markers are used to update the line gutters. 1654 // This is useful for re-rendering the line gutters when the viewport changes 1655 // (note: the visible ranges will be different) in this case, mainly when the editor is scrolled. 1656 else if (!this.#lineGutterMarkers.size) { 1657 return; 1658 } 1659 markers = Array.from(this.#lineGutterMarkers.values()); 1660 1661 const { 1662 codemirrorView: { lineNumberMarkers, GutterMarker }, 1663 codemirrorState: { RangeSetBuilder }, 1664 } = this.#CodeMirror6; 1665 1666 // This creates a new GutterMarker https://codemirror.net/docs/ref/#view.GutterMarker 1667 // to represents how each line gutter is rendered in the view. 1668 // This is set as the value for the Range https://codemirror.net/docs/ref/#state.Range 1669 // which represents the line. 1670 class LineGutterMarker extends GutterMarker { 1671 constructor(className, lineNumber, createElementNode, conditionResult) { 1672 super(); 1673 this.elementClass = className || null; 1674 this.lineNumber = lineNumber; 1675 this.createElementNode = createElementNode; 1676 this.conditionResult = conditionResult; 1677 1678 this.toDOM = createElementNode 1679 ? () => createElementNode(lineNumber, conditionResult) 1680 : null; 1681 } 1682 1683 eq(marker) { 1684 return ( 1685 marker.lineNumber == this.lineNumber && 1686 marker.conditionResult == this.conditionResult 1687 ); 1688 } 1689 } 1690 1691 // Loop through the visible ranges https://codemirror.net/docs/ref/#view.EditorView.visibleRanges 1692 // (representing the lines in the current viewport) and generate a new rangeset for updating the line gutter 1693 // based on the conditions defined in the markers(for each line) provided. 1694 const builder = new RangeSetBuilder(); 1695 const { from, to } = cm.viewport; 1696 let pos = from; 1697 while (pos <= to) { 1698 const line = cm.state.doc.lineAt(pos); 1699 for (const { 1700 lineClassName, 1701 condition, 1702 createLineElementNode, 1703 } of markers) { 1704 if (typeof condition !== "function") { 1705 throw new Error("The `condition` is not a valid function"); 1706 } 1707 const conditionResult = condition(line.number); 1708 if (conditionResult !== false) { 1709 builder.add( 1710 line.from, 1711 line.to, 1712 new LineGutterMarker( 1713 lineClassName, 1714 line.number, 1715 createLineElementNode, 1716 conditionResult 1717 ) 1718 ); 1719 } 1720 } 1721 pos = line.to + 1; 1722 } 1723 1724 // To update the state with the newly generated marker range set, a dispatch is called on the view 1725 // with an transaction effect created by the lineNumberMarkersCompartment, which is used to update the 1726 // lineNumberMarkers extension configuration. 1727 cm.dispatch({ 1728 effects: this.#compartments.lineNumberMarkersCompartment.reconfigure( 1729 lineNumberMarkers.of(builder.finish()) 1730 ), 1731 }); 1732 } 1733 1734 /** 1735 * This creates the extension used to manage the rendering of markers for 1736 * results for any search pattern 1737 * 1738 * @param {RegExp} pattern - The search pattern 1739 * @param {string} className - The class used to decorate each result 1740 * @returns {Array<ViewPlugin>} An extension which is an array containing the view 1741 * which manages the rendering of the line content markers. 1742 */ 1743 #searchHighlighterExtension({ 1744 /* This defaults to matching nothing */ pattern = /.^/g, 1745 className = "", 1746 }) { 1747 const cm = editors.get(this); 1748 if (!cm) { 1749 return []; 1750 } 1751 const { 1752 codemirrorView: { Decoration, ViewPlugin, EditorView, MatchDecorator }, 1753 codemirrorSearch: { RegExpCursor }, 1754 } = this.#CodeMirror6; 1755 1756 this.searchState.query = pattern; 1757 const searchCursor = new RegExpCursor(cm.state.doc, pattern, { 1758 ignoreCase: pattern.ignoreCase, 1759 }); 1760 this.searchState.cursors = Array.from(searchCursor); 1761 this.searchState.currentCursorIndex = -1; 1762 1763 const patternMatcher = new MatchDecorator({ 1764 regexp: pattern, 1765 decorate: (add, from, to) => { 1766 add(from, to, Decoration.mark({ class: className })); 1767 }, 1768 }); 1769 1770 const searchHighlightView = ViewPlugin.fromClass( 1771 class { 1772 decorations; 1773 constructor(view) { 1774 this.decorations = patternMatcher.createDeco(view); 1775 } 1776 update(viewUpdate) { 1777 this.decorations = patternMatcher.updateDeco( 1778 viewUpdate, 1779 this.decorations 1780 ); 1781 } 1782 }, 1783 { 1784 decorations: instance => instance.decorations, 1785 provide: plugin => 1786 EditorView.atomicRanges.of(view => { 1787 return view.plugin(plugin)?.decorations || Decoration.none; 1788 }), 1789 } 1790 ); 1791 1792 return [searchHighlightView]; 1793 } 1794 1795 /** 1796 * This should add the class to the results of a search pattern specified 1797 * 1798 * @param {RegExp} pattern - The search pattern 1799 * @param {string} className - The class used to decorate each result 1800 */ 1801 highlightSearchMatches(pattern, className) { 1802 const cm = editors.get(this); 1803 cm.dispatch({ 1804 effects: this.#compartments.searchHighlightCompartment.reconfigure( 1805 this.#searchHighlighterExtension({ pattern, className }) 1806 ), 1807 }); 1808 } 1809 1810 /** 1811 * This clear any decoration on all the search results 1812 */ 1813 clearSearchMatches() { 1814 this.highlightSearchMatches(undefined, ""); 1815 } 1816 1817 /** 1818 * Retrieves the cursor for the next selection to be highlighted 1819 * 1820 * @param {boolean} reverse - Determines the direction of the cursor movement 1821 * @returns {RegExpSearchCursor} 1822 */ 1823 getNextSearchCursor(reverse) { 1824 if (reverse) { 1825 if (this.searchState.currentCursorIndex == 0) { 1826 this.searchState.currentCursorIndex = 1827 this.searchState.cursors.length - 1; 1828 } else { 1829 this.searchState.currentCursorIndex--; 1830 } 1831 } else if ( 1832 this.searchState.currentCursorIndex == 1833 this.searchState.cursors.length - 1 1834 ) { 1835 this.searchState.currentCursorIndex = 0; 1836 } else { 1837 this.searchState.currentCursorIndex++; 1838 } 1839 return this.searchState.cursors[this.searchState.currentCursorIndex]; 1840 } 1841 1842 /** 1843 * Get the start and end locations of the current viewport 1844 * 1845 * @param {number} offsetHorizontalCharacters - Offset of characters offscreen 1846 * @param {number} offsetVerticalLines - Offset of lines offscreen 1847 * @returns {object} - The location information for the current viewport 1848 */ 1849 getLocationsInViewport( 1850 offsetHorizontalCharacters = 0, 1851 offsetVerticalLines = 0 1852 ) { 1853 if (this.isDestroyed()) { 1854 return null; 1855 } 1856 const cm = editors.get(this); 1857 let startLine, endLine, scrollLeft, charWidth, rightPosition; 1858 if (this.config.cm6) { 1859 // Report no viewport until we show an actual source (and not a loading/error message) 1860 if (!this.#currentDocumentId) { 1861 return null; 1862 } 1863 const { from, to } = cm.viewport; 1864 startLine = cm.state.doc.lineAt(from).number - offsetVerticalLines; 1865 endLine = cm.state.doc.lineAt(to).number + offsetVerticalLines; 1866 scrollLeft = cm.scrollDOM.scrollLeft; 1867 charWidth = cm.defaultCharacterWidth; 1868 rightPosition = scrollLeft + cm.dom.getBoundingClientRect().width; 1869 } else { 1870 if (!cm) { 1871 return null; 1872 } 1873 1874 const scrollArea = cm.getScrollInfo(); 1875 const rect = cm.getWrapperElement().getBoundingClientRect(); 1876 startLine = cm.lineAtHeight(rect.top, "window") - offsetVerticalLines; 1877 endLine = cm.lineAtHeight(rect.bottom, "window") + offsetVerticalLines; 1878 scrollLeft = cm.doc.scrollLeft; 1879 charWidth = cm.defaultCharWidth(); 1880 rightPosition = scrollLeft + (scrollArea.clientWidth - 30); 1881 } 1882 1883 return { 1884 start: { 1885 line: startLine, 1886 column: 1887 scrollLeft > 0 1888 ? Math.floor(scrollLeft / charWidth) - offsetHorizontalCharacters 1889 : 0, 1890 }, 1891 end: { 1892 line: endLine, 1893 column: 1894 Math.floor(rightPosition / charWidth) + offsetHorizontalCharacters, 1895 }, 1896 }; 1897 } 1898 1899 /** 1900 * Gets the position information for the current selection 1901 * 1902 * @returns {object} cursor - The location information for the current selection 1903 * cursor.from - An object with the starting line / column of the selection 1904 * cursor.to - An object with the end line / column of the selection 1905 */ 1906 getSelectionCursor() { 1907 const cm = editors.get(this); 1908 if (this.config.cm6) { 1909 const selection = cm.state.selection.ranges[0]; 1910 const lineFrom = cm.state.doc.lineAt(selection.from); 1911 const lineTo = cm.state.doc.lineAt(selection.to); 1912 return { 1913 from: { 1914 line: lineFrom.number, 1915 ch: selection.from - lineFrom.from, 1916 }, 1917 to: { 1918 line: lineTo.number, 1919 ch: selection.to - lineTo.from, 1920 }, 1921 }; 1922 } 1923 return { 1924 from: cm.getCursor("from"), 1925 to: cm.getCursor("to"), 1926 }; 1927 } 1928 1929 /** 1930 * Gets the text content for the current selection 1931 * 1932 * @returns {string} 1933 */ 1934 getSelectedText() { 1935 const cm = editors.get(this); 1936 if (this.config.cm6) { 1937 const selection = cm.state.selection.ranges[0]; 1938 return cm.state.doc.sliceString(selection.from, selection.to); 1939 } 1940 return cm.getSelection().trim(); 1941 } 1942 1943 /** 1944 * Given screen coordinates this should return the line and column 1945 * related. This used currently to determine the line and columns 1946 * for the tokens that are hovered over. 1947 * 1948 * @param {number} left - Horizontal position from the left 1949 * @param {number} top - Vertical position from the top 1950 * @returns {object} position - The line and column related to the screen coordinates. 1951 */ 1952 getPositionAtScreenCoords(left, top) { 1953 const cm = editors.get(this); 1954 if (this.config.cm6) { 1955 const position = cm.posAtCoords( 1956 { x: left, y: top }, 1957 // "precise", i.e. if a specific position cannot be determined, an estimated one will be used 1958 false 1959 ); 1960 const line = cm.state.doc.lineAt(position); 1961 return { 1962 line: line.number, 1963 column: position - line.from, 1964 }; 1965 } 1966 const { line, ch } = cm.coordsChar( 1967 { left, top }, 1968 // Use the "window" context where the coordinates are relative to the top-left corner 1969 // of the currently visible (scrolled) window. 1970 // This enables codemirror also correctly handle wrappped lines in the editor. 1971 "window" 1972 ); 1973 return { 1974 line: line + 1, 1975 column: ch, 1976 }; 1977 } 1978 1979 /** 1980 * Check that text is selected 1981 * 1982 * @returns {boolean} 1983 */ 1984 isTextSelected() { 1985 const cm = editors.get(this); 1986 if (this.config.cm6) { 1987 const selection = cm.state.selection.ranges[0]; 1988 return selection.from !== selection.to; 1989 } 1990 return cm.somethingSelected(); 1991 } 1992 1993 /** 1994 * Returns a boolean indicating whether the editor is ready to 1995 * use. Use appendTo(el).then(() => {}) for most cases 1996 */ 1997 isAppended() { 1998 return editors.has(this); 1999 } 2000 2001 /** 2002 * Returns the currently active highlighting mode. 2003 * See Editor.modes for the list of all suppoert modes. 2004 */ 2005 getMode() { 2006 return this.getOption("mode"); 2007 } 2008 2009 /** 2010 * Loads a script into editor's containing window. 2011 */ 2012 loadScript(url) { 2013 if (!this.container) { 2014 throw new Error("Can't load a script until the editor is loaded."); 2015 } 2016 const win = this.container.contentWindow.wrappedJSObject; 2017 Services.scriptloader.loadSubScript(url, win); 2018 } 2019 2020 /** 2021 * Creates a CodeMirror Document 2022 * 2023 * @param {string} text: Initial text of the document 2024 * @param {object | string} mode: Mode of the document. See https://codemirror.net/5/doc/manual.html#option_mode 2025 * @returns CodeMirror.Doc 2026 */ 2027 createDocument(text = "", mode) { 2028 return new this.Doc(text, mode); 2029 } 2030 2031 /** 2032 * Replaces the current document with a new source document 2033 */ 2034 replaceDocument(doc) { 2035 const cm = editors.get(this); 2036 cm.swapDoc(doc); 2037 } 2038 2039 /** 2040 * Changes the currently used syntax highlighting mode. 2041 * 2042 * @param {object} mode - Any of the modes from Editor.modes 2043 * @returns 2044 */ 2045 setMode(mode) { 2046 if (this.config.cm6) { 2047 const cm = editors.get(this); 2048 // Fallback to using js syntax highlighting if there is none found 2049 const languageMode = this.#languageModes.has(mode) 2050 ? this.#languageModes.get(mode) 2051 : this.#languageModes.get(Editor.modes.javascript); 2052 2053 return cm.dispatch({ 2054 effects: this.#compartments.languageCompartment.reconfigure([ 2055 languageMode, 2056 ]), 2057 }); 2058 } 2059 this.setOption("mode", mode); 2060 2061 // If autocomplete was set up and the mode is changing, then 2062 // turn it off and back on again so the proper mode can be used. 2063 if (this.config.autocomplete) { 2064 this.setOption("autocomplete", false); 2065 this.setOption("autocomplete", true); 2066 } 2067 return null; 2068 } 2069 2070 /** 2071 * The source editor can expose several commands linked from system and context menus. 2072 * Kept for backward compatibility with styleeditor. 2073 */ 2074 insertCommandsController() { 2075 const { 2076 insertCommandsController, 2077 } = require("resource://devtools/client/shared/sourceeditor/editor-commands-controller.js"); 2078 insertCommandsController(this); 2079 } 2080 2081 /** 2082 * Returns text from the text area. If line argument is provided 2083 * the method returns only that line. 2084 */ 2085 getText(line) { 2086 const cm = editors.get(this); 2087 2088 if (line == null) { 2089 return this.config.cm6 ? cm.state.doc.toString() : cm.getValue(); 2090 } 2091 2092 const info = this.lineInfo(line); 2093 return info ? info.text : ""; 2094 } 2095 2096 getDoc() { 2097 if (!this.config) { 2098 return null; 2099 } 2100 const cm = editors.get(this); 2101 if (this.config.cm6) { 2102 if (!this.#currentDocument) { 2103 // A key for caching the WASM content in the WeakMap 2104 this.#currentDocument = { id: this.#currentDocumentId }; 2105 } 2106 return this.#currentDocument; 2107 } 2108 return cm.getDoc(); 2109 } 2110 2111 get isWasm() { 2112 return wasm.isWasm(this.getDoc()); 2113 } 2114 2115 getWasmLineNumberFormatter() { 2116 return wasm.getWasmLineNumberFormatter(this.getDoc()); 2117 } 2118 2119 wasmOffsetToLine(offset) { 2120 return wasm.wasmOffsetToLine(this.getDoc(), offset); 2121 } 2122 2123 lineToWasmOffset(number) { 2124 return wasm.lineToWasmOffset(this.getDoc(), number); 2125 } 2126 2127 toLineIfWasmOffset(maybeOffset) { 2128 if (typeof maybeOffset !== "number" || !this.isWasm) { 2129 return maybeOffset; 2130 } 2131 return this.wasmOffsetToLine(maybeOffset); 2132 } 2133 2134 renderWasmText(content) { 2135 return wasm.renderWasmText(this.getDoc(), content); 2136 } 2137 2138 /** 2139 * Gets details about the line 2140 * 2141 * @param {number} line 2142 * @returns {object} line info object 2143 */ 2144 lineInfo(line) { 2145 const cm = editors.get(this); 2146 if (this.config.cm6) { 2147 const el = this.getElementAtLine(line); 2148 return { 2149 text: el.innerText, 2150 // TODO: Expose those, or see usage for those and do things differently 2151 line: null, 2152 gutterMarkers: null, 2153 textClass: null, 2154 bgClass: null, 2155 wrapClass: el.className, 2156 widgets: null, 2157 }; 2158 } 2159 2160 return cm.lineInfo(line); 2161 } 2162 2163 /** 2164 * Get the functions symbols for the current source loaded in the 2165 * the editor. 2166 * 2167 * @param {number} maxResults - The maximum no of results to display 2168 */ 2169 async getFunctionSymbols(maxResults) { 2170 const cm = editors.get(this); 2171 const { codemirrorLanguage } = this.#CodeMirror6; 2172 2173 const functionSymbols = []; 2174 let resultsCount = 0; 2175 await lezerUtils.walkTree(cm, codemirrorLanguage, { 2176 filterSet: lezerUtils.nodeTypeSets.functionsDeclAndExpr, 2177 enterVisitor: node => { 2178 if (resultsCount == maxResults) { 2179 return; 2180 } 2181 const syntaxNode = node.node; 2182 const name = lezerUtils.getFunctionName(cm.state.doc, syntaxNode); 2183 // Ignore anonymous functions 2184 if (name == null) { 2185 return; 2186 } 2187 2188 functionSymbols.push({ 2189 name, 2190 klass: lezerUtils.getFunctionClass(cm.state.doc, syntaxNode), 2191 location: { 2192 start: lezerUtils.positionToLocation(cm.state.doc, node.from), 2193 end: lezerUtils.positionToLocation(cm.state.doc, node.to), 2194 }, 2195 parameterNames: lezerUtils.getFunctionParameterNames( 2196 cm.state.doc, 2197 syntaxNode 2198 ), 2199 identifier: null, 2200 index: node.index, 2201 }); 2202 resultsCount++; 2203 }, 2204 forceParseTo: cm.state.doc.length, 2205 }); 2206 2207 return functionSymbols; 2208 } 2209 2210 /** 2211 * Get the class symbols for the current source loaded in the the editor. 2212 * 2213 * @returns 2214 */ 2215 async getClassSymbols() { 2216 const cm = editors.get(this); 2217 const { codemirrorLanguage } = this.#CodeMirror6; 2218 2219 const classSymbols = []; 2220 await lezerUtils.walkTree(cm, codemirrorLanguage, { 2221 filterSet: lezerUtils.nodeTypeSets.classes, 2222 enterVisitor: node => { 2223 const classVarDefNode = node.node.firstChild.nextSibling; 2224 classSymbols.push({ 2225 name: cm.state.doc.sliceString( 2226 classVarDefNode.from, 2227 classVarDefNode.to 2228 ), 2229 location: { 2230 start: lezerUtils.positionToLocation(cm.state.doc, node.from), 2231 end: lezerUtils.positionToLocation(cm.state.doc, node.to), 2232 }, 2233 }); 2234 }, 2235 forceParseTo: cm.state.doc.length, 2236 }); 2237 2238 return classSymbols; 2239 } 2240 2241 /** 2242 * Finds the best function name for the location specified. 2243 * This is used to map original function names to their corresponding 2244 * generated functions. 2245 * 2246 * @param {object} location 2247 * @returns 2248 */ 2249 async getClosestFunctionName(location) { 2250 const cm = editors.get(this); 2251 const { 2252 codemirrorLangJavascript: { javascriptLanguage }, 2253 codemirrorLanguage: { forceParsing, syntaxTree }, 2254 } = this.#CodeMirror6; 2255 2256 let doc, tree; 2257 // If the specified source is already loaded in the editor, 2258 // codemirror has likely parsed most or all the source needed, 2259 // just leverage that 2260 const sourceId = location.source.id; 2261 if (this.#currentDocumentId === sourceId) { 2262 doc = cm.state.doc; 2263 // Parse the rest of the if needed. 2264 await forceParsing(cm, doc.length, 10000); 2265 2266 tree = syntaxTree(cm.state); 2267 } else { 2268 // If the source is not currently loaded in the editor we will need 2269 // to explicitly parse its source text. 2270 // Note: The `loadSourceText` actions is called before this util `getClosestFunctionName` 2271 // to make sure source content is available to use. 2272 const sourceContent = this.#sources.get(location.source.id); 2273 if (!sourceContent) { 2274 console.error( 2275 `Can't find source content for ${location.source.id}, no function name can be determined` 2276 ); 2277 return ""; 2278 } 2279 2280 // Create a codemirror document for the current source text. 2281 doc = cm.state.toText(sourceContent); 2282 tree = lezerUtils.getTree(javascriptLanguage, sourceId, sourceContent); 2283 } 2284 2285 const token = lezerUtils.getTreeNodeAtLocation(doc, tree, location); 2286 if (!token) { 2287 return null; 2288 } 2289 2290 const enclosingScope = lezerUtils.getEnclosingFunction(doc, token); 2291 return enclosingScope ? enclosingScope.funcName : ""; 2292 } 2293 2294 /** 2295 * Traverse the syntaxTree and return expressions 2296 * which best match the specified token location is on our 2297 * list of accepted symbol types. 2298 * 2299 * @param {object} tokenLocation 2300 * @returns {Array} Member expression matches 2301 */ 2302 async findBestMatchExpressions(tokenLocation) { 2303 const cm = editors.get(this); 2304 const { codemirrorLanguage } = this.#CodeMirror6; 2305 2306 const expressions = []; 2307 2308 const line = cm.state.doc.line(tokenLocation.line); 2309 const tokPos = line.from + tokenLocation.column; 2310 2311 await lezerUtils.walkTree(cm, codemirrorLanguage, { 2312 filterSet: lezerUtils.nodeTypeSets.expressions, 2313 enterVisitor: node => { 2314 if (node.from <= tokPos && node.to >= tokPos) { 2315 expressions.push({ 2316 type: node.name, 2317 // Computed member expressions not currently supported 2318 computed: false, 2319 expression: cm.state.doc.sliceString(node.from, node.to), 2320 location: { 2321 start: lezerUtils.positionToLocation(cm.state.doc, node.from), 2322 end: lezerUtils.positionToLocation(cm.state.doc, node.to), 2323 }, 2324 from: node.from, 2325 to: node.to, 2326 }); 2327 } 2328 }, 2329 walkFrom: line.from, 2330 walkTo: line.to, 2331 }); 2332 2333 // There might be multiple expressions which are within the locations. 2334 // We want to match expressions based on dots before the desired token. 2335 // 2336 // ========================== EXAMPLE 1 ================================ 2337 // Full Expression: `this.myProperty.x` 2338 // Hovered Token: `myProperty` 2339 // Found Expressions: 2340 // { name: "MemberExpression", expression: "this.myProperty.x", from: 1715, to: 1732 } 2341 // { name: "MemberExpression", expression: "this.myProperty" from: 1715, to: 1730 } * 2342 // { name: "PropertyName", expression: "myProperty" from: 1720, to: 1730 } 2343 // 2344 // ========================== EXAMPLE 2 ================================== 2345 // Full Expression: `a(b).catch` 2346 // Hovered Token: `b` 2347 // Found Expressions: 2348 // { name: "MemberExpression", expression: "a(b).catch", from: 1921 to: 1931 } 2349 // { name: "VariableName", expression: "b", from: 1923 to: 1924 } * 2350 // 2351 // We sort based on the `to` make sure we return the correct property 2352 return expressions.sort((a, b) => { 2353 if (a.to < b.to) { 2354 return -1; 2355 } else if (a.to > b.to) { 2356 return 1; 2357 } 2358 return 0; 2359 }); 2360 } 2361 2362 /** 2363 * Get all the lines which are inscope when paused a the specified location. 2364 * 2365 * @param {object} location 2366 * @param {Array} in scope lines 2367 */ 2368 async getInScopeLines(location) { 2369 const cm = editors.get(this); 2370 const { codemirrorLanguage } = this.#CodeMirror6; 2371 2372 const functionLocations = []; 2373 2374 await lezerUtils.walkTree(cm, codemirrorLanguage, { 2375 filterSet: lezerUtils.nodeTypeSets.functions, 2376 enterVisitor: node => { 2377 functionLocations.push({ 2378 name: node.name, 2379 start: lezerUtils.positionToLocation(cm.state.doc, node.from), 2380 end: lezerUtils.positionToLocation(cm.state.doc, node.to), 2381 }); 2382 }, 2383 forceParseTo: cm.viewport.to, 2384 }); 2385 2386 // Sort based on the start locations so the scopes 2387 // are in the same order as in the source. 2388 const sortedLocations = scopeUtils.sortByStart(functionLocations); 2389 2390 // Any function locations which are within the immediate function scope 2391 // of the paused location. 2392 const innerLocations = scopeUtils.getInnerLocations( 2393 sortedLocations, 2394 location 2395 ); 2396 2397 // Any outer locations which do not contain the immediate function 2398 // of the paused location 2399 const outerLocations = sortedLocations.filter(loc => { 2400 if (innerLocations.includes(loc)) { 2401 return false; 2402 } 2403 return !scopeUtils.containsPosition(loc, location); 2404 }); 2405 2406 const outOfScopeLines = scopeUtils.getOutOfScopeLines( 2407 scopeUtils.removeOverlapLocations(outerLocations) 2408 ); 2409 2410 // This operation can be very costly for large files so we sacrifice a bit of readability 2411 // for performance sake. 2412 // We initialize an array with a fixed size and we'll directly assign value for lines 2413 // that are not out of scope. This is much faster than having an empty array and pushing 2414 // into it. 2415 const sourceNumLines = cm.state.doc.lines; 2416 const sourceLines = new Array(sourceNumLines); 2417 for (let i = 0; i < sourceNumLines; i++) { 2418 const line = i + 1; 2419 if (outOfScopeLines.size == 0 || !outOfScopeLines.has(line)) { 2420 sourceLines[i] = line; 2421 } 2422 } 2423 2424 // Finally we need to remove any undefined values, i.e. the ones that were matching 2425 // out of scope lines. 2426 return sourceLines.filter(i => i != undefined); 2427 } 2428 2429 /** 2430 * Gets all the bindings and generates the related references for 2431 * the specified platform scope and its ancestry 2432 * 2433 * @param {object} location - The currently paused location 2434 * @param {object} scope - The innermost scope node for the tree. This is provided by the 2435 * platform. 2436 * @returns {object} Binding references 2437 * Structure 2438 * ========== 2439 * { 2440 * 0: { // Levels 2441 * a: { // Binding 2442 * enumerable: true, 2443 * configurable: false 2444 * value: "foo" 2445 * refs: [{ // References 2446 * start: { line: 1, column: 4 } 2447 * end: { line: 3, column: 5 } 2448 * meta: {...} // For details see https://searchfox.org/mozilla-central/rev/ba7293cb2710f015fcd34f2b9919d00e27a9c2f6/devtools/client/shared/sourceeditor/lezer-utils.js#414-420 2449 * }] 2450 * }, 2451 * ... 2452 * } 2453 */ 2454 async getBindingReferences(location, scope) { 2455 const cm = editors.get(this); 2456 const { 2457 codemirrorLanguage: { syntaxTree }, 2458 } = this.#CodeMirror6; 2459 2460 const token = lezerUtils.getTreeNodeAtLocation( 2461 cm.state.doc, 2462 syntaxTree(cm.state), 2463 location 2464 ); 2465 2466 if (!token) { 2467 return null; 2468 } 2469 2470 let scopeNode = null; 2471 let level = 0; 2472 const bindingReferences = {}; 2473 2474 // Walk up the scope tree and generate the bindings and references 2475 while (scope && scope.bindings) { 2476 const bindings = lezerUtils.getScopeBindings(scope.bindings); 2477 const seen = new Set(); 2478 scopeNode = lezerUtils.getParentScopeOfType( 2479 scopeNode || token, 2480 scope.type 2481 ); 2482 if (!scopeNode) { 2483 break; 2484 } 2485 await lezerUtils.walkCursor(scopeNode.node.cursor(), { 2486 filterSet: lezerUtils.nodeTypeSets.bindingReferences, 2487 enterVisitor: node => { 2488 let bindingName = cm.state.doc.sliceString(node.from, node.to); 2489 if (!(bindingName in bindings) || seen.has(bindingName)) { 2490 return; 2491 } 2492 const bindingData = bindings[bindingName]; 2493 const ref = { 2494 start: lezerUtils.positionToLocation(cm.state.doc, node.from), 2495 end: lezerUtils.positionToLocation(cm.state.doc, node.to), 2496 }; 2497 const syntaxNode = node.node; 2498 // Previews for member expressions are built of the meta property which is 2499 // reference of the child property and so on. e.g a.b.c 2500 if (syntaxNode.parent.name == lezerUtils.nodeTypes.MemberExpression) { 2501 ref.meta = lezerUtils.getMetaBindings( 2502 cm.state.doc, 2503 syntaxNode.parent 2504 ); 2505 // For member expressions use the name of the parent object as the binding name 2506 // i.e for `obj.a.b` the binding name should be `obj` 2507 bindingName = cm.state.doc.sliceString( 2508 syntaxNode.parent.from, 2509 syntaxNode.parent.to 2510 ); 2511 const dotIndex = bindingName.indexOf("."); 2512 if (dotIndex > -1) { 2513 bindingName = bindingName.substring(0, dotIndex); 2514 } 2515 } 2516 2517 if (!bindingReferences[level]) { 2518 bindingReferences[level] = Object.create(null); 2519 } 2520 if (!bindingReferences[level][bindingName]) { 2521 // Put the binding info and related references together for 2522 // easy and efficient access. 2523 bindingReferences[level][bindingName] = { 2524 ...bindingData, 2525 refs: [], 2526 }; 2527 } 2528 bindingReferences[level][bindingName].refs.push(ref); 2529 seen.add(bindingName); 2530 }, 2531 }); 2532 if (scope.type === "function") { 2533 break; 2534 } 2535 level++; 2536 scope = scope.parent; 2537 } 2538 return bindingReferences; 2539 } 2540 2541 /** 2542 * Replaces whatever is in the text area with the contents of 2543 * the 'value' argument. 2544 * 2545 * @param {string} value: The text to replace the editor content 2546 * @param {object} options 2547 * @param {string} options.documentId 2548 * Optional unique id represeting the specific document which is source of the text. 2549 * Will be null for loading and error messages. 2550 * @param {boolean} options.saveTransactionToHistory 2551 * This determines if the transaction for this specific text change should be added to the undo/redo history. 2552 */ 2553 async setText(value, { documentId, saveTransactionToHistory = true } = {}) { 2554 const cm = editors.get(this); 2555 const isWasm = typeof value !== "string" && "binary" in value; 2556 2557 if (documentId) { 2558 this.#currentDocumentId = documentId; 2559 } else { 2560 // Reset this ID when showing loading and error messages, 2561 // so that we keep track when an actual source is displayed 2562 this.#currentDocumentId = null; 2563 } 2564 2565 if (isWasm) { 2566 // wasm? 2567 // binary does not survive as Uint8Array, converting from string 2568 const binary = value.binary; 2569 const data = new Uint8Array(binary.length); 2570 for (let i = 0; i < data.length; i++) { 2571 data[i] = binary.charCodeAt(i); 2572 } 2573 2574 const { lines, done } = wasm.getWasmText(this.getDoc(), data); 2575 const MAX_LINES = 10000000; 2576 if (lines.length > MAX_LINES) { 2577 lines.splice(MAX_LINES, lines.length - MAX_LINES); 2578 lines.push(";; .... text is truncated due to the size"); 2579 } 2580 if (!done) { 2581 lines.push(";; .... possible error during wast conversion"); 2582 } 2583 2584 if (this.config.cm6) { 2585 value = lines.join("\n"); 2586 } else { 2587 // cm will try to split into lines anyway, saving memory 2588 value = { split: () => lines }; 2589 } 2590 } 2591 2592 if (this.config.cm6) { 2593 if (cm.state.doc.toString() == value) { 2594 return; 2595 } 2596 2597 const { 2598 codemirrorView: { EditorView, lineNumbers }, 2599 codemirrorState: { Transaction }, 2600 } = this.#CodeMirror6; 2601 2602 await cm.dispatch({ 2603 changes: { from: 0, to: cm.state.doc.length, insert: value }, 2604 selection: { anchor: 0 }, 2605 annotations: [Transaction.addToHistory.of(saveTransactionToHistory)], 2606 }); 2607 2608 const effects = []; 2609 if (this.config?.lineNumbers) { 2610 const lineNumbersConfig = { 2611 domEventHandlers: this.#gutterDOMEventHandlers, 2612 }; 2613 if (isWasm) { 2614 lineNumbersConfig.formatNumber = this.getWasmLineNumberFormatter(); 2615 } 2616 effects.push( 2617 this.#compartments.lineNumberCompartment.reconfigure( 2618 lineNumbers(lineNumbersConfig) 2619 ) 2620 ); 2621 } 2622 // Get the cached scroll snapshot for this source and restore 2623 // the scroll position. Note: The scroll has to be done in a seperate dispatch 2624 // (after the previous dispatch has set the document), this is because 2625 // it is required that the document the scroll snapshot is applied to 2626 // is the exact document it was saved on. 2627 const scrollSnapshot = this.#scrollSnapshots.get(documentId); 2628 2629 effects.push( 2630 scrollSnapshot ? scrollSnapshot : EditorView.scrollIntoView(0) 2631 ); 2632 2633 await cm.dispatch({ effects }); 2634 2635 if (this.currentDocumentId) { 2636 // If there is no scroll snapshot explicitly cache the snapshot set as no scroll 2637 // is triggered. 2638 if (!scrollSnapshot) { 2639 this.#cacheScrollSnapshot(); 2640 } 2641 } 2642 } else { 2643 cm.setValue(value); 2644 } 2645 2646 this.resetIndentUnit(); 2647 } 2648 2649 addSource(id, sourceText) { 2650 this.#sources.set(id, sourceText); 2651 } 2652 2653 clearSources(ids) { 2654 if (ids) { 2655 for (const id of ids) { 2656 this.#sources.delete(id); 2657 } 2658 } else { 2659 this.#sources.clear(); 2660 lezerUtils.clear(); 2661 } 2662 } 2663 2664 /* Currently used only in tests */ 2665 sourcesCount() { 2666 return this.#sources.size; 2667 } 2668 2669 /** 2670 * Reloads the state of the editor based on all current preferences. 2671 * This is called automatically when any of the relevant preferences 2672 * change. 2673 */ 2674 reloadPreferences() { 2675 // Restore the saved autoCloseBrackets value if it is preffed on. 2676 const useAutoClose = Services.prefs.getBoolPref(AUTO_CLOSE); 2677 this.setOption( 2678 "autoCloseBrackets", 2679 useAutoClose ? this.config.autoCloseBracketsSaved : false 2680 ); 2681 2682 this.updateCodeFoldingGutter(); 2683 2684 this.resetIndentUnit(); 2685 this.setupAutoCompletion(); 2686 } 2687 2688 /** 2689 * Set the current keyMap for CodeMirror, and load the support file if needed. 2690 * 2691 * @param {Window} win: The window on which the keymap files should be loaded. 2692 */ 2693 setKeyMap(win) { 2694 if (this.config.isReadOnly) { 2695 return; 2696 } 2697 2698 const keyMap = Services.prefs.getCharPref(KEYMAP_PREF); 2699 2700 // If alternative keymap is provided, use it. 2701 if (VALID_KEYMAPS.has(keyMap)) { 2702 if (!this.#loadedKeyMaps.has(keyMap)) { 2703 Services.scriptloader.loadSubScript(VALID_KEYMAPS.get(keyMap), win); 2704 this.#loadedKeyMaps.add(keyMap); 2705 } 2706 this.setOption("keyMap", keyMap); 2707 } else { 2708 this.setOption("keyMap", "default"); 2709 } 2710 } 2711 2712 /** 2713 * Sets the editor's indentation based on the current prefs and 2714 * re-detect indentation if we should. 2715 */ 2716 resetIndentUnit() { 2717 if (this.isDestroyed()) { 2718 return; 2719 } 2720 const cm = editors.get(this); 2721 const iterFn = (start, maxEnd, callback) => { 2722 if (!this.config.cm6) { 2723 if (this.isDestroyed()) { 2724 return; 2725 } 2726 cm.eachLine(start, maxEnd, line => { 2727 return callback(line.text); 2728 }); 2729 } else { 2730 const iterator = cm.state.doc.iterLines( 2731 start + 1, 2732 Math.min(cm.state.doc.lines, maxEnd) + 1 2733 ); 2734 let callbackRes; 2735 do { 2736 iterator.next(); 2737 callbackRes = callback(iterator.value); 2738 } while (iterator.done !== true && !callbackRes); 2739 } 2740 }; 2741 2742 const { indentUnit, indentWithTabs } = getIndentationFromIteration(iterFn); 2743 2744 if (!this.config.cm6) { 2745 cm.setOption("tabSize", indentUnit); 2746 cm.setOption("indentUnit", indentUnit); 2747 cm.setOption("indentWithTabs", indentWithTabs); 2748 } else { 2749 const { 2750 codemirrorState: { EditorState }, 2751 codemirrorLanguage, 2752 } = this.#CodeMirror6; 2753 2754 cm.dispatch({ 2755 effects: this.#compartments.tabSizeCompartment.reconfigure( 2756 EditorState.tabSize.of(indentUnit) 2757 ), 2758 }); 2759 cm.dispatch({ 2760 effects: this.#compartments.indentCompartment.reconfigure( 2761 codemirrorLanguage.indentUnit.of( 2762 (indentWithTabs ? "\t" : " ").repeat(indentUnit) 2763 ) 2764 ), 2765 }); 2766 } 2767 } 2768 2769 /** 2770 * Replaces contents of a text area within the from/to {line, ch} 2771 * range. If neither `from` nor `to` arguments are provided works 2772 * exactly like setText. If only `from` object is provided, inserts 2773 * text at that point, *overwriting* as many characters as needed. 2774 */ 2775 replaceText(value, from, to) { 2776 const cm = editors.get(this); 2777 2778 if (!from) { 2779 this.setText(value); 2780 return; 2781 } 2782 2783 if (!to) { 2784 const text = cm.getRange({ line: 0, ch: 0 }, from); 2785 this.setText(text + value); 2786 return; 2787 } 2788 2789 cm.replaceRange(value, from, to); 2790 } 2791 2792 /** 2793 * Inserts text at the specified {line, ch} position, shifting existing 2794 * contents as necessary. 2795 */ 2796 insertText(value, at) { 2797 const cm = editors.get(this); 2798 cm.replaceRange(value, at, at); 2799 } 2800 2801 /** 2802 * Deselects contents of the text area. 2803 */ 2804 dropSelection() { 2805 if (!this.somethingSelected()) { 2806 return; 2807 } 2808 2809 this.setCursor(this.getCursor()); 2810 } 2811 2812 /** 2813 * Returns true if there is more than one selection in the editor. 2814 */ 2815 hasMultipleSelections() { 2816 const cm = editors.get(this); 2817 return cm.listSelections().length > 1; 2818 } 2819 2820 /** 2821 * Gets the first visible line number in the editor. 2822 */ 2823 getFirstVisibleLine() { 2824 const cm = editors.get(this); 2825 return cm.lineAtHeight(0, "local"); 2826 } 2827 2828 /** 2829 * Scrolls the view such that the given line number is the first visible line. 2830 */ 2831 setFirstVisibleLine(line) { 2832 const cm = editors.get(this); 2833 const { top } = cm.charCoords({ line, ch: 0 }, "local"); 2834 cm.scrollTo(0, top); 2835 } 2836 2837 /** 2838 * Sets the cursor to the specified {line, ch} position with an additional 2839 * option to align the line at the "top", "center" or "bottom" of the editor 2840 * with "top" being default value. 2841 */ 2842 setCursor({ line, ch }, align) { 2843 const cm = editors.get(this); 2844 this.alignLine(line, align); 2845 cm.setCursor({ line, ch }); 2846 this.emit("cursorActivity"); 2847 } 2848 2849 /** 2850 * Aligns the provided line to either "top", "center" or "bottom" of the 2851 * editor view with a maximum margin of MAX_VERTICAL_OFFSET lines from top or 2852 * bottom. 2853 */ 2854 alignLine(line, align) { 2855 const cm = editors.get(this); 2856 const from = cm.lineAtHeight(0, "page"); 2857 const to = cm.lineAtHeight(cm.getWrapperElement().clientHeight, "page"); 2858 const linesVisible = to - from; 2859 const halfVisible = Math.round(linesVisible / 2); 2860 2861 // If the target line is in view, skip the vertical alignment part. 2862 if (line <= to && line >= from) { 2863 return; 2864 } 2865 2866 // Setting the offset so that the line always falls in the upper half 2867 // of visible lines (lower half for bottom aligned). 2868 // MAX_VERTICAL_OFFSET is the maximum allowed value. 2869 const offset = Math.min(halfVisible, MAX_VERTICAL_OFFSET); 2870 2871 let topLine = 2872 { 2873 center: Math.max(line - halfVisible, 0), 2874 bottom: Math.max(line - linesVisible + offset, 0), 2875 top: Math.max(line - offset, 0), 2876 }[align || "top"] || offset; 2877 2878 // Bringing down the topLine to total lines in the editor if exceeding. 2879 topLine = Math.min(topLine, this.lineCount()); 2880 this.setFirstVisibleLine(topLine); 2881 } 2882 2883 /** 2884 * Returns whether a marker of a specified class exists in a line's gutter. 2885 */ 2886 hasMarker(line, gutterName, markerClass) { 2887 const marker = this.getMarker(line, gutterName); 2888 if (!marker) { 2889 return false; 2890 } 2891 2892 return marker.classList.contains(markerClass); 2893 } 2894 2895 /** 2896 * Adds a marker with a specified class to a line's gutter. If another marker 2897 * exists on that line, the new marker class is added to its class list. 2898 */ 2899 addMarker(line, gutterName, markerClass) { 2900 const cm = editors.get(this); 2901 const info = this.lineInfo(line); 2902 if (!info) { 2903 return; 2904 } 2905 2906 const gutterMarkers = info.gutterMarkers; 2907 let marker; 2908 if (gutterMarkers) { 2909 marker = gutterMarkers[gutterName]; 2910 if (marker) { 2911 marker.classList.add(markerClass); 2912 return; 2913 } 2914 } 2915 2916 marker = cm.getWrapperElement().ownerDocument.createElement("div"); 2917 marker.className = markerClass; 2918 cm.setGutterMarker(info.line, gutterName, marker); 2919 } 2920 2921 /** 2922 * The reverse of addMarker. Removes a marker of a specified class from a 2923 * line's gutter. 2924 */ 2925 removeMarker(line, gutterName, markerClass) { 2926 if (!this.hasMarker(line, gutterName, markerClass)) { 2927 return; 2928 } 2929 2930 this.lineInfo(line).gutterMarkers[gutterName].classList.remove(markerClass); 2931 } 2932 2933 /** 2934 * Adds a marker with a specified class and an HTML content to a line's 2935 * gutter. If another marker exists on that line, it is overwritten by a new 2936 * marker. 2937 */ 2938 addContentMarker(line, gutterName, markerClass, content) { 2939 const cm = editors.get(this); 2940 const info = this.lineInfo(line); 2941 if (!info) { 2942 return; 2943 } 2944 2945 const marker = cm.getWrapperElement().ownerDocument.createElement("div"); 2946 marker.className = markerClass; 2947 // eslint-disable-next-line no-unsanitized/property 2948 marker.innerHTML = content; 2949 cm.setGutterMarker(info.line, gutterName, marker); 2950 } 2951 2952 /** 2953 * The reverse of addContentMarker. Removes any line's markers in the 2954 * specified gutter. 2955 */ 2956 removeContentMarker(line, gutterName) { 2957 const cm = editors.get(this); 2958 const info = this.lineInfo(line); 2959 if (!info) { 2960 return; 2961 } 2962 2963 cm.setGutterMarker(info.line, gutterName, null); 2964 } 2965 2966 getMarker(line, gutterName) { 2967 const info = this.lineInfo(line); 2968 if (!info) { 2969 return null; 2970 } 2971 2972 const gutterMarkers = info.gutterMarkers; 2973 if (!gutterMarkers) { 2974 return null; 2975 } 2976 2977 return gutterMarkers[gutterName]; 2978 } 2979 2980 /** 2981 * Removes all gutter markers in the gutter with the given name. 2982 */ 2983 removeAllMarkers(gutterName) { 2984 const cm = editors.get(this); 2985 cm.clearGutter(gutterName); 2986 } 2987 2988 /** 2989 * Handles attaching a set of events listeners on a marker. They should 2990 * be passed as an object literal with keys as event names and values as 2991 * function listeners. The line number, marker node and optional data 2992 * will be passed as arguments to the function listener. 2993 * 2994 * You don't need to worry about removing these event listeners. 2995 * They're automatically orphaned when clearing markers. 2996 */ 2997 setMarkerListeners(line, gutterName, markerClass, eventsArg, data) { 2998 if (!this.hasMarker(line, gutterName, markerClass)) { 2999 return; 3000 } 3001 3002 const cm = editors.get(this); 3003 const marker = cm.lineInfo(line).gutterMarkers[gutterName]; 3004 3005 for (const name in eventsArg) { 3006 const listener = eventsArg[name].bind(this, line, marker, data); 3007 marker.addEventListener(name, listener, { 3008 signal: this.#abortController?.signal, 3009 }); 3010 } 3011 } 3012 3013 /** 3014 * Returns whether a line is decorated using the specified class name. 3015 */ 3016 hasLineClass(line, className) { 3017 const info = this.lineInfo(line); 3018 3019 if (!info || !info.wrapClass) { 3020 return false; 3021 } 3022 3023 return info.wrapClass.split(" ").includes(className); 3024 } 3025 3026 /** 3027 * Sets a CSS class name for the given line, including the text and gutter. 3028 */ 3029 addLineClass(lineOrOffset, className) { 3030 const cm = editors.get(this); 3031 const line = this.toLineIfWasmOffset(lineOrOffset); 3032 cm.addLineClass(line, "wrap", className); 3033 } 3034 3035 /** 3036 * The reverse of addLineClass. 3037 */ 3038 removeLineClass(lineOrOffset, className) { 3039 const cm = editors.get(this); 3040 const line = this.toLineIfWasmOffset(lineOrOffset); 3041 cm.removeLineClass(line, "wrap", className); 3042 } 3043 3044 /** 3045 * Mark a range of text inside the two {line, ch} bounds. Since the range may 3046 * be modified, for example, when typing text, this method returns a function 3047 * that can be used to remove the mark. 3048 */ 3049 markText(from, to, className = "marked-text") { 3050 const cm = editors.get(this); 3051 const text = cm.getRange(from, to); 3052 const span = cm.getWrapperElement().ownerDocument.createElement("span"); 3053 span.className = className; 3054 span.textContent = text; 3055 3056 const mark = cm.markText(from, to, { replacedWith: span }); 3057 return { 3058 anchor: span, 3059 clear: () => mark.clear(), 3060 }; 3061 } 3062 3063 /** 3064 * Calculates and returns one or more {line, ch} objects for 3065 * a zero-based index who's value is relative to the start of 3066 * the editor's text. 3067 * 3068 * If only one argument is given, this method returns a single 3069 * {line,ch} object. Otherwise it returns an array. 3070 */ 3071 getPosition(...args) { 3072 const cm = editors.get(this); 3073 const res = args.map(ind => cm.posFromIndex(ind)); 3074 return args.length === 1 ? res[0] : res; 3075 } 3076 3077 /** 3078 * The reverse of getPosition. Similarly to getPosition this 3079 * method returns a single value if only one argument was given 3080 * and an array otherwise. 3081 */ 3082 getOffset(...args) { 3083 const cm = editors.get(this); 3084 const res = args.map(pos => cm.indexFromPos(pos)); 3085 return args.length > 1 ? res : res[0]; 3086 } 3087 3088 /** 3089 * Returns a {line, ch} object that corresponds to the 3090 * left, top coordinates. 3091 */ 3092 getPositionFromCoords({ left, top }) { 3093 const cm = editors.get(this); 3094 return cm.coordsChar({ left, top }); 3095 } 3096 3097 /** 3098 * Returns true if there's something to undo and false otherwise. 3099 */ 3100 canUndo() { 3101 const cm = editors.get(this); 3102 return cm.historySize().undo > 0; 3103 } 3104 3105 /** 3106 * Returns true if there's something to redo and false otherwise. 3107 */ 3108 canRedo() { 3109 const cm = editors.get(this); 3110 return cm.historySize().redo > 0; 3111 } 3112 3113 /** 3114 * Marks the contents as clean and returns the current 3115 * version number. 3116 */ 3117 setClean() { 3118 const cm = editors.get(this); 3119 this.version = cm.changeGeneration(); 3120 this.#lastDirty = false; 3121 this.emit("dirty-change"); 3122 return this.version; 3123 } 3124 3125 /** 3126 * Returns true if contents of the text area are 3127 * clean i.e. no changes were made since the last version. 3128 */ 3129 isClean() { 3130 const cm = editors.get(this); 3131 return cm.isClean(this.version); 3132 } 3133 3134 /** 3135 * This method opens an in-editor dialog asking for a line to 3136 * jump to. Once given, it changes cursor to that line. 3137 */ 3138 jumpToLine() { 3139 const doc = editors.get(this).getWrapperElement().ownerDocument; 3140 const div = doc.createElement("div"); 3141 const inp = doc.createElement("input"); 3142 const txt = doc.createTextNode(L10N.getStr("gotoLineCmd.promptTitle")); 3143 3144 inp.type = "text"; 3145 inp.style.width = "10em"; 3146 inp.style.marginInlineStart = "1em"; 3147 3148 div.appendChild(txt); 3149 div.appendChild(inp); 3150 3151 this.openDialog(div, line => { 3152 // Handle LINE:COLUMN as well as LINE 3153 const match = line.toString().match(RE_JUMP_TO_LINE); 3154 if (match) { 3155 const [, matchLine, column] = match; 3156 this.setCursor({ line: matchLine - 1, ch: column ? column - 1 : 0 }); 3157 } 3158 }); 3159 } 3160 3161 /** 3162 * Moves the content of the current line or the lines selected up a line. 3163 */ 3164 moveLineUp() { 3165 const cm = editors.get(this); 3166 const start = cm.getCursor("start"); 3167 const end = cm.getCursor("end"); 3168 3169 if (start.line === 0) { 3170 return; 3171 } 3172 3173 // Get the text in the lines selected or the current line of the cursor 3174 // and append the text of the previous line. 3175 let value; 3176 if (start.line !== end.line) { 3177 value = 3178 cm.getRange( 3179 { line: start.line, ch: 0 }, 3180 { line: end.line, ch: cm.getLine(end.line).length } 3181 ) + "\n"; 3182 } else { 3183 value = cm.getLine(start.line) + "\n"; 3184 } 3185 value += cm.getLine(start.line - 1); 3186 3187 // Replace the previous line and the currently selected lines with the new 3188 // value and maintain the selection of the text. 3189 cm.replaceRange( 3190 value, 3191 { line: start.line - 1, ch: 0 }, 3192 { line: end.line, ch: cm.getLine(end.line).length } 3193 ); 3194 cm.setSelection( 3195 { line: start.line - 1, ch: start.ch }, 3196 { line: end.line - 1, ch: end.ch } 3197 ); 3198 } 3199 3200 /** 3201 * Moves the content of the current line or the lines selected down a line. 3202 */ 3203 moveLineDown() { 3204 const cm = editors.get(this); 3205 const start = cm.getCursor("start"); 3206 const end = cm.getCursor("end"); 3207 3208 if (end.line + 1 === cm.lineCount()) { 3209 return; 3210 } 3211 3212 // Get the text of next line and append the text in the lines selected 3213 // or the current line of the cursor. 3214 let value = cm.getLine(end.line + 1) + "\n"; 3215 if (start.line !== end.line) { 3216 value += cm.getRange( 3217 { line: start.line, ch: 0 }, 3218 { line: end.line, ch: cm.getLine(end.line).length } 3219 ); 3220 } else { 3221 value += cm.getLine(start.line); 3222 } 3223 3224 // Replace the currently selected lines and the next line with the new 3225 // value and maintain the selection of the text. 3226 cm.replaceRange( 3227 value, 3228 { line: start.line, ch: 0 }, 3229 { line: end.line + 1, ch: cm.getLine(end.line + 1).length } 3230 ); 3231 cm.setSelection( 3232 { line: start.line + 1, ch: start.ch }, 3233 { line: end.line + 1, ch: end.ch } 3234 ); 3235 } 3236 3237 /** 3238 * Intercept CodeMirror's Find and replace key shortcut to select the search input 3239 */ 3240 findOrReplace(node, isReplaceAll) { 3241 const cm = editors.get(this); 3242 const isInput = node.tagName === "INPUT"; 3243 const isSearchInput = isInput && node.type === "search"; 3244 // replace box is a different input instance than search, and it is 3245 // located in a code mirror dialog 3246 const isDialogInput = 3247 isInput && 3248 node.parentNode && 3249 node.parentNode.classList.contains("CodeMirror-dialog"); 3250 if (!(isSearchInput || isDialogInput)) { 3251 return; 3252 } 3253 3254 if (isSearchInput || isReplaceAll) { 3255 // select the search input 3256 // it's the precise reason why we reimplement these key shortcuts 3257 node.select(); 3258 } 3259 3260 // need to call it since we prevent the propagation of the event and 3261 // cancel codemirror's key handling 3262 cm.execCommand("findPersistent"); 3263 } 3264 3265 /** 3266 * Intercept CodeMirror's findNext and findPrev key shortcut to allow 3267 * immediately search for next occurance after typing a word to search. 3268 */ 3269 findNextOrPrev(node, isFindPrev) { 3270 const cm = editors.get(this); 3271 const isInput = node.tagName === "INPUT"; 3272 const isSearchInput = isInput && node.type === "search"; 3273 if (!isSearchInput) { 3274 return; 3275 } 3276 const query = node.value; 3277 // cm.state.search allows to automatically start searching for the next occurance 3278 // it's the precise reason why we reimplement these key shortcuts 3279 if (!cm.state.search || cm.state.search.query !== query) { 3280 cm.state.search = { 3281 posFrom: null, 3282 posTo: null, 3283 overlay: null, 3284 query, 3285 }; 3286 } 3287 3288 // need to call it since we prevent the propagation of the event and 3289 // cancel codemirror's key handling 3290 if (isFindPrev) { 3291 cm.execCommand("findPrev"); 3292 } else { 3293 cm.execCommand("findNext"); 3294 } 3295 } 3296 3297 /** 3298 * Returns current font size for the editor area, in pixels. 3299 */ 3300 getFontSize() { 3301 const cm = editors.get(this); 3302 const el = cm.getWrapperElement(); 3303 const win = el.ownerDocument.defaultView; 3304 3305 return parseInt(win.getComputedStyle(el).getPropertyValue("font-size"), 10); 3306 } 3307 3308 /** 3309 * Sets font size for the editor area. 3310 */ 3311 setFontSize(size) { 3312 const cm = editors.get(this); 3313 cm.getWrapperElement().style.fontSize = parseInt(size, 10) + "px"; 3314 cm.refresh(); 3315 } 3316 3317 setLineWrapping(value) { 3318 const cm = editors.get(this); 3319 if (this.config.cm6) { 3320 const { 3321 codemirrorView: { EditorView }, 3322 } = this.#CodeMirror6; 3323 cm.dispatch({ 3324 effects: this.#compartments.lineWrapCompartment.reconfigure( 3325 value ? EditorView.lineWrapping : [] 3326 ), 3327 }); 3328 } else { 3329 cm.setOption("lineWrapping", value); 3330 } 3331 this.config.lineWrapping = value; 3332 } 3333 3334 /** 3335 * Sets an option for the editor. For most options it just defers to 3336 * CodeMirror.setOption, but certain ones are maintained within the editor 3337 * instance. 3338 */ 3339 setOption(o, v) { 3340 const cm = editors.get(this); 3341 3342 // Save the state of a valid autoCloseBrackets string, so we can reset 3343 // it if it gets preffed off and back on. 3344 if (o === "autoCloseBrackets" && v) { 3345 this.config.autoCloseBracketsSaved = v; 3346 } 3347 3348 if (o === "autocomplete") { 3349 this.config.autocomplete = v; 3350 this.setupAutoCompletion(); 3351 } else { 3352 cm.setOption(o, v); 3353 this.config[o] = v; 3354 } 3355 3356 if (o === "enableCodeFolding") { 3357 // The new value maybe explicitly force foldGUtter on or off, ignoring 3358 // the prefs service. 3359 this.updateCodeFoldingGutter(); 3360 } 3361 } 3362 3363 /** 3364 * Gets an option for the editor. For most options it just defers to 3365 * CodeMirror.getOption, but certain ones are maintained within the editor 3366 * instance. 3367 */ 3368 getOption(o) { 3369 const cm = editors.get(this); 3370 if (o === "autocomplete") { 3371 return this.config.autocomplete; 3372 } 3373 3374 return cm.getOption(o); 3375 } 3376 3377 /** 3378 * Sets up autocompletion for the editor. Lazily imports the required 3379 * dependencies because they vary by editor mode. 3380 * 3381 * Autocompletion is special, because we don't want to automatically use 3382 * it just because it is preffed on (it still needs to be requested by the 3383 * editor), but we do want to always disable it if it is preffed off. 3384 */ 3385 setupAutoCompletion() { 3386 if (!this.config.autocomplete && !this.initializeAutoCompletion) { 3387 // Do nothing since there is no autocomplete config and no autocompletion have 3388 // been initialized. 3389 return; 3390 } 3391 // The autocomplete module will overwrite this.initializeAutoCompletion 3392 // with a mode specific autocompletion handler. 3393 if (!this.initializeAutoCompletion) { 3394 this.extend( 3395 require("resource://devtools/client/shared/sourceeditor/autocomplete.js") 3396 ); 3397 } 3398 3399 if (this.config.autocomplete && Services.prefs.getBoolPref(AUTOCOMPLETE)) { 3400 this.initializeAutoCompletion(this.config.autocompleteOpts); 3401 } else { 3402 this.destroyAutoCompletion(); 3403 } 3404 } 3405 3406 getAutoCompletionText() { 3407 const cm = editors.get(this); 3408 const mark = cm 3409 .getAllMarks() 3410 .find(m => m.className === AUTOCOMPLETE_MARK_CLASSNAME); 3411 if (!mark) { 3412 return ""; 3413 } 3414 3415 return mark.attributes["data-completion"] || ""; 3416 } 3417 3418 setAutoCompletionText(text) { 3419 const cursor = this.getCursor(); 3420 const cm = editors.get(this); 3421 const className = AUTOCOMPLETE_MARK_CLASSNAME; 3422 3423 cm.operation(() => { 3424 cm.getAllMarks().forEach(mark => { 3425 if (mark.className === className) { 3426 mark.clear(); 3427 } 3428 }); 3429 3430 if (text) { 3431 cm.markText({ ...cursor, ch: cursor.ch - 1 }, cursor, { 3432 className, 3433 attributes: { 3434 "data-completion": text, 3435 }, 3436 }); 3437 } 3438 }); 3439 } 3440 3441 /** 3442 * Gets the element at the specified codemirror offset 3443 * 3444 * @param {number} offset 3445 * @return {Element|null} 3446 */ 3447 #getElementAtOffset(offset) { 3448 const cm = editors.get(this); 3449 const el = cm.domAtPos(offset).node; 3450 if (!el) { 3451 return null; 3452 } 3453 // Text nodes do not have offset* properties, so lets use its 3454 // parent element; 3455 if (el.nodeType == nodeConstants.TEXT_NODE) { 3456 return el.parentElement; 3457 } 3458 return el; 3459 } 3460 3461 /** 3462 * This checks if the specified position (line/column) is within the current viewport 3463 * bounds. it helps determine if scrolling should happen. 3464 * 3465 * @param {number} line - The line in the source 3466 * @param {number} column - The column in the source 3467 * @returns {boolean} 3468 */ 3469 isPositionVisible(line, column) { 3470 const cm = editors.get(this); 3471 let inXView, inYView; 3472 3473 function withinBounds(x, min, max) { 3474 return x >= min && x <= max; 3475 } 3476 3477 if (this.config.cm6) { 3478 const pos = this.#positionToOffset(line, column); 3479 if (pos == null) { 3480 return false; 3481 } 3482 // `coordsAtPos` returns the absolute position of the line/column location 3483 // so that we have to ensure comparing with same absolute position for 3484 // CodeMirror DOM Element. 3485 // 3486 // Note that it may return the coordinates for a column breakpoint marker 3487 // so it may still report as visible, if the marker is on the edge of the viewport 3488 // and the displayed character at line/column is actually hidden after the scrollable area. 3489 const coords = cm.coordsAtPos(pos); 3490 if (!coords) { 3491 return false; 3492 } 3493 const { x, y, width, height } = cm.dom.getBoundingClientRect(); 3494 const gutterWidth = cm.dom.querySelector(".cm-gutters").clientWidth; 3495 3496 inXView = coords.left > x + gutterWidth && coords.right < x + width; 3497 inYView = coords.top > y && coords.bottom < y + height; 3498 } else { 3499 const { top, left } = cm.charCoords({ line, ch: column }, "local"); 3500 const scrollArea = cm.getScrollInfo(); 3501 const charWidth = cm.defaultCharWidth(); 3502 const fontHeight = cm.defaultTextHeight(); 3503 const { scrollTop, scrollLeft } = cm.doc; 3504 3505 inXView = withinBounds( 3506 left, 3507 scrollLeft, 3508 // Note: 30 might relate to the margin on one of the scroll bar elements. 3509 // See comment https://github.com/firefox-devtools/debugger/pull/5182#discussion_r163439209 3510 scrollLeft + (scrollArea.clientWidth - 30) - charWidth 3511 ); 3512 inYView = withinBounds( 3513 top, 3514 scrollTop, 3515 scrollTop + scrollArea.clientHeight - fontHeight 3516 ); 3517 } 3518 return inXView && inYView; 3519 } 3520 3521 /** 3522 * Converts line/col to CM6 offset position 3523 * 3524 * @param {number} line - The line in the source 3525 * @param {number} col - The column in the source 3526 * @returns {number} 3527 */ 3528 #positionToOffset(line, col = 0) { 3529 const cm = editors.get(this); 3530 try { 3531 const offset = cm.state.doc.line(line); 3532 return offset.from + col; 3533 } catch (e) { 3534 // Line likey does not exist in viewport yet 3535 console.warn(e.message); 3536 } 3537 return null; 3538 } 3539 3540 /** 3541 * This returns the line and column for the specified search cursor's position 3542 * 3543 * @param {RegExpSearchCursor} searchCursor 3544 * @returns {object} 3545 */ 3546 getPositionFromSearchCursor(searchCursor) { 3547 const cm = editors.get(this); 3548 const lineFrom = cm.state.doc.lineAt(searchCursor.from); 3549 return { 3550 line: lineFrom.number - 1, 3551 ch: searchCursor.to - searchCursor.match[0].length - lineFrom.from, 3552 }; 3553 } 3554 3555 /** 3556 * Scrolls the editor to the specified codemirror position 3557 * 3558 * @param {number} position 3559 */ 3560 scrollToPosition(position) { 3561 const cm = editors.get(this); 3562 if (!this.config.cm6) { 3563 throw new Error("This function is only compatible with CM6"); 3564 } 3565 const { 3566 codemirrorView: { EditorView }, 3567 } = this.#CodeMirror6; 3568 return cm.dispatch({ 3569 effects: EditorView.scrollIntoView(position, { 3570 x: "nearest", 3571 y: "center", 3572 }), 3573 }); 3574 } 3575 3576 /** 3577 * Scrolls the editor to the specified line and column 3578 * 3579 * @param {number} line - The line in the source 3580 * @param {number} column - The column in the source 3581 * @param {string | null} yAlign - Optional value for position of the line after the line is scrolled. 3582 * (Used by `scrollEditorIntoView` test helper) 3583 */ 3584 async scrollTo(line, column, yAlign) { 3585 if (this.isDestroyed()) { 3586 return null; 3587 } 3588 const cm = editors.get(this); 3589 if (this.config.cm6) { 3590 const { 3591 codemirrorView: { EditorView }, 3592 } = this.#CodeMirror6; 3593 3594 if (!this.isPositionVisible(line, column)) { 3595 const offset = this.#positionToOffset(line, column); 3596 if (offset == null) { 3597 return null; 3598 } 3599 return cm.dispatch({ 3600 effects: EditorView.scrollIntoView(offset, { 3601 x: "center", 3602 y: yAlign || "center", 3603 }), 3604 }); 3605 } 3606 } else { 3607 // For all cases where these are on the first line and column, 3608 // avoid the possibly slow computation of cursor location on large bundles. 3609 if (!line && !column) { 3610 cm.scrollTo(0, 0); 3611 return null; 3612 } 3613 3614 const { top, left } = cm.charCoords({ line, ch: column }, "local"); 3615 3616 if (!this.isPositionVisible(line, column)) { 3617 const scroller = cm.getScrollerElement(); 3618 const centeredX = Math.max(left - scroller.offsetWidth / 2, 0); 3619 const centeredY = Math.max(top - scroller.offsetHeight / 2, 0); 3620 3621 return cm.scrollTo(centeredX, centeredY); 3622 } 3623 } 3624 return null; 3625 } 3626 3627 // Used only in tests 3628 setSelectionAt(start, end) { 3629 const cm = editors.get(this); 3630 if (this.config.cm6) { 3631 const from = this.#positionToOffset(start.line, start.column); 3632 const to = this.#positionToOffset(end.line, end.column); 3633 if (from == null || to == null) { 3634 return; 3635 } 3636 cm.dispatch({ selection: { anchor: from, head: to } }); 3637 } else { 3638 cm.setSelection( 3639 { line: start.line - 1, ch: start.column }, 3640 { line: end.line - 1, ch: end.column } 3641 ); 3642 } 3643 } 3644 3645 /** 3646 * Move CodeMirror cursor to a given location. 3647 * This will also scroll the editor to the specified position. 3648 * Used only for CM6 3649 * 3650 * @param {number} line 3651 * @param {number} column 3652 */ 3653 async setCursorAt(line, column) { 3654 await this.scrollTo(line, column); 3655 const cm = editors.get(this); 3656 const { lines } = cm.state.doc; 3657 if (line > lines) { 3658 console.error( 3659 `Trying to set the cursor on a non-existing line ${line} > ${lines}` 3660 ); 3661 return null; 3662 } 3663 const lineInfo = cm.state.doc.line(line); 3664 if (column >= lineInfo.length) { 3665 console.error( 3666 `Trying to set the cursor on a non-existing column ${column} >= ${lineInfo.length}` 3667 ); 3668 return null; 3669 } 3670 const position = lineInfo.from + column; 3671 return cm.dispatch({ selection: { anchor: position, head: position } }); 3672 } 3673 3674 // Used only in tests 3675 getEditorFileMode() { 3676 const cm = editors.get(this); 3677 if (this.config.cm6) { 3678 return cm.contentDOM.dataset.language; 3679 } 3680 return cm.getOption("mode").name; 3681 } 3682 3683 // Used only in tests 3684 getEditorContent() { 3685 const cm = editors.get(this); 3686 if (this.config.cm6) { 3687 return cm.state.doc.toString(); 3688 } 3689 return cm.getValue(); 3690 } 3691 3692 isSearchStateReady() { 3693 const cm = editors.get(this); 3694 if (this.config.cm6) { 3695 return !!this.searchState.cursors; 3696 } 3697 return !!cm.state.search; 3698 } 3699 3700 // Used only in tests 3701 getCoords(line, column = 0) { 3702 const cm = editors.get(this); 3703 if (this.config.cm6) { 3704 const offset = this.#positionToOffset(line, column); 3705 if (offset == null) { 3706 return null; 3707 } 3708 return cm.coordsAtPos(offset); 3709 } 3710 // CodeMirror is 0-based while line and column arguments are 1-based. 3711 // Pass "column=-1" when there is no column argument passed. 3712 return cm.charCoords({ line: ~~line, ch: ~~column }); 3713 } 3714 3715 // Used only in tests 3716 // Only used for CM6 3717 getElementAtLine(line) { 3718 const offset = this.#positionToOffset(line); 3719 const el = this.#getElementAtOffset(offset); 3720 return el.closest(".cm-line"); 3721 } 3722 3723 // Used only in tests 3724 getSearchQuery() { 3725 const cm = editors.get(this); 3726 if (this.config.cm6) { 3727 return this.searchState.query.toString(); 3728 } 3729 return cm.state.search.query; 3730 } 3731 3732 // Used only in tests 3733 // Gets currently selected search term 3734 getSearchSelection() { 3735 const cm = editors.get(this); 3736 if (this.config.cm6) { 3737 const cursor = 3738 this.searchState.cursors[this.searchState.currentCursorIndex]; 3739 if (!cursor) { 3740 return { text: "", line: -1, column: -1 }; 3741 } 3742 3743 const cursorPosition = lezerUtils.positionToLocation( 3744 cm.state.doc, 3745 cursor.to 3746 ); 3747 // The lines in CM6 are 1 based 3748 return { 3749 text: cursor.match[0], 3750 line: cursorPosition.line - 1, 3751 column: cursorPosition.column, 3752 }; 3753 } 3754 const cursor = cm.getCursor(); 3755 return { 3756 text: cm.getSelection(), 3757 line: cursor.line, 3758 column: cursor.ch, 3759 }; 3760 } 3761 3762 // Only used for CM6 3763 getElementAtPos(line, column) { 3764 const offset = this.#positionToOffset(line, column); 3765 const el = this.#getElementAtOffset(offset); 3766 return el; 3767 } 3768 3769 // Used only in tests 3770 getLineCount() { 3771 const cm = editors.get(this); 3772 if (this.config.cm6) { 3773 return cm.state.doc.lines; 3774 } 3775 return cm.lineCount(); 3776 } 3777 3778 /** 3779 * Extends an instance of the Editor object with additional 3780 * functions. Each function will be called with context as 3781 * the first argument. Context is a {ed, cm} object where 3782 * 'ed' is an instance of the Editor object and 'cm' is an 3783 * instance of the CodeMirror object. Example: 3784 * 3785 * function hello(ctx, name) { 3786 * let { cm, ed } = ctx; 3787 * cm; // CodeMirror instance 3788 * ed; // Editor instance 3789 * name; // 'Mozilla' 3790 * } 3791 * 3792 * editor.extend({ hello: hello }); 3793 * editor.hello('Mozilla'); 3794 */ 3795 extend(funcs) { 3796 Object.keys(funcs).forEach(name => { 3797 const cm = editors.get(this); 3798 const ctx = { ed: this, cm, Editor }; 3799 3800 if (name === "initialize") { 3801 funcs[name](ctx); 3802 return; 3803 } 3804 3805 this[name] = funcs[name].bind(null, ctx); 3806 }); 3807 } 3808 3809 isDestroyed() { 3810 return !this.config || !editors.get(this); 3811 } 3812 3813 destroy() { 3814 if (this.config.cm6 && this.#CodeMirror6) { 3815 this.#clearEditorDOMEventListeners(); 3816 } 3817 if (this.#abortController) { 3818 this.#abortController.abort(); 3819 this.#abortController = null; 3820 } 3821 this.container = null; 3822 this.config = null; 3823 this.version = null; 3824 this.#ownerDoc = null; 3825 this.#updateListener = null; 3826 this.#lineGutterMarkers.clear(); 3827 this.#lineContentMarkers.clear(); 3828 this.#scrollSnapshots.clear(); 3829 this.#languageModes.clear(); 3830 this.clearSources(); 3831 3832 if (this.#prefObserver) { 3833 this.#prefObserver.off(KEYMAP_PREF, this.setKeyMap); 3834 this.#prefObserver.off(TAB_SIZE, this.reloadPreferences); 3835 this.#prefObserver.off(EXPAND_TAB, this.reloadPreferences); 3836 this.#prefObserver.off(AUTO_CLOSE, this.reloadPreferences); 3837 this.#prefObserver.off(AUTOCOMPLETE, this.reloadPreferences); 3838 this.#prefObserver.off(DETECT_INDENT, this.reloadPreferences); 3839 this.#prefObserver.off(ENABLE_CODE_FOLDING, this.reloadPreferences); 3840 this.#prefObserver.destroy(); 3841 } 3842 3843 // Remove the link between the document and code-mirror. 3844 const cm = editors.get(this); 3845 if (cm?.doc) { 3846 cm.doc.cm = null; 3847 } 3848 3849 // Destroy the CM6 view 3850 if (cm?.destroy) { 3851 cm.destroy(); 3852 } 3853 this.emit("destroy"); 3854 } 3855 3856 updateCodeFoldingGutter() { 3857 let shouldFoldGutter = this.config.enableCodeFolding; 3858 const foldGutterIndex = this.config.gutters.indexOf( 3859 "CodeMirror-foldgutter" 3860 ); 3861 const cm = editors.get(this); 3862 3863 if (shouldFoldGutter === undefined) { 3864 shouldFoldGutter = Services.prefs.getBoolPref(ENABLE_CODE_FOLDING); 3865 } 3866 3867 if (shouldFoldGutter) { 3868 // Add the gutter before enabling foldGutter 3869 if (foldGutterIndex === -1) { 3870 const gutters = this.config.gutters.slice(); 3871 gutters.push("CodeMirror-foldgutter"); 3872 this.setOption("gutters", gutters); 3873 } 3874 3875 this.setOption("foldGutter", true); 3876 } else { 3877 // No code should remain folded when folding is off. 3878 if (cm) { 3879 cm.execCommand("unfoldAll"); 3880 } 3881 3882 // Remove the gutter so it doesn't take up space 3883 if (foldGutterIndex !== -1) { 3884 const gutters = this.config.gutters.slice(); 3885 gutters.splice(foldGutterIndex, 1); 3886 this.setOption("gutters", gutters); 3887 } 3888 3889 this.setOption("foldGutter", false); 3890 } 3891 } 3892 3893 /** 3894 * Register all key shortcuts. 3895 */ 3896 #initSearchShortcuts(win) { 3897 const shortcuts = new KeyShortcuts({ 3898 window: win, 3899 }); 3900 const keys = ["find.key", "findNext.key", "findPrev.key"]; 3901 3902 if (OS === "Darwin") { 3903 keys.push("replaceAllMac.key"); 3904 } else { 3905 keys.push("replaceAll.key"); 3906 } 3907 // Process generic keys: 3908 keys.forEach(name => { 3909 const key = L10N.getStr(name); 3910 shortcuts.on(key, event => this.#onSearchShortcut(name, event)); 3911 }); 3912 } 3913 /** 3914 * Key shortcut listener. 3915 */ 3916 #onSearchShortcut = (name, event) => { 3917 if (!this.#isInputOrTextarea(event.target)) { 3918 return; 3919 } 3920 const node = event.originalTarget; 3921 3922 switch (name) { 3923 // replaceAll.key is Alt + find.key 3924 case "replaceAllMac.key": 3925 this.findOrReplace(node, true); 3926 break; 3927 // replaceAll.key is Shift + find.key 3928 case "replaceAll.key": 3929 this.findOrReplace(node, true); 3930 break; 3931 case "find.key": 3932 this.findOrReplace(node, false); 3933 break; 3934 // findPrev.key is Shift + findNext.key 3935 case "findPrev.key": 3936 this.findNextOrPrev(node, true); 3937 break; 3938 case "findNext.key": 3939 this.findNextOrPrev(node, false); 3940 break; 3941 default: 3942 console.error("Unexpected editor key shortcut", name); 3943 return; 3944 } 3945 // Prevent default for this action 3946 event.stopPropagation(); 3947 event.preventDefault(); 3948 }; 3949 3950 /** 3951 * Check if a node is an input or textarea 3952 */ 3953 #isInputOrTextarea(element) { 3954 const name = element.tagName.toLowerCase(); 3955 return name === "input" || name === "textarea"; 3956 } 3957 3958 /** 3959 * Parse passed code string and returns an HTML string with the same classes CodeMirror 3960 * adds to handle syntax highlighting. 3961 * 3962 * @param {Document} doc: A document that will be used to create elements 3963 * @param {string} code: The code to highlight 3964 * @returns {string} The HTML string for the parsed code 3965 */ 3966 highlightText(doc, code) { 3967 if (!doc) { 3968 return code; 3969 } 3970 3971 const outputNode = doc.createElement("div"); 3972 if (!this.config.cm6) { 3973 this.CodeMirror.runMode(code, "application/javascript", outputNode); 3974 } else { 3975 const { codemirrorLangJavascript, lezerHighlight } = this.#CodeMirror6; 3976 const { highlightCode, classHighlighter } = lezerHighlight; 3977 3978 function emit(text, classes) { 3979 const textNode = doc.createTextNode(text); 3980 if (classes) { 3981 const span = doc.createElement("span"); 3982 span.appendChild(textNode); 3983 span.className = classes; 3984 outputNode.appendChild(span); 3985 } else { 3986 outputNode.appendChild(textNode); 3987 } 3988 } 3989 function emitBreak() { 3990 outputNode.appendChild(doc.createTextNode("\n")); 3991 } 3992 3993 highlightCode( 3994 code, 3995 codemirrorLangJavascript.javascriptLanguage.parser.parse(code), 3996 classHighlighter, 3997 emit, 3998 emitBreak 3999 ); 4000 } 4001 return outputNode.innerHTML; 4002 } 4003 4004 /** 4005 * Focus the CodeMirror editor 4006 */ 4007 focus() { 4008 const cm = editors.get(this); 4009 cm.focus(); 4010 } 4011 4012 /** 4013 * Select the whole document 4014 */ 4015 selectAll() { 4016 const cm = editors.get(this); 4017 if (this.config.cm6) { 4018 cm.dispatch({ 4019 selection: { anchor: 0, head: cm.state.doc.length }, 4020 userEvent: "select", 4021 }); 4022 } else { 4023 cm.execCommand("selectAll"); 4024 } 4025 } 4026 } 4027 4028 // Since Editor is a thin layer over CodeMirror some methods 4029 // are mapped directly—without any changes. 4030 4031 CM_MAPPING.forEach(name => { 4032 Editor.prototype[name] = function (...args) { 4033 const cm = editors.get(this); 4034 return cm[name].apply(cm, args); 4035 }; 4036 }); 4037 4038 /** 4039 * We compute the CSS property names, values, and color names to be used with 4040 * CodeMirror to more closely reflect what is supported by the target platform. 4041 * The database is used to replace the values used in CodeMirror while initiating 4042 * an editor object. This is done here instead of the file codemirror/css.js so 4043 * as to leave that file untouched and easily upgradable. 4044 */ 4045 function getCSSKeywords(cssProperties) { 4046 function keySet(array) { 4047 const keys = {}; 4048 for (let i = 0; i < array.length; ++i) { 4049 keys[array[i]] = true; 4050 } 4051 return keys; 4052 } 4053 4054 const propertyKeywords = cssProperties.getNames(); 4055 const colorKeywords = {}; 4056 const valueKeywords = {}; 4057 4058 propertyKeywords.forEach(property => { 4059 if (property.includes("color")) { 4060 cssProperties.getValues(property).forEach(value => { 4061 colorKeywords[value] = true; 4062 }); 4063 } else { 4064 cssProperties.getValues(property).forEach(value => { 4065 valueKeywords[value] = true; 4066 }); 4067 } 4068 }); 4069 4070 return { 4071 propertyKeywords: keySet(propertyKeywords), 4072 colorKeywords, 4073 valueKeywords, 4074 }; 4075 } 4076 4077 module.exports = Editor;