inplace-editor.js (70558B)
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 /** 6 * Basic use: 7 * let spanToEdit = document.getElementById("somespan"); 8 * 9 * editableField({ 10 * element: spanToEdit, 11 * done: function(value, commit, direction, key) { 12 * if (commit) { 13 * spanToEdit.textContent = value; 14 * } 15 * }, 16 * trigger: "dblclick" 17 * }); 18 * 19 * See editableField() for more options. 20 */ 21 22 "use strict"; 23 24 const focusManager = Services.focus; 25 const isOSX = Services.appinfo.OS === "Darwin"; 26 const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); 27 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 28 const { 29 findMostRelevantCssPropertyIndex, 30 } = require("resource://devtools/client/shared/suggestion-picker.js"); 31 32 loader.lazyRequireGetter( 33 this, 34 "InspectorCSSParserWrapper", 35 "resource://devtools/shared/css/lexer.js", 36 true 37 ); 38 39 const HTML_NS = "http://www.w3.org/1999/xhtml"; 40 const CONTENT_TYPES = { 41 PLAIN_TEXT: 0, 42 CSS_VALUE: 1, 43 CSS_MIXED: 2, 44 CSS_PROPERTY: 3, 45 }; 46 47 // The limit of 500 autocomplete suggestions should not be reached but is kept 48 // for safety. 49 const MAX_POPUP_ENTRIES = 500; 50 51 const FOCUS_FORWARD = focusManager.MOVEFOCUS_FORWARD; 52 const FOCUS_BACKWARD = focusManager.MOVEFOCUS_BACKWARD; 53 54 const WORD_REGEXP = /\w/; 55 const isWordChar = function (str) { 56 return str && WORD_REGEXP.test(str); 57 }; 58 59 const GRID_PROPERTY_NAMES = [ 60 "grid-area", 61 "grid-row", 62 "grid-row-start", 63 "grid-row-end", 64 "grid-column", 65 "grid-column-start", 66 "grid-column-end", 67 ]; 68 const GRID_ROW_PROPERTY_NAMES = [ 69 "grid-area", 70 "grid-row", 71 "grid-row-start", 72 "grid-row-end", 73 ]; 74 const GRID_COL_PROPERTY_NAMES = [ 75 "grid-area", 76 "grid-column", 77 "grid-column-start", 78 "grid-column-end", 79 ]; 80 81 /** 82 * Helper to check if the provided key matches one of the expected keys. 83 * Keys will be prefixed with DOM_VK_ and should match a key in KeyCodes. 84 * 85 * @param {string} key 86 * the key to check (can be a keyCode). 87 * @param {...string} keys 88 * list of possible keys allowed. 89 * @return {boolean} true if the key matches one of the keys. 90 */ 91 function isKeyIn(key, ...keys) { 92 return keys.some(expectedKey => { 93 return key === KeyCodes["DOM_VK_" + expectedKey]; 94 }); 95 } 96 97 /** 98 * Mark a span editable. |editableField| will listen for the span to 99 * be focused and create an InlineEditor to handle text input. 100 * Changes will be committed when the InlineEditor's input is blurred 101 * or dropped when the user presses escape. 102 * 103 * @param {object} options: Options for the editable field 104 * @param {Element} options.element: 105 * (required) The span to be edited on focus. 106 * @param {string} options.inputClass: 107 * An optional class to be added to the input. 108 * @param {Function} options.canEdit: 109 * Will be called before creating the inplace editor. Editor 110 * won't be created if canEdit returns false. 111 * @param {Function} options.start: 112 * Will be called when the inplace editor is initialized. 113 * @param {Function} options.change: 114 * Will be called when the text input changes. Will be called 115 * with the current value of the text input. 116 * @param {Function} options.done: 117 * Called when input is committed or blurred. Called with 118 * current value, a boolean telling the caller whether to 119 * commit the change, the direction of the next element to be 120 * selected and the event keybode. Direction may be one of Services.focus.MOVEFOCUS_FORWARD, 121 * Services.focus.MOVEFOCUS_BACKWARD, or null (no movement). 122 * This function is called before the editor has been torn down. 123 * @param {Function} options.destroy: 124 * Called when the editor is destroyed and has been torn down. 125 * This may be called with the return value of the options.done callback (if it is passed). 126 * @param {Function} options.contextMenu: 127 * Called when the user triggers a contextmenu event on the input. 128 * @param {object} options.advanceChars: 129 * This can be either a string or a function. 130 * If it is a string, then if any characters in it are typed, 131 * focus will advance to the next element. 132 * Otherwise, if it is a function, then the function will 133 * be called with three arguments: a key code, the current text, 134 * and the insertion point. If the function returns true, 135 * then the focus advance takes place. If it returns false, 136 * then the character is inserted instead. 137 * @param {boolean} options.stopOnReturn: 138 * If true, the return key will not advance the editor to the next 139 * focusable element. Note that Ctrl/Cmd+Enter will still advance the editor 140 * @param {boolean} options.stopOnTab: 141 * If true, the tab key will not advance the editor to the next 142 * focusable element. 143 * @param {boolean} options.stopOnShiftTab: 144 * If true, shift tab will not advance the editor to the previous 145 * focusable element. 146 * @param {string} options.trigger: The DOM event that should trigger editing, 147 * defaults to "click" 148 * @param {boolean} options.multiline: Should the editor be a multiline textarea? 149 * defaults to false 150 * @param {Function or options.Number} maxWidth: 151 * Should the editor wrap to remain below the provided max width. Only 152 * available if multiline is true. If a function is provided, it will be 153 * called when replacing the element by the inplace input. 154 * @param {boolean} options.trimOutput: Should the returned string be trimmed? 155 * defaults to true 156 * @param {boolean} options.preserveTextStyles: If true, do not copy text-related styles 157 * from `element` to the new input. 158 * defaults to false 159 * @param {object} options.cssProperties: An instance of CSSProperties. 160 * @param {object} options.getCssVariables: A function that returns a Map containing 161 * all CSS variables. The Map key is the variable name, the value is the variable value 162 * @param {number} options.defaultIncrement: The value by which the input is incremented 163 * or decremented by default (0.1 for properties like opacity and 1 by default) 164 * @param {Function} options.getGridLineNames: 165 * Will be called before offering autocomplete sugestions, if the property is 166 * a member of GRID_PROPERTY_NAMES. 167 * @param {boolean} options.showSuggestCompletionOnEmpty: 168 * If true, show the suggestions in case that the current text becomes empty. 169 * Defaults to false. 170 * @param {boolean} options.focusEditableFieldAfterApply 171 * If true, try to focus the next editable field after the input value is commited. 172 * When set to true, focusEditableFieldContainerSelector is mandatory. 173 * If no editable field can be found within the element retrieved with 174 * focusEditableFieldContainerSelector, the focus will be moved to the next focusable 175 * element (which won't be an editable field) 176 * @param {string} options.focusEditableFieldContainerSelector 177 * A CSS selector that will be used to retrieve the container element into which 178 * the next focused element should be in, when focusEditableFieldAfterApply 179 * is set to true. This allows to bail out if we can't find a suitable 180 * focusable field. 181 * @param {string} options.inputAriaLabel 182 * Optional aria-label attribute value that will be added to the input. 183 * @param {string} options.inputAriaLabelledBy 184 * Optional aria-labelled-by attribute value that will be added to the input. 185 */ 186 function editableField(options) { 187 return editableItem(options, function (element, event) { 188 if (!options.element.inplaceEditor) { 189 new InplaceEditor(options, event); 190 } 191 }); 192 } 193 194 exports.editableField = editableField; 195 196 /** 197 * Handle events for an element that should respond to 198 * clicks and sit in the editing tab order, and call 199 * a callback when it is activated. 200 * 201 * @param {object} options 202 * The options for this editor, including: 203 * {Element} element: The DOM element. 204 * {String} trigger: The DOM event that should trigger editing, 205 * defaults to "click" 206 * @param {Function} callback 207 * Called when the editor is activated. 208 * @return {Function} function which calls callback 209 */ 210 function editableItem(options, callback) { 211 const trigger = options.trigger || "click"; 212 const element = options.element; 213 element.addEventListener(trigger, function (evt) { 214 if (!isValidTargetForEditableItemCallback(evt.target)) { 215 return; 216 } 217 218 const win = this.ownerDocument.defaultView; 219 const selection = win.getSelection(); 220 if (trigger != "click" || selection.isCollapsed) { 221 callback(element, evt); 222 } 223 evt.stopPropagation(); 224 }); 225 226 // If focused by means other than a click, start editing by 227 // pressing enter or space. 228 element.addEventListener( 229 "keypress", 230 function (evt) { 231 if (!isValidTargetForEditableItemCallback(evt.target)) { 232 return; 233 } 234 235 if (isKeyIn(evt.keyCode, "RETURN") || isKeyIn(evt.charCode, "SPACE")) { 236 callback(element); 237 } 238 }, 239 true 240 ); 241 242 // Mark the element editable field for tab 243 // navigation while editing. 244 element._editable = true; 245 // And an attribute that can be used to target 246 element.setAttribute("editable", ""); 247 248 // Save the trigger type so we can dispatch this later 249 element._trigger = trigger; 250 251 // Add button semantics to the element, to indicate that it can be activated. 252 element.setAttribute("role", "button"); 253 254 return function turnOnEditMode() { 255 callback(element); 256 }; 257 } 258 259 exports.editableItem = editableItem; 260 261 /** 262 * Returns false if the passed event target should not trigger the callback passed 263 * to the editable item. 264 * 265 * @param {Element} eventTarget 266 * @returns {boolean} 267 */ 268 function isValidTargetForEditableItemCallback(eventTarget) { 269 const { nodeName } = eventTarget; 270 // If the event happened on a link or a button, we shouldn't trigger the callback 271 return nodeName !== "a" && nodeName !== "button"; 272 } 273 274 /* 275 * Various API consumers (especially tests) sometimes want to grab the 276 * inplaceEditor expando off span elements. However, when each global has its 277 * own compartment, those expandos live on Xray wrappers that are only visible 278 * within this JSM. So we provide a little workaround here. 279 */ 280 281 function getInplaceEditorForSpan(span) { 282 return span.inplaceEditor; 283 } 284 285 exports.getInplaceEditorForSpan = getInplaceEditorForSpan; 286 287 class InplaceEditor extends EventEmitter { 288 constructor(options, event) { 289 super(); 290 291 this.elt = options.element; 292 const doc = this.elt.ownerDocument; 293 this.doc = doc; 294 this.elt.inplaceEditor = this; 295 this.cssProperties = options.cssProperties; 296 this.getCssVariables = options.getCssVariables 297 ? options.getCssVariables.bind(this) 298 : null; 299 this.change = options.change; 300 this.done = options.done; 301 this.contextMenu = options.contextMenu; 302 this.defaultIncrement = options.defaultIncrement || 1; 303 this.destroy = options.destroy; 304 this.initial = options.initial ? options.initial : this.elt.textContent; 305 this.multiline = options.multiline || false; 306 this.maxWidth = options.maxWidth; 307 if (typeof this.maxWidth == "function") { 308 this.maxWidth = this.maxWidth(); 309 } 310 311 this.trimOutput = 312 options.trimOutput === undefined ? true : !!options.trimOutput; 313 this.stopOnShiftTab = !!options.stopOnShiftTab; 314 this.stopOnTab = !!options.stopOnTab; 315 this.stopOnReturn = !!options.stopOnReturn; 316 this.contentType = options.contentType || CONTENT_TYPES.PLAIN_TEXT; 317 this.property = options.property; 318 this.popup = options.popup; 319 this.preserveTextStyles = 320 options.preserveTextStyles === undefined 321 ? false 322 : !!options.preserveTextStyles; 323 this.showSuggestCompletionOnEmpty = !!options.showSuggestCompletionOnEmpty; 324 this.focusEditableFieldAfterApply = 325 options.focusEditableFieldAfterApply === true; 326 this.focusEditableFieldContainerSelector = 327 options.focusEditableFieldContainerSelector; 328 329 if ( 330 this.focusEditableFieldAfterApply && 331 !this.focusEditableFieldContainerSelector 332 ) { 333 throw new Error( 334 "focusEditableFieldContainerSelector is mandatory when focusEditableFieldAfterApply is true" 335 ); 336 } 337 338 this.#createInput(options); 339 340 // Hide the provided element and add our editor. 341 this.originalDisplay = this.elt.style.display; 342 this.elt.style.display = "none"; 343 this.elt.parentNode.insertBefore(this.input, this.elt); 344 345 // After inserting the input to have all CSS styles applied, start autosizing. 346 this.#autosize(); 347 348 this.inputCharDimensions = this.#getInputCharDimensions(); 349 // Pull out character codes for advanceChars, listing the 350 // characters that should trigger a blur. 351 if (typeof options.advanceChars === "function") { 352 this.#advanceChars = options.advanceChars; 353 } else { 354 const advanceCharcodes = {}; 355 const advanceChars = options.advanceChars || ""; 356 for (let i = 0; i < advanceChars.length; i++) { 357 advanceCharcodes[advanceChars.charCodeAt(i)] = true; 358 } 359 this.#advanceChars = charCode => charCode in advanceCharcodes; 360 } 361 362 this.input.focus(); 363 364 if (typeof options.selectAll == "undefined" || options.selectAll) { 365 this.input.select(); 366 } 367 368 const win = doc.defaultView; 369 this.#abortController = new win.AbortController(); 370 const eventListenerConfig = { signal: this.#abortController.signal }; 371 372 this.input.addEventListener("blur", this.#onBlur, eventListenerConfig); 373 this.input.addEventListener( 374 "keypress", 375 this.#onKeyPress, 376 eventListenerConfig 377 ); 378 this.input.addEventListener("wheel", this.#onWheel, eventListenerConfig); 379 this.input.addEventListener("input", this.#onInput, eventListenerConfig); 380 this.input.addEventListener( 381 "dblclick", 382 this.#stopEventPropagation, 383 eventListenerConfig 384 ); 385 this.input.addEventListener( 386 "click", 387 this.#stopEventPropagation, 388 eventListenerConfig 389 ); 390 this.input.addEventListener( 391 "mousedown", 392 this.#stopEventPropagation, 393 eventListenerConfig 394 ); 395 this.input.addEventListener( 396 "contextmenu", 397 this.#onContextMenu, 398 eventListenerConfig 399 ); 400 win.addEventListener("blur", this.#onWindowBlur, eventListenerConfig); 401 402 this.validate = options.validate; 403 404 if (this.validate) { 405 this.input.addEventListener("keyup", this.#onKeyup, eventListenerConfig); 406 } 407 408 this.#updateSize(); 409 410 if (options.start) { 411 options.start(this, event); 412 } 413 414 this.#getGridNamesBeforeCompletion(options.getGridLineNames); 415 } 416 static CONTENT_TYPES = CONTENT_TYPES; 417 418 #abortController; 419 #advanceChars; 420 #applied; 421 #measurement; 422 #openPopupTimeout; 423 #pressedKey; 424 #preventSuggestions; 425 #selectedIndex; 426 #variableNames; 427 #variables; 428 429 get currentInputValue() { 430 const val = this.trimOutput ? this.input.value.trim() : this.input.value; 431 return val; 432 } 433 434 /** 435 * Create the input element. 436 * 437 * @param {object} options 438 * @param {string} options.inputAriaLabel 439 * Optional aria-label attribute value that will be added to the input. 440 * @param {string} options.inputAriaLabelledBy 441 * Optional aria-labelledby attribute value that will be added to the input. 442 * @param {string} options.inputClass: 443 * Optional class to be added to the input. 444 */ 445 #createInput(options = {}) { 446 this.input = this.doc.createElementNS( 447 HTML_NS, 448 this.multiline ? "textarea" : "input" 449 ); 450 this.input.inplaceEditor = this; 451 452 if (this.multiline) { 453 // Hide the textarea resize handle. 454 this.input.style.resize = "none"; 455 this.input.style.overflow = "hidden"; 456 // Also reset padding. 457 this.input.style.padding = "0"; 458 } 459 460 this.input.classList.add("styleinspector-propertyeditor"); 461 if (options.inputClass) { 462 this.input.classList.add(options.inputClass); 463 } 464 this.input.value = this.initial; 465 if (options.inputAriaLabel) { 466 this.input.setAttribute("aria-label", options.inputAriaLabel); 467 } else if (options.inputAriaLabelledBy) { 468 this.input.setAttribute("aria-labelledby", options.inputAriaLabelledBy); 469 } 470 471 if (!this.preserveTextStyles) { 472 copyTextStyles(this.elt, this.input); 473 } 474 } 475 476 /** 477 * Get rid of the editor. 478 * 479 * @param {*|null} doneCallResult: When #clear is called after calling #apply, this will 480 * be the returned value of the call to options.done that is done there. 481 * Will be null when options.done is undefined. 482 */ 483 #clear(doneCallResult) { 484 if (!this.input) { 485 // Already cleared. 486 return; 487 } 488 489 this.#abortController.abort(); 490 this.#stopAutosize(); 491 492 this.elt.style.display = this.originalDisplay; 493 494 if (this.doc.activeElement == this.input) { 495 this.elt.focus(); 496 } 497 498 this.input.remove(); 499 this.input = null; 500 501 delete this.elt.inplaceEditor; 502 delete this.elt; 503 504 if (this.destroy) { 505 this.destroy(doneCallResult); 506 } 507 } 508 509 /** 510 * Keeps the editor close to the size of its input string. This is pretty 511 * crappy, suggestions for improvement welcome. 512 */ 513 #autosize() { 514 // Create a hidden, absolutely-positioned span to measure the text 515 // in the input. Boo. 516 517 // We can't just measure the original element because a) we don't 518 // change the underlying element's text ourselves (we leave that 519 // up to the client), and b) without tweaking the style of the 520 // original element, it might wrap differently or something. 521 this.#measurement = this.doc.createElementNS( 522 HTML_NS, 523 this.multiline ? "pre" : "span" 524 ); 525 this.#measurement.className = "autosizer"; 526 this.elt.parentNode.appendChild(this.#measurement); 527 const style = this.#measurement.style; 528 style.visibility = "hidden"; 529 style.position = "absolute"; 530 style.top = "0"; 531 style.left = "0"; 532 533 if (this.multiline) { 534 style.whiteSpace = "pre-wrap"; 535 style.wordWrap = "break-word"; 536 if (this.maxWidth) { 537 style.maxWidth = this.maxWidth + "px"; 538 // Use position fixed to measure dimensions without any influence from 539 // the container of the editor. 540 style.position = "fixed"; 541 } 542 } 543 544 copyAllStyles(this.input, this.#measurement); 545 this.#updateSize(); 546 } 547 548 /** 549 * Clean up the mess created by _autosize(). 550 */ 551 #stopAutosize() { 552 if (!this.#measurement) { 553 return; 554 } 555 this.#measurement.remove(); 556 this.#measurement = null; 557 } 558 559 /** 560 * Size the editor to fit its current contents. 561 */ 562 #updateSize() { 563 // Replace spaces with non-breaking spaces. Otherwise setting 564 // the span's textContent will collapse spaces and the measurement 565 // will be wrong. 566 let content = this.input.value; 567 const unbreakableSpace = "\u00a0"; 568 569 // Make sure the content is not empty. 570 if (content === "") { 571 content = unbreakableSpace; 572 } 573 574 // If content ends with a new line, add a blank space to force the autosize 575 // element to adapt its height. 576 if (content.lastIndexOf("\n") === content.length - 1) { 577 content = content + unbreakableSpace; 578 } 579 580 if (!this.multiline) { 581 content = content.replace(/ /g, unbreakableSpace); 582 } 583 584 this.#measurement.textContent = content; 585 586 // Do not use offsetWidth: it will round floating width values. 587 let width = this.#measurement.getBoundingClientRect().width; 588 if (this.multiline) { 589 if (this.maxWidth) { 590 width = Math.min(this.maxWidth, width); 591 } 592 const height = this.#measurement.getBoundingClientRect().height; 593 this.input.style.height = height + "px"; 594 } 595 this.input.style.width = width + "px"; 596 } 597 598 /** 599 * Get the width and height of a single character in the input to properly 600 * position the autocompletion popup. 601 */ 602 #getInputCharDimensions() { 603 // Just make the text content to be 'x' to get the width and height of any 604 // character in a monospace font. 605 this.#measurement.textContent = "x"; 606 const width = this.#measurement.clientWidth; 607 const height = this.#measurement.clientHeight; 608 return { width, height }; 609 } 610 611 /** 612 * Increment property values in rule view. 613 * 614 * @param {number} increment 615 * The amount to increase/decrease the property value. 616 * @return {boolean} true if value has been incremented. 617 */ 618 #incrementValue(increment) { 619 const value = this.input.value; 620 const selectionStart = this.input.selectionStart; 621 const selectionEnd = this.input.selectionEnd; 622 623 const newValue = this.#incrementCSSValue( 624 value, 625 increment, 626 selectionStart, 627 selectionEnd 628 ); 629 630 if (!newValue) { 631 return false; 632 } 633 634 this.input.value = newValue.value; 635 this.input.setSelectionRange(newValue.start, newValue.end); 636 this.#doValidation(); 637 638 // Call the user's change handler if available. 639 if (this.change) { 640 this.change(this.currentInputValue); 641 } 642 643 return true; 644 } 645 646 /** 647 * Increment the property value based on the property type. 648 * 649 * @param {string} value 650 * Property value. 651 * @param {number} increment 652 * Amount to increase/decrease the property value. 653 * @param {number} selStart 654 * Starting index of the value. 655 * @param {number} selEnd 656 * Ending index of the value. 657 * @return {object} object with properties 'value', 'start', and 'end'. 658 */ 659 #incrementCSSValue(value, increment, selStart, selEnd) { 660 const range = this.#parseCSSValue(value, selStart); 661 const type = range?.type || ""; 662 const rawValue = range ? value.substring(range.start, range.end) : ""; 663 const preRawValue = range ? value.substr(0, range.start) : ""; 664 const postRawValue = range ? value.substr(range.end) : ""; 665 let info; 666 667 let incrementedValue = null, 668 selection; 669 if (type === "num") { 670 if (rawValue == "0") { 671 info = {}; 672 info.units = this.#findCompatibleUnit(preRawValue, postRawValue); 673 } 674 675 const newValue = this.#incrementRawValue(rawValue, increment, info); 676 if (newValue !== null) { 677 incrementedValue = newValue; 678 selection = [0, incrementedValue.length]; 679 } 680 } else if (type === "hex") { 681 const exprOffset = selStart - range.start; 682 const exprOffsetEnd = selEnd - range.start; 683 const newValue = this.#incHexColor( 684 rawValue, 685 increment, 686 exprOffset, 687 exprOffsetEnd 688 ); 689 if (newValue) { 690 incrementedValue = newValue.value; 691 selection = newValue.selection; 692 } 693 } else { 694 if (type === "rgb" || type === "hsl" || type === "hwb") { 695 info = {}; 696 const isCSS4Color = !value.includes(","); 697 // In case the value uses the new syntax of the CSS Color 4 specification, 698 // it is split by the spaces and the slash separating the alpha value 699 // between the different color components. 700 // Example: rgb(255 0 0 / 0.5) 701 // Otherwise, the value is represented using the old color syntax and is 702 // split by the commas between the color components. 703 // Example: rgba(255, 0, 0, 0.5) 704 const part = 705 value 706 .substring(range.start, selStart) 707 .split(isCSS4Color ? / ?\/ ?| / : ",").length - 1; 708 if (part === 3) { 709 // alpha 710 info.minValue = 0; 711 info.maxValue = 1; 712 } else if (type === "rgb") { 713 info.minValue = 0; 714 info.maxValue = 255; 715 } else if (part !== 0) { 716 // hsl or hwb percentage 717 info.minValue = 0; 718 info.maxValue = 100; 719 720 // select the previous number if the selection is at the end of a 721 // percentage sign. 722 if (value.charAt(selStart - 1) === "%") { 723 --selStart; 724 } 725 } 726 } 727 return this.#incrementGenericValue( 728 value, 729 increment, 730 selStart, 731 selEnd, 732 info 733 ); 734 } 735 736 if (incrementedValue === null) { 737 return null; 738 } 739 740 return { 741 value: preRawValue + incrementedValue + postRawValue, 742 start: range.start + selection[0], 743 end: range.start + selection[1], 744 }; 745 } 746 747 /** 748 * Find a compatible unit to use for a CSS number value inserted between the 749 * provided beforeValue and afterValue. The compatible unit will be picked 750 * from a selection of default units corresponding to supported CSS value 751 * dimensions (distance, angle, duration). 752 * 753 * @param {string} beforeValue 754 * The string preceeding the number value in the current property 755 * value. 756 * @param {string} afterValue 757 * The string following the number value in the current property value. 758 * @return {string} a valid unit that can be used for this number value or 759 * empty string if no match could be found. 760 */ 761 #findCompatibleUnit(beforeValue, afterValue) { 762 if (!this.property || !this.property.name) { 763 return ""; 764 } 765 766 // A DOM element is used to test the validity of various units. This is to 767 // avoid having to do an async call to the server to get this information. 768 const el = this.doc.createElement("div"); 769 770 // Cycle through unitless (""), pixels, degrees and seconds. 771 const units = ["", "px", "deg", "s"]; 772 for (const unit of units) { 773 const value = beforeValue + "1" + unit + afterValue; 774 el.style.setProperty(this.property.name, ""); 775 el.style.setProperty(this.property.name, value); 776 // The property was set to `""` first, so if the value is no longer `""`, 777 // it means that the second `setProperty` call set a valid property and we 778 // can use this unit. 779 if (el.style.getPropertyValue(this.property.name) !== "") { 780 return unit; 781 } 782 } 783 return ""; 784 } 785 786 /** 787 * Parses the property value and type. 788 * 789 * @param {string} value 790 * Property value. 791 * @param {number} offset 792 * Starting index of value. 793 * @return {object} object with properties 'value', 'start', 'end', and 794 * 'type'. 795 */ 796 #parseCSSValue(value, offset) { 797 /* eslint-disable max-len */ 798 const reSplitCSS = 799 /(?<url>url\("?[^"\)]+"?\)?)|(?<rgb>rgba?\([^)]*\)?)|(?<hsl>hsla?\([^)]*\)?)|(?<hwb>hwb\([^)]*\)?)|(?<hex>#[\dA-Fa-f]+)|(?<number>-?\d*\.?\d+(%|[a-z]{1,4})?)|"([^"]*)"?|'([^']*)'?|([^,\s\/!\(\)]+)|(!(.*)?)/; 800 /* eslint-enable */ 801 let start = 0; 802 let m; 803 804 // retreive values from left to right until we find the one at our offset 805 while ((m = reSplitCSS.exec(value)) && m.index + m[0].length < offset) { 806 value = value.substring(m.index + m[0].length); 807 start += m.index + m[0].length; 808 offset -= m.index + m[0].length; 809 } 810 811 if (!m) { 812 return null; 813 } 814 815 let type; 816 if (m.groups.url) { 817 type = "url"; 818 } else if (m.groups.rgb) { 819 type = "rgb"; 820 } else if (m.groups.hsl) { 821 type = "hsl"; 822 } else if (m.groups.hwb) { 823 type = "hwb"; 824 } else if (m.groups.hex) { 825 type = "hex"; 826 } else if (m.groups.number) { 827 type = "num"; 828 } 829 830 return { 831 value: m[0], 832 start: start + m.index, 833 end: start + m.index + m[0].length, 834 type, 835 }; 836 } 837 838 /** 839 * Increment the property value for types other than 840 * number or hex, such as rgb, hsl, hwb, and file names. 841 * 842 * @param {string} value 843 * Property value. 844 * @param {number} increment 845 * Amount to increment/decrement. 846 * @param {number} offset 847 * Starting index of the property value. 848 * @param {number} offsetEnd 849 * Ending index of the property value. 850 * @param {object} info 851 * Object with details about the property value. 852 * @return {object} object with properties 'value', 'start', and 'end'. 853 */ 854 #incrementGenericValue(value, increment, offset, offsetEnd, info) { 855 // Try to find a number around the cursor to increment. 856 let start, end; 857 // Check if we are incrementing in a non-number context (such as a URL) 858 if ( 859 /^-?[0-9.]/.test(value.substring(offset, offsetEnd)) && 860 !/\d/.test(value.charAt(offset - 1) + value.charAt(offsetEnd)) 861 ) { 862 // We have a number selected, possibly with a suffix, and we are not in 863 // the disallowed case of just part of a known number being selected. 864 // Use that number. 865 start = offset; 866 end = offsetEnd; 867 } else { 868 // Parse periods as belonging to the number only if we are in a known 869 // number context. (This makes incrementing the 1 in 'image1.gif' work.) 870 const pattern = "[" + (info ? "0-9." : "0-9") + "]*"; 871 const before = new RegExp(pattern + "$").exec(value.substr(0, offset))[0] 872 .length; 873 const after = new RegExp("^" + pattern).exec(value.substr(offset))[0] 874 .length; 875 876 start = offset - before; 877 end = offset + after; 878 879 // Expand the number to contain an initial minus sign if it seems 880 // free-standing. 881 if ( 882 value.charAt(start - 1) === "-" && 883 (start - 1 === 0 || /[ (:,='"]/.test(value.charAt(start - 2))) 884 ) { 885 --start; 886 } 887 } 888 889 if (start !== end) { 890 // Include percentages as part of the incremented number (they are 891 // common enough). 892 if (value.charAt(end) === "%") { 893 ++end; 894 } 895 896 const first = value.substr(0, start); 897 let mid = value.substring(start, end); 898 const last = value.substr(end); 899 900 mid = this.#incrementRawValue(mid, increment, info); 901 902 if (mid !== null) { 903 return { 904 value: first + mid + last, 905 start, 906 end: start + mid.length, 907 }; 908 } 909 } 910 911 return null; 912 } 913 914 /** 915 * Increment the property value for numbers. 916 * 917 * @param {string} rawValue 918 * Raw value to increment. 919 * @param {number} increment 920 * Amount to increase/decrease the raw value. 921 * @param {object} info 922 * Object with info about the property value. 923 * @return {string} the incremented value. 924 */ 925 #incrementRawValue(rawValue, increment, info) { 926 const num = parseFloat(rawValue); 927 928 if (isNaN(num)) { 929 return null; 930 } 931 932 const number = /\d+(\.\d+)?/.exec(rawValue); 933 934 let units = rawValue.substr(number.index + number[0].length); 935 if (info && "units" in info) { 936 units = info.units; 937 } 938 939 // avoid rounding errors 940 let newValue = Math.round((num + increment) * 1000) / 1000; 941 942 if (info && "minValue" in info) { 943 newValue = Math.max(newValue, info.minValue); 944 } 945 if (info && "maxValue" in info) { 946 newValue = Math.min(newValue, info.maxValue); 947 } 948 949 newValue = newValue.toString(); 950 951 return newValue + units; 952 } 953 954 /** 955 * Increment the property value for hex. 956 * 957 * @param {string} value 958 * Property value. 959 * @param {number} increment 960 * Amount to increase/decrease the property value. 961 * @param {number} offset 962 * Starting index of the property value. 963 * @param {number} offsetEnd 964 * Ending index of the property value. 965 * @return {object} object with properties 'value' and 'selection'. 966 */ 967 #incHexColor(rawValue, increment, offset, offsetEnd) { 968 // Return early if no part of the rawValue is selected. 969 if (offsetEnd > rawValue.length && offset >= rawValue.length) { 970 return null; 971 } 972 if (offset < 1 && offsetEnd <= 1) { 973 return null; 974 } 975 // Ignore the leading #. 976 rawValue = rawValue.substr(1); 977 --offset; 978 --offsetEnd; 979 980 // Clamp the selection to within the actual value. 981 offset = Math.max(offset, 0); 982 offsetEnd = Math.min(offsetEnd, rawValue.length); 983 offsetEnd = Math.max(offsetEnd, offset); 984 985 // Normalize #ABC -> #AABBCC. 986 if (rawValue.length === 3) { 987 rawValue = 988 rawValue.charAt(0) + 989 rawValue.charAt(0) + 990 rawValue.charAt(1) + 991 rawValue.charAt(1) + 992 rawValue.charAt(2) + 993 rawValue.charAt(2); 994 offset *= 2; 995 offsetEnd *= 2; 996 } 997 998 // Normalize #ABCD -> #AABBCCDD. 999 if (rawValue.length === 4) { 1000 rawValue = 1001 rawValue.charAt(0) + 1002 rawValue.charAt(0) + 1003 rawValue.charAt(1) + 1004 rawValue.charAt(1) + 1005 rawValue.charAt(2) + 1006 rawValue.charAt(2) + 1007 rawValue.charAt(3) + 1008 rawValue.charAt(3); 1009 offset *= 2; 1010 offsetEnd *= 2; 1011 } 1012 1013 if (rawValue.length !== 6 && rawValue.length !== 8) { 1014 return null; 1015 } 1016 1017 // If no selection, increment an adjacent color, preferably one to the left. 1018 if (offset === offsetEnd) { 1019 if (offset === 0) { 1020 offsetEnd = 1; 1021 } else { 1022 offset = offsetEnd - 1; 1023 } 1024 } 1025 1026 // Make the selection cover entire parts. 1027 offset -= offset % 2; 1028 offsetEnd += offsetEnd % 2; 1029 1030 // Remap the increments from [0.1, 1, 10] to [1, 1, 16]. 1031 if (increment > -1 && increment < 1) { 1032 increment = increment < 0 ? -1 : 1; 1033 } 1034 if (Math.abs(increment) === 10) { 1035 increment = increment < 0 ? -16 : 16; 1036 } 1037 1038 const isUpper = rawValue.toUpperCase() === rawValue; 1039 1040 for (let pos = offset; pos < offsetEnd; pos += 2) { 1041 // Increment the part in [pos, pos+2). 1042 let mid = rawValue.substr(pos, 2); 1043 const value = parseInt(mid, 16); 1044 1045 if (isNaN(value)) { 1046 return null; 1047 } 1048 1049 mid = Math.min(Math.max(value + increment, 0), 255).toString(16); 1050 1051 while (mid.length < 2) { 1052 mid = "0" + mid; 1053 } 1054 if (isUpper) { 1055 mid = mid.toUpperCase(); 1056 } 1057 1058 rawValue = rawValue.substr(0, pos) + mid + rawValue.substr(pos + 2); 1059 } 1060 1061 return { 1062 value: "#" + rawValue, 1063 selection: [offset + 1, offsetEnd + 1], 1064 }; 1065 } 1066 1067 /** 1068 * Cycle through the autocompletion suggestions in the popup. 1069 * 1070 * @param {boolean} reverse 1071 * true to select previous item from the popup. 1072 * @param {boolean} noSelect 1073 * true to not select the text after selecting the newly selectedItem 1074 * from the popup. 1075 */ 1076 #cycleCSSSuggestion(reverse, noSelect) { 1077 // selectedItem can be null when nothing is selected in an empty editor. 1078 const { label, preLabel } = this.popup.selectedItem || { 1079 label: "", 1080 preLabel: "", 1081 }; 1082 if (reverse) { 1083 this.popup.selectPreviousItem(); 1084 } else { 1085 this.popup.selectNextItem(); 1086 } 1087 1088 this.#selectedIndex = this.popup.selectedIndex; 1089 const input = this.input; 1090 let pre = ""; 1091 1092 if (input.selectionStart < input.selectionEnd) { 1093 pre = input.value.slice(0, input.selectionStart); 1094 } else { 1095 pre = input.value.slice( 1096 0, 1097 input.selectionStart - label.length + preLabel.length 1098 ); 1099 } 1100 1101 const post = input.value.slice(input.selectionEnd, input.value.length); 1102 const item = this.popup.selectedItem; 1103 const toComplete = item.label.slice(item.preLabel.length); 1104 input.value = pre + toComplete + post; 1105 1106 if (!noSelect) { 1107 input.setSelectionRange(pre.length, pre.length + toComplete.length); 1108 } else { 1109 input.setSelectionRange( 1110 pre.length + toComplete.length, 1111 pre.length + toComplete.length 1112 ); 1113 } 1114 1115 this.#updateSize(); 1116 // This emit is mainly for the purpose of making the test flow simpler. 1117 this.emit("after-suggest"); 1118 } 1119 1120 /** 1121 * Call the client's done handler and clear out. 1122 */ 1123 #apply(direction, key) { 1124 if (this.#applied) { 1125 return null; 1126 } 1127 1128 this.#applied = true; 1129 1130 if (this.done) { 1131 const val = this.cancelled ? this.initial : this.currentInputValue; 1132 return this.done(val, !this.cancelled, direction, key); 1133 } 1134 1135 return null; 1136 } 1137 1138 /** 1139 * Hide the popup and cancel any pending popup opening. 1140 */ 1141 #onWindowBlur = () => { 1142 if (this.popup && this.popup.isOpen) { 1143 this.popup.hidePopup(); 1144 } 1145 1146 if (this.#openPopupTimeout) { 1147 this.doc.defaultView.clearTimeout(this.#openPopupTimeout); 1148 } 1149 }; 1150 1151 /** 1152 * Event handler called when the inplace-editor's input loses focus. 1153 */ 1154 #onBlur = event => { 1155 if ( 1156 event && 1157 this.popup && 1158 this.popup.isOpen && 1159 this.popup.selectedIndex >= 0 1160 ) { 1161 this.#acceptPopupSuggestion(); 1162 } else { 1163 const onApplied = this.#apply(); 1164 this.#clear(onApplied); 1165 } 1166 }; 1167 1168 /** 1169 * Before offering autocomplete, set this.gridLineNames as the line names 1170 * of the current grid, if they exist. 1171 * 1172 * @param {Function} getGridLineNames 1173 * A function which gets the line names of the current grid. 1174 */ 1175 async #getGridNamesBeforeCompletion(getGridLineNames) { 1176 if ( 1177 getGridLineNames && 1178 this.property && 1179 GRID_PROPERTY_NAMES.includes(this.property.name) 1180 ) { 1181 this.gridLineNames = await getGridLineNames(); 1182 } 1183 1184 if ( 1185 this.contentType == CONTENT_TYPES.CSS_VALUE && 1186 this.input && 1187 this.input.value == "" 1188 ) { 1189 this.#maybeSuggestCompletion(false); 1190 } 1191 } 1192 1193 /** 1194 * Event handler called by the autocomplete popup when receiving a click 1195 * event. 1196 */ 1197 #onAutocompletePopupClick = () => { 1198 this.#acceptPopupSuggestion(); 1199 }; 1200 1201 #acceptPopupSuggestion() { 1202 let label, preLabel; 1203 1204 if (this.#selectedIndex === undefined) { 1205 ({ label, preLabel } = this.popup.getItemAtIndex( 1206 this.popup.selectedIndex 1207 )); 1208 } else { 1209 ({ label, preLabel } = this.popup.getItemAtIndex(this.#selectedIndex)); 1210 } 1211 1212 const input = this.input; 1213 1214 let pre = ""; 1215 1216 // CSS_MIXED needs special treatment here to make it so that 1217 // multiple presses of tab will cycle through completions, but 1218 // without selecting the completed text. However, this same 1219 // special treatment will do the wrong thing for other editing 1220 // styles. 1221 if ( 1222 input.selectionStart < input.selectionEnd || 1223 this.contentType !== CONTENT_TYPES.CSS_MIXED 1224 ) { 1225 pre = input.value.slice(0, input.selectionStart); 1226 } else { 1227 pre = input.value.slice( 1228 0, 1229 input.selectionStart - label.length + preLabel.length 1230 ); 1231 } 1232 const post = input.value.slice(input.selectionEnd, input.value.length); 1233 const item = this.popup.selectedItem; 1234 this.#selectedIndex = this.popup.selectedIndex; 1235 const toComplete = item.label.slice(item.preLabel.length); 1236 input.value = pre + toComplete + post; 1237 input.setSelectionRange( 1238 pre.length + toComplete.length, 1239 pre.length + toComplete.length 1240 ); 1241 this.#updateSize(); 1242 // Wait for the popup to hide and then focus input async otherwise it does 1243 // not work. 1244 const onPopupHidden = () => { 1245 this.popup.off("popup-closed", onPopupHidden); 1246 this.doc.defaultView.setTimeout(() => { 1247 input.focus(); 1248 this.emit("after-suggest"); 1249 }, 0); 1250 }; 1251 this.popup.on("popup-closed", onPopupHidden); 1252 this.#hideAutocompletePopup(); 1253 } 1254 1255 /** 1256 * Handle the input field's keypress event. 1257 */ 1258 // eslint-disable-next-line complexity 1259 #onKeyPress = event => { 1260 let prevent = false; 1261 1262 const key = event.keyCode; 1263 const input = this.input; 1264 1265 // We want to autoclose some characters, remember the pressed key in order to process 1266 // it later on in maybeSuggestionCompletion(). 1267 this.#pressedKey = event.key; 1268 1269 const multilineNavigation = 1270 !this.#isSingleLine() && isKeyIn(key, "UP", "DOWN", "LEFT", "RIGHT"); 1271 const isPlainText = this.contentType == CONTENT_TYPES.PLAIN_TEXT; 1272 const isPopupOpen = this.popup && this.popup.isOpen; 1273 1274 let increment = 0; 1275 if (!isPlainText && !multilineNavigation) { 1276 increment = this.#getIncrement(event); 1277 } 1278 1279 if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) { 1280 this.#preventSuggestions = true; 1281 } 1282 1283 let cycling = false; 1284 if (increment && this.#incrementValue(increment)) { 1285 this.#updateSize(); 1286 prevent = true; 1287 cycling = true; 1288 } 1289 1290 if (isPopupOpen && isKeyIn(key, "UP", "DOWN", "PAGE_UP", "PAGE_DOWN")) { 1291 prevent = true; 1292 cycling = true; 1293 this.#cycleCSSSuggestion(isKeyIn(key, "UP", "PAGE_UP")); 1294 this.#doValidation(); 1295 } 1296 1297 if (isKeyIn(key, "BACK_SPACE", "DELETE", "LEFT", "RIGHT", "HOME", "END")) { 1298 if (isPopupOpen && this.currentInputValue !== "") { 1299 this.#hideAutocompletePopup(); 1300 } 1301 } else if ( 1302 // We may show the suggestion completion if Ctrl+space is pressed, or if an 1303 // otherwise unhandled key is pressed and the user is not cycling through the 1304 // options in the pop-up menu, it is not an expanded shorthand property, no 1305 // modifier key is pressed, and the pressed key isn't Shift+Arrow(Up|Down). 1306 (event.key === " " && event.ctrlKey) || 1307 (!cycling && 1308 !multilineNavigation && 1309 !event.metaKey && 1310 !event.altKey && 1311 !event.ctrlKey && 1312 // We only need to handle the case where the Shift key is pressed because maybeSuggestCompletion 1313 // will trigger the completion because there are selected character here, and it 1314 // will look like a "regular" completion with a suggested value. We don't need 1315 // to care about other shift + key (e.g. LEFT, HOME, …), since we're not coming 1316 // here for them. 1317 !(isKeyIn(key, "UP", "DOWN") && event.shiftKey)) 1318 ) { 1319 this.#maybeSuggestCompletion(true); 1320 } 1321 1322 if (this.multiline && event.shiftKey && isKeyIn(key, "RETURN")) { 1323 prevent = false; 1324 } else if ( 1325 this.#advanceChars(event.charCode, input.value, input.selectionStart) || 1326 isKeyIn(key, "RETURN", "TAB") 1327 ) { 1328 prevent = true; 1329 1330 const ctrlOrCmd = isOSX ? event.metaKey : event.ctrlKey; 1331 1332 let direction; 1333 if ( 1334 (this.stopOnReturn && isKeyIn(key, "RETURN") && !ctrlOrCmd) || 1335 (this.stopOnTab && !event.shiftKey && isKeyIn(key, "TAB")) || 1336 (this.stopOnShiftTab && event.shiftKey && isKeyIn(key, "TAB")) 1337 ) { 1338 direction = null; 1339 } else if (event.shiftKey && isKeyIn(key, "TAB")) { 1340 direction = FOCUS_BACKWARD; 1341 } else { 1342 direction = FOCUS_FORWARD; 1343 } 1344 1345 // Now we don't want to suggest anything as we are moving out. 1346 this.#preventSuggestions = true; 1347 // But we still want to show suggestions for css values. i.e. moving out 1348 // of css property input box in forward direction 1349 if ( 1350 this.contentType == CONTENT_TYPES.CSS_PROPERTY && 1351 direction == FOCUS_FORWARD 1352 ) { 1353 this.#preventSuggestions = false; 1354 } 1355 1356 if (isKeyIn(key, "TAB") && this.contentType == CONTENT_TYPES.CSS_MIXED) { 1357 if (this.popup && input.selectionStart < input.selectionEnd) { 1358 event.preventDefault(); 1359 input.setSelectionRange(input.selectionEnd, input.selectionEnd); 1360 this.emit("after-suggest"); 1361 return; 1362 } else if (this.popup && this.popup.isOpen) { 1363 event.preventDefault(); 1364 this.#cycleCSSSuggestion(event.shiftKey, true); 1365 return; 1366 } 1367 } 1368 1369 const onApplied = this.#apply(direction, key); 1370 1371 // Close the popup if open 1372 if (this.popup && this.popup.isOpen) { 1373 this.#hideAutocompletePopup(); 1374 } 1375 1376 if (direction !== null && focusManager.focusedElement === input) { 1377 // If the focused element wasn't changed by the done callback, 1378 // move the focus as requested. 1379 const next = moveFocus( 1380 this.doc.defaultView, 1381 direction, 1382 this.focusEditableFieldAfterApply, 1383 this.focusEditableFieldContainerSelector 1384 ); 1385 1386 // If the next node to be focused has been tagged as an editable 1387 // node, trigger editing using the configured event 1388 if (next && next.ownerDocument === this.doc && next._editable) { 1389 const e = this.doc.createEvent("Event"); 1390 e.initEvent(next._trigger, true, true); 1391 next.dispatchEvent(e); 1392 } 1393 } 1394 1395 this.#clear(onApplied); 1396 } else if (isKeyIn(key, "ESCAPE")) { 1397 // Cancel and blur ourselves. 1398 // Now we don't want to suggest anything as we are moving out. 1399 this.#preventSuggestions = true; 1400 // Close the popup if open 1401 if (this.popup && this.popup.isOpen) { 1402 this.#hideAutocompletePopup(); 1403 } else { 1404 this.cancelled = true; 1405 const onApplied = this.#apply(); 1406 this.#clear(onApplied); 1407 } 1408 prevent = true; 1409 event.stopPropagation(); 1410 } else if (isKeyIn(key, "SPACE")) { 1411 // No need for leading spaces here. This is particularly 1412 // noticable when adding a property: it's very natural to type 1413 // <name>: (which advances to the next property) then spacebar. 1414 prevent = !input.value; 1415 } 1416 1417 if (prevent) { 1418 event.preventDefault(); 1419 } 1420 }; 1421 1422 #onContextMenu = event => { 1423 if (this.contextMenu) { 1424 // Call stopPropagation() and preventDefault() here so that avoid to show default 1425 // context menu in about:devtools-toolbox. See Bug 1515265. 1426 event.stopPropagation(); 1427 event.preventDefault(); 1428 this.contextMenu(event); 1429 } 1430 }; 1431 1432 /** 1433 * Open the autocomplete popup, adding a custom click handler and classname. 1434 * 1435 * @param {number} offset 1436 * X-offset relative to the input starting edge. 1437 * @param {number} selectedIndex 1438 * The index of the item that should be selected. Use -1 to have no 1439 * item selected. 1440 */ 1441 #openAutocompletePopup(offset, selectedIndex) { 1442 this.popup.on("popup-click", this.#onAutocompletePopupClick); 1443 this.popup.openPopup(this.input, offset, 0, selectedIndex); 1444 } 1445 1446 /** 1447 * Remove the custom classname and click handler and close the autocomplete 1448 * popup. 1449 */ 1450 #hideAutocompletePopup() { 1451 this.popup.off("popup-click", this.#onAutocompletePopupClick); 1452 this.popup.hidePopup(); 1453 } 1454 1455 /** 1456 * Get the increment/decrement step to use for the provided key or wheel 1457 * event. 1458 * 1459 * @param {Event} event 1460 * The event from which the increment should be comuted 1461 * @return {number} The computed increment value. 1462 */ 1463 #getIncrement(event) { 1464 const largeIncrement = 100; 1465 const mediumIncrement = 10; 1466 const smallIncrement = 0.1; 1467 1468 let increment = 0; 1469 1470 let wheelUp = false; 1471 let wheelDown = false; 1472 if (event.type === "wheel") { 1473 if (event.wheelDelta > 0) { 1474 wheelUp = true; 1475 } else if (event.wheelDelta < 0) { 1476 wheelDown = true; 1477 } 1478 } 1479 1480 const key = event.keyCode; 1481 1482 if (wheelUp || isKeyIn(key, "UP", "PAGE_UP")) { 1483 increment = 1 * this.defaultIncrement; 1484 } else if (wheelDown || isKeyIn(key, "DOWN", "PAGE_DOWN")) { 1485 increment = -1 * this.defaultIncrement; 1486 } 1487 1488 const largeIncrementKeyPressed = event.shiftKey; 1489 const smallIncrementKeyPressed = this.#isSmallIncrementKeyPressed(event); 1490 if (largeIncrementKeyPressed && !smallIncrementKeyPressed) { 1491 if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) { 1492 increment *= largeIncrement; 1493 } else { 1494 increment *= mediumIncrement; 1495 } 1496 } else if (smallIncrementKeyPressed && !largeIncrementKeyPressed) { 1497 increment *= smallIncrement; 1498 } 1499 1500 return increment; 1501 } 1502 1503 #isSmallIncrementKeyPressed = evt => { 1504 if (isOSX) { 1505 return evt.altKey; 1506 } 1507 return evt.ctrlKey; 1508 }; 1509 1510 /** 1511 * Handle the input field's keyup event. 1512 */ 1513 #onKeyup = () => { 1514 this.#applied = false; 1515 }; 1516 1517 /** 1518 * Handle changes to the input text. 1519 */ 1520 #onInput = () => { 1521 // Validate the entered value. 1522 this.#doValidation(); 1523 1524 // Update size if we're autosizing. 1525 if (this.#measurement) { 1526 this.#updateSize(); 1527 } 1528 1529 // Call the user's change handler if available. 1530 if (this.change) { 1531 this.change(this.currentInputValue); 1532 } 1533 1534 // In case that the current value becomes empty, show the suggestions if needed. 1535 if (this.currentInputValue === "" && this.showSuggestCompletionOnEmpty) { 1536 this.#maybeSuggestCompletion(false); 1537 } 1538 }; 1539 1540 /** 1541 * Handle the input field's wheel event. 1542 * 1543 * @param {WheelEvent} event 1544 */ 1545 #onWheel = event => { 1546 const isPlainText = this.contentType == CONTENT_TYPES.PLAIN_TEXT; 1547 let increment = 0; 1548 if (!isPlainText) { 1549 increment = this.#getIncrement(event); 1550 } 1551 1552 if (increment && this.#incrementValue(increment)) { 1553 this.#updateSize(); 1554 event.preventDefault(); 1555 } 1556 }; 1557 1558 /** 1559 * Stop propagation on the provided event 1560 */ 1561 #stopEventPropagation(e) { 1562 e.stopPropagation(); 1563 } 1564 1565 /** 1566 * Fire validation callback with current input 1567 */ 1568 #doValidation() { 1569 if (this.validate && this.input) { 1570 this.validate(this.input.value); 1571 } 1572 } 1573 1574 /** 1575 * Handles displaying suggestions based on the current input. 1576 * 1577 * @param {boolean} autoInsert 1578 * Pass true to automatically insert the most relevant suggestion. 1579 */ 1580 #maybeSuggestCompletion(autoInsert) { 1581 // Input can be null in cases when you intantaneously switch out of it. 1582 if (!this.input) { 1583 return; 1584 } 1585 1586 const preTimeoutQuery = this.input.value; 1587 1588 // Since we are calling this method from a keypress event handler, the 1589 // |input.value| does not include currently typed character. Thus we perform 1590 // this method async. 1591 // eslint-disable-next-line complexity 1592 this.#openPopupTimeout = this.doc.defaultView.setTimeout(() => { 1593 if (this.#preventSuggestions) { 1594 this.#preventSuggestions = false; 1595 return; 1596 } 1597 if (this.contentType == CONTENT_TYPES.PLAIN_TEXT) { 1598 return; 1599 } 1600 if (!this.input) { 1601 return; 1602 } 1603 const input = this.input; 1604 // The length of input.value should be increased by 1 1605 if (input.value.length - preTimeoutQuery.length > 1) { 1606 return; 1607 } 1608 const query = input.value.slice(0, input.selectionStart); 1609 let startCheckQuery = query; 1610 if (query == null) { 1611 return; 1612 } 1613 // If nothing is selected and there is a word (\w) character after the cursor, do 1614 // not autocomplete. 1615 if ( 1616 input.selectionStart == input.selectionEnd && 1617 input.selectionStart < input.value.length 1618 ) { 1619 const nextChar = input.value.slice(input.selectionStart)[0]; 1620 // Check if the next character is a valid word character, no suggestion should be 1621 // provided when preceeding a word. 1622 if (/[\w-]/.test(nextChar)) { 1623 // This emit is mainly to make the test flow simpler. 1624 this.emit("after-suggest", "nothing to autocomplete"); 1625 return; 1626 } 1627 } 1628 1629 let list = []; 1630 let postLabelValues = []; 1631 1632 if (this.contentType == CONTENT_TYPES.CSS_PROPERTY) { 1633 list = this.#getCSSVariableNames().concat(this.#getCSSPropertyList()); 1634 } else if (this.contentType == CONTENT_TYPES.CSS_VALUE) { 1635 // Build the context for the autocomplete 1636 // TODO: We may want to parse the whole input, or at least, until we get into 1637 // an empty state (e.g. if cursor is in a function, we might check what's after 1638 // the cursor to build good autocomplete). 1639 const lexer = new InspectorCSSParserWrapper(query); 1640 const functionStack = []; 1641 let token; 1642 // The last parsed token that isn't a whitespace or a comment 1643 let lastMeaningfulToken; 1644 let foundImportant = false; 1645 let importantState = ""; 1646 1647 let queryStartIndex = 0; 1648 while ((token = lexer.nextToken())) { 1649 const currentFunction = functionStack.at(-1); 1650 if ( 1651 token.tokenType !== "WhiteSpace" && 1652 token.tokenType !== "Comment" 1653 ) { 1654 lastMeaningfulToken = token; 1655 if (currentFunction) { 1656 currentFunction.tokens.push(token); 1657 } 1658 } 1659 if ( 1660 token.tokenType === "Function" || 1661 token.tokenType === "ParenthesisBlock" 1662 ) { 1663 functionStack.push({ fnToken: token, tokens: [] }); 1664 } else if (token.tokenType === "CloseParenthesis") { 1665 functionStack.pop(); 1666 } 1667 1668 if ( 1669 token.tokenType === "WhiteSpace" || 1670 token.tokenType === "Comma" || 1671 token.tokenType === "Function" || 1672 (token.tokenType === "Comment" && 1673 // The parser already returns a comment token for non-closed comment, like "/*". 1674 // But we only want to start the completion after the comment is closed 1675 // Make sure we have a closed comment,i.e. at least `/**/` 1676 token.text.length >= 4 && 1677 token.text.endsWith("*/")) 1678 ) { 1679 queryStartIndex = token.endOffset; 1680 } 1681 1682 // Checking for the presence of !important (once is enough) 1683 if (!foundImportant) { 1684 // !important is composed of 2 tokens, `!` is a Delim, and `important` is an Ident. 1685 // Here we have a potential start 1686 if (token.tokenType === "Delim" && token.text === "!") { 1687 importantState = "!"; 1688 } else if (importantState === "!") { 1689 // If we saw the "!" char, then we need to have an "important" Ident 1690 if (token.tokenType === "Ident" && token.text === "important") { 1691 foundImportant = true; 1692 break; 1693 } else { 1694 // otherwise, we can reset the state. 1695 importantState = ""; 1696 } 1697 } 1698 } 1699 } 1700 1701 startCheckQuery = query.substring(queryStartIndex); 1702 1703 const lastFunctionEntry = functionStack.at(-1); 1704 const functionValues = lastFunctionEntry 1705 ? this.#getAutocompleteDataForFunction(lastFunctionEntry) 1706 : null; 1707 1708 // Don't autocomplete after !important 1709 if (foundImportant) { 1710 list = []; 1711 postLabelValues = []; 1712 } else if (functionValues) { 1713 list = functionValues.list; 1714 postLabelValues = functionValues.postLabelValues; 1715 } else { 1716 list = this.#getCSSValuesForPropertyName(this.property.name); 1717 // Only show !important if: 1718 if ( 1719 // we're not in a function 1720 !functionStack.length && 1721 // and there is no non-whitespace items after the cursor 1722 !input.value.slice(input.selectionStart).trim() && 1723 // and the last meaningful token wasn't a delimiter or a comma 1724 lastMeaningfulToken && 1725 (lastMeaningfulToken.tokenType !== "Delim" || 1726 lastMeaningfulToken.text !== "/") && 1727 lastMeaningfulToken.tokenType !== "Comma" && 1728 // and the input value doesn't start with ! ("!important" is parsed as a 1729 // Delim, "!", and then an indent, "important", so we can't just check the 1730 // last token) 1731 !input.value.trim().startsWith("!") 1732 ) { 1733 list.unshift("!important"); 1734 } 1735 } 1736 } else if ( 1737 this.contentType == CONTENT_TYPES.CSS_MIXED && 1738 /^\s*style\s*=/.test(query) 1739 ) { 1740 // Check if the style attribute is closed before the selection. 1741 const styleValue = query.replace(/^\s*style\s*=\s*/, ""); 1742 // Look for a quote matching the opening quote (single or double). 1743 if (/^("[^"]*"|'[^']*')/.test(styleValue)) { 1744 // This emit is mainly to make the test flow simpler. 1745 this.emit("after-suggest", "nothing to autocomplete"); 1746 return; 1747 } 1748 1749 // Detecting if cursor is at property or value; 1750 const match = query.match(/([:;"'=]?)\s*([^"';:=]+)?$/); 1751 if (match && match.length >= 2) { 1752 if (match[1] == ":") { 1753 // We are in CSS value completion 1754 const propertyName = query.match( 1755 /[;"'=]\s*([^"';:= ]+)\s*:\s*[^"';:=]*$/ 1756 )[1]; 1757 list = [ 1758 "!important;", 1759 ...this.#getCSSValuesForPropertyName(propertyName), 1760 ]; 1761 const matchLastQuery = /([^\s,.\/]+$)/.exec(match[2] || ""); 1762 if (matchLastQuery) { 1763 startCheckQuery = matchLastQuery[0]; 1764 } else { 1765 startCheckQuery = ""; 1766 } 1767 if (!match[2]) { 1768 // Don't suggest '!important' without any manually typed character 1769 list.splice(0, 1); 1770 } 1771 } else if (match[1]) { 1772 // We are in CSS property name completion 1773 list = this.#getCSSVariableNames().concat( 1774 this.#getCSSPropertyList() 1775 ); 1776 startCheckQuery = match[2]; 1777 } 1778 if (startCheckQuery == null) { 1779 // This emit is mainly to make the test flow simpler. 1780 this.emit("after-suggest", "nothing to autocomplete"); 1781 return; 1782 } 1783 } 1784 } 1785 1786 if (!this.popup) { 1787 // This emit is mainly to make the test flow simpler. 1788 this.emit("after-suggest", "no popup"); 1789 return; 1790 } 1791 1792 const finalList = []; 1793 const length = list.length; 1794 for (let i = 0, count = 0; i < length && count < MAX_POPUP_ENTRIES; i++) { 1795 if (startCheckQuery != null && list[i].startsWith(startCheckQuery)) { 1796 count++; 1797 finalList.push({ 1798 preLabel: startCheckQuery, 1799 label: list[i], 1800 postLabel: postLabelValues[i] ? postLabelValues[i] : "", 1801 }); 1802 } else if (count > 0) { 1803 // Since count was incremented, we had already crossed the entries 1804 // which would have started with query, assuming that list is sorted. 1805 break; 1806 } else if (startCheckQuery != null && list[i][0] > startCheckQuery[0]) { 1807 // We have crossed all possible matches alphabetically. 1808 break; 1809 } 1810 } 1811 1812 // Sort items starting with [a-z0-9] first, to make sure vendor-prefixed 1813 // values and "!important" are suggested only after standard values. 1814 finalList.sort((item1, item2) => { 1815 // Get the expected alphabetical comparison between the items. 1816 let comparison = item1.label.localeCompare(item2.label); 1817 if (/^\w/.test(item1.label) != /^\w/.test(item2.label)) { 1818 // One starts with [a-z0-9], one does not: flip the comparison. 1819 comparison = -1 * comparison; 1820 } 1821 return comparison; 1822 }); 1823 1824 let index = 0; 1825 if (startCheckQuery) { 1826 // Only select a "best" suggestion when the user started a query. 1827 const cssValues = finalList.map(item => item.label); 1828 index = findMostRelevantCssPropertyIndex(cssValues); 1829 } 1830 1831 // Insert the most relevant item from the final list as the input value. 1832 if (autoInsert && finalList[index]) { 1833 const item = finalList[index].label; 1834 input.value = 1835 query + 1836 item.slice(startCheckQuery.length) + 1837 input.value.slice(query.length); 1838 input.setSelectionRange( 1839 query.length, 1840 query.length + item.length - startCheckQuery.length 1841 ); 1842 this.#updateSize(); 1843 } 1844 1845 // Display the list of suggestions if there are more than one. 1846 if (finalList.length > 1) { 1847 // Calculate the popup horizontal offset. 1848 const indent = this.input.selectionStart - startCheckQuery.length; 1849 let offset = indent * this.inputCharDimensions.width; 1850 offset = this.#isSingleLine() ? offset : 0; 1851 1852 // Select the most relevantItem if autoInsert is allowed 1853 const selectedIndex = autoInsert ? index : -1; 1854 1855 // Open the suggestions popup. 1856 this.popup.setItems(finalList, selectedIndex); 1857 this.#openAutocompletePopup(offset, selectedIndex); 1858 } else { 1859 this.#hideAutocompletePopup(); 1860 } 1861 1862 this.#autocloseParenthesis(); 1863 1864 // This emit is mainly for the purpose of making the test flow simpler. 1865 this.emit("after-suggest"); 1866 this.#doValidation(); 1867 }, 0); 1868 } 1869 1870 /** 1871 * Returns the autocomplete data for the passed function. 1872 * 1873 * @param {object} functionStackEntry 1874 * @param {InspectorCSSToken} functionStackEntry.fnToken: The token for the 1875 * function call 1876 * @returns {object | null} Return null if there's nothing specific to display for the function. 1877 * Otherwise, return an object of the following shape: 1878 * - {Array<String>} list: The list of autocomplete items 1879 * - {Array<String>} postLabelValue: The list of autocomplete items 1880 * post labels (e.g. for variable names, their values). 1881 */ 1882 #getAutocompleteDataForFunction(functionStackEntry) { 1883 const functionName = functionStackEntry?.fnToken?.value; 1884 if (!functionName) { 1885 return null; 1886 } 1887 1888 let list = []; 1889 let postLabelValues = []; 1890 1891 if (functionName === "var") { 1892 // We only want to return variables for the first parameters of var(), not for its 1893 // fallback. If we get more than one tokens, and given we don't get comments or 1894 // whitespace, this means we're in the fallback value already. 1895 if (functionStackEntry.tokens.length > 1) { 1896 // In such case we'll use the default behavior 1897 return null; 1898 } 1899 list = this.#getCSSVariableNames(); 1900 postLabelValues = list.map(varName => this.#getCSSVariableValue(varName)); 1901 } else if (functionName.includes("gradient")) { 1902 // For gradient functions we want to display named colors and color functions, 1903 // but only if the user didn't already entered a color token after the last comma. 1904 list = this.#getCSSValuesForPropertyName("color"); 1905 } 1906 1907 // TODO: Handle other functions, e.g. color functions to autocomplete on relative 1908 // color format (Bug 1898273), `color()` to suggest color space (Bug 1898277), 1909 // `anchor()` to display existing anchor names (Bug 1903278) 1910 1911 return { list, postLabelValues }; 1912 } 1913 1914 /** 1915 * Automatically add closing parenthesis and skip closing parenthesis when needed. 1916 */ 1917 #autocloseParenthesis() { 1918 // Split the current value at the cursor index to rebuild the string. 1919 const { selectionStart, selectionEnd } = this.input; 1920 1921 const parts = this.#splitStringAt( 1922 this.input.value, 1923 // Use selectionEnd, so when an autocomplete item was inserted, we put the closing 1924 // parenthesis after the suggestion 1925 selectionEnd 1926 ); 1927 1928 // Lookup the character following the caret to know if the string should be modified. 1929 const nextChar = parts[1][0]; 1930 1931 // Autocomplete closing parenthesis if the last key pressed was "(" and the next 1932 // character is not a "word" character. 1933 if (this.#pressedKey == "(" && !isWordChar(nextChar)) { 1934 this.#updateValue(parts[0] + ")" + parts[1]); 1935 } 1936 1937 // Skip inserting ")" if the next character is already a ")" (note that we actually 1938 // insert and remove the extra ")" here, as the input has already been modified). 1939 if (this.#pressedKey == ")" && nextChar == ")") { 1940 this.#updateValue(parts[0] + parts[1].substring(1)); 1941 } 1942 1943 // set original selection range 1944 this.input.setSelectionRange(selectionStart, selectionEnd); 1945 1946 this.#pressedKey = null; 1947 } 1948 1949 /** 1950 * Update the current value of the input while preserving the caret position. 1951 */ 1952 #updateValue(str) { 1953 const start = this.input.selectionStart; 1954 this.input.value = str; 1955 this.input.setSelectionRange(start, start); 1956 this.#updateSize(); 1957 } 1958 1959 /** 1960 * Split the provided string at the provided index. Returns an array of two strings. 1961 * _splitStringAt("1234567", 3) will return ["123", "4567"] 1962 */ 1963 #splitStringAt(str, index) { 1964 return [str.substring(0, index), str.substring(index, str.length)]; 1965 } 1966 1967 /** 1968 * Check if the current input is displaying more than one line of text. 1969 * 1970 * @return {boolean} true if the input has a single line of text 1971 */ 1972 #isSingleLine() { 1973 if (!this.multiline) { 1974 // Checking the inputCharDimensions.height only makes sense with multiline 1975 // editors, because the textarea is directly sized using 1976 // inputCharDimensions (see _updateSize()). 1977 // By definition if !this.multiline, then we are in single line mode. 1978 return true; 1979 } 1980 const inputRect = this.input.getBoundingClientRect(); 1981 return inputRect.height < 2 * this.inputCharDimensions.height; 1982 } 1983 1984 /** 1985 * Returns the list of CSS properties to use for the autocompletion. This 1986 * method is overridden by tests in order to use mocked suggestion lists. 1987 * 1988 * @return {Array} array of CSS property names (Strings) 1989 */ 1990 #getCSSPropertyList() { 1991 return this.cssProperties.getNames().sort(); 1992 } 1993 1994 /** 1995 * Returns a list of CSS values valid for a provided property name to use for 1996 * the autocompletion. This method is overridden by tests in order to use 1997 * mocked suggestion lists. 1998 * 1999 * @param {string} propertyName 2000 * @return {Array} array of CSS property values (Strings) 2001 */ 2002 #getCSSValuesForPropertyName(propertyName) { 2003 const gridLineList = []; 2004 if (this.gridLineNames) { 2005 if (GRID_ROW_PROPERTY_NAMES.includes(this.property.name)) { 2006 gridLineList.push(...this.gridLineNames.rows); 2007 } 2008 if (GRID_COL_PROPERTY_NAMES.includes(this.property.name)) { 2009 gridLineList.push(...this.gridLineNames.cols); 2010 } 2011 } 2012 // Must be alphabetically sorted before comparing the results with 2013 // the user input, otherwise we will lose some results. 2014 return gridLineList 2015 .concat(this.cssProperties.getValues(propertyName)) 2016 .sort(); 2017 } 2018 2019 #getCSSVariablesMap() { 2020 if (!this.getCssVariables) { 2021 return null; 2022 } 2023 2024 if (!this.#variables) { 2025 this.#variables = this.getCssVariables(); 2026 } 2027 return this.#variables; 2028 } 2029 2030 /** 2031 * Returns the list of all CSS variables to use for the autocompletion. 2032 * 2033 * @return {Array} array of CSS variable names (Strings) 2034 */ 2035 #getCSSVariableNames() { 2036 if (!this.#variableNames) { 2037 const variables = this.#getCSSVariablesMap(); 2038 if (!variables) { 2039 return []; 2040 } 2041 this.#variableNames = Array.from(variables.keys()).sort(); 2042 } 2043 return this.#variableNames; 2044 } 2045 2046 /** 2047 * Returns the variable's value for the given CSS variable name. 2048 * 2049 * @param {string} varName 2050 * The variable name to retrieve the value of 2051 * @return {string} the variable value to the given CSS variable name 2052 */ 2053 #getCSSVariableValue(varName) { 2054 return this.#getCSSVariablesMap()?.get(varName); 2055 } 2056 } 2057 2058 exports.InplaceEditor = InplaceEditor; 2059 2060 /** 2061 * Copy text-related styles from one element to another. 2062 */ 2063 function copyTextStyles(from, to) { 2064 const win = from.ownerDocument.defaultView; 2065 const style = win.getComputedStyle(from); 2066 2067 to.style.fontFamily = style.fontFamily; 2068 to.style.fontSize = style.fontSize; 2069 to.style.fontWeight = style.fontWeight; 2070 to.style.fontStyle = style.fontStyle; 2071 } 2072 2073 /** 2074 * Copy all styles which could have an impact on the element size. 2075 */ 2076 function copyAllStyles(from, to) { 2077 const win = from.ownerDocument.defaultView; 2078 const style = win.getComputedStyle(from); 2079 2080 copyTextStyles(from, to); 2081 to.style.lineHeight = style.lineHeight; 2082 2083 // If box-sizing is set to border-box, box model styles also need to be 2084 // copied. 2085 const boxSizing = style.boxSizing; 2086 if (boxSizing === "border-box") { 2087 to.style.boxSizing = boxSizing; 2088 copyBoxModelStyles(from, to); 2089 } 2090 } 2091 2092 /** 2093 * Copy box model styles that can impact width and height measurements when box- 2094 * sizing is set to "border-box" instead of "content-box". 2095 * 2096 * @param {DOMNode} from 2097 * the element from which styles are copied 2098 * @param {DOMNode} to 2099 * the element on which copied styles are applied 2100 */ 2101 function copyBoxModelStyles(from, to) { 2102 const properties = [ 2103 // Copy all paddings. 2104 "paddingTop", 2105 "paddingRight", 2106 "paddingBottom", 2107 "paddingLeft", 2108 // Copy border styles. 2109 "borderTopStyle", 2110 "borderRightStyle", 2111 "borderBottomStyle", 2112 "borderLeftStyle", 2113 // Copy border widths. 2114 "borderTopWidth", 2115 "borderRightWidth", 2116 "borderBottomWidth", 2117 "borderLeftWidth", 2118 ]; 2119 2120 const win = from.ownerDocument.defaultView; 2121 const style = win.getComputedStyle(from); 2122 for (const property of properties) { 2123 to.style[property] = style[property]; 2124 } 2125 } 2126 2127 /** 2128 * Trigger a focus change similar to pressing tab/shift-tab. 2129 * 2130 * @param {Window} win: The window into which the focus should be moved 2131 * @param {number} direction: See Services.focus.MOVEFOCUS_* 2132 * @param {boolean} focusEditableField: Set to true to move the focus to the previous/next 2133 * editable field. If not set, the focus will be set on the next focusable element. 2134 * The function might still put the focus on a non-editable field, if none is found 2135 * within the element matching focusEditableFieldContainerSelector 2136 * @param {string} focusEditableFieldContainerSelector: A CSS selector the editabled element 2137 * we want to focus should be in. This is only used when focusEditableField is set 2138 * to true. 2139 * It's important to pass a boundary otherwise we might hit an infinite loop 2140 * @returns {Element} The element that received the focus 2141 */ 2142 function moveFocus( 2143 win, 2144 direction, 2145 focusEditableField, 2146 focusEditableFieldContainerSelector 2147 ) { 2148 if (!focusEditableField) { 2149 return focusManager.moveFocus(win, null, direction, 0); 2150 } 2151 2152 if (!win.document.querySelector(focusEditableFieldContainerSelector)) { 2153 console.error( 2154 focusEditableFieldContainerSelector, 2155 "can't be found in document.", 2156 `focusEditableFieldContainerSelector should match an existing element` 2157 ); 2158 return focusManager.moveFocus(win, null, direction, 0); 2159 } 2160 2161 // Let's look for the next/previous editable element to focus 2162 while (true) { 2163 const focusedElement = focusManager.moveFocus(win, null, direction, 0); 2164 // The _editable property is set by the InplaceEditor on the target element 2165 if (focusedElement._editable) { 2166 return focusedElement; 2167 } 2168 2169 // If the focus was moved outside of the container, simply return the focused element 2170 if (!focusedElement.closest(focusEditableFieldContainerSelector)) { 2171 return focusedElement; 2172 } 2173 } 2174 }