diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/client/shared/inplace-editor.js | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/shared/inplace-editor.js')
-rw-r--r-- | devtools/client/shared/inplace-editor.js | 1964 |
1 files changed, 1964 insertions, 0 deletions
diff --git a/devtools/client/shared/inplace-editor.js b/devtools/client/shared/inplace-editor.js new file mode 100644 index 0000000000..fa97b2a982 --- /dev/null +++ b/devtools/client/shared/inplace-editor.js @@ -0,0 +1,1964 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Basic use: + * let spanToEdit = document.getElementById("somespan"); + * + * editableField({ + * element: spanToEdit, + * done: function(value, commit, direction, key) { + * if (commit) { + * spanToEdit.textContent = value; + * } + * }, + * trigger: "dblclick" + * }); + * + * See editableField() for more options. + */ + +"use strict"; + +const focusManager = Services.focus; +const isOSX = Services.appinfo.OS === "Darwin"; +const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); +const { + findMostRelevantCssPropertyIndex, +} = require("resource://devtools/client/shared/suggestion-picker.js"); + +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const CONTENT_TYPES = { + PLAIN_TEXT: 0, + CSS_VALUE: 1, + CSS_MIXED: 2, + CSS_PROPERTY: 3, +}; + +// The limit of 500 autocomplete suggestions should not be reached but is kept +// for safety. +const MAX_POPUP_ENTRIES = 500; + +const FOCUS_FORWARD = focusManager.MOVEFOCUS_FORWARD; +const FOCUS_BACKWARD = focusManager.MOVEFOCUS_BACKWARD; + +const WORD_REGEXP = /\w/; +const isWordChar = function (str) { + return str && WORD_REGEXP.test(str); +}; + +const GRID_PROPERTY_NAMES = [ + "grid-area", + "grid-row", + "grid-row-start", + "grid-row-end", + "grid-column", + "grid-column-start", + "grid-column-end", +]; +const GRID_ROW_PROPERTY_NAMES = [ + "grid-area", + "grid-row", + "grid-row-start", + "grid-row-end", +]; +const GRID_COL_PROPERTY_NAMES = [ + "grid-area", + "grid-column", + "grid-column-start", + "grid-column-end", +]; + +/** + * Helper to check if the provided key matches one of the expected keys. + * Keys will be prefixed with DOM_VK_ and should match a key in KeyCodes. + * + * @param {String} key + * the key to check (can be a keyCode). + * @param {...String} keys + * list of possible keys allowed. + * @return {Boolean} true if the key matches one of the keys. + */ +function isKeyIn(key, ...keys) { + return keys.some(expectedKey => { + return key === KeyCodes["DOM_VK_" + expectedKey]; + }); +} + +/** + * Mark a span editable. |editableField| will listen for the span to + * be focused and create an InlineEditor to handle text input. + * Changes will be committed when the InlineEditor's input is blurred + * or dropped when the user presses escape. + * + * @param {Object} options: Options for the editable field + * @param {Element} options.element: + * (required) The span to be edited on focus. + * @param {Function} options.canEdit: + * Will be called before creating the inplace editor. Editor + * won't be created if canEdit returns false. + * @param {Function} options.start: + * Will be called when the inplace editor is initialized. + * @param {Function} options.change: + * Will be called when the text input changes. Will be called + * with the current value of the text input. + * @param {Function} options.done: + * Called when input is committed or blurred. Called with + * current value, a boolean telling the caller whether to + * commit the change, the direction of the next element to be + * selected and the event keybode. Direction may be one of Services.focus.MOVEFOCUS_FORWARD, + * Services.focus.MOVEFOCUS_BACKWARD, or null (no movement). + * This function is called before the editor has been torn down. + * @param {Function} options.destroy: + * Called when the editor is destroyed and has been torn down. + * @param {Function} options.contextMenu: + * Called when the user triggers a contextmenu event on the input. + * @param {Object} options.advanceChars: + * This can be either a string or a function. + * If it is a string, then if any characters in it are typed, + * focus will advance to the next element. + * Otherwise, if it is a function, then the function will + * be called with three arguments: a key code, the current text, + * and the insertion point. If the function returns true, + * then the focus advance takes place. If it returns false, + * then the character is inserted instead. + * @param {Boolean} options.stopOnReturn: + * If true, the return key will not advance the editor to the next + * focusable element. Note that Ctrl/Cmd+Enter will still advance the editor + * @param {Boolean} options.stopOnTab: + * If true, the tab key will not advance the editor to the next + * focusable element. + * @param {Boolean} options.stopOnShiftTab: + * If true, shift tab will not advance the editor to the previous + * focusable element. + * @param {String} options.trigger: The DOM event that should trigger editing, + * defaults to "click" + * @param {Boolean} options.multiline: Should the editor be a multiline textarea? + * defaults to false + * @param {Function or options.Number} maxWidth: + * Should the editor wrap to remain below the provided max width. Only + * available if multiline is true. If a function is provided, it will be + * called when replacing the element by the inplace input. + * @param {Boolean} options.trimOutput: Should the returned string be trimmed? + * defaults to true + * @param {Boolean} options.preserveTextStyles: If true, do not copy text-related styles + * from `element` to the new input. + * defaults to false + * @param {Object} options.cssProperties: An instance of CSSProperties. + * @param {Object} options.cssVariables: A Map object containing all CSS variables. + * @param {Number} options.defaultIncrement: The value by which the input is incremented + * or decremented by default (0.1 for properties like opacity and 1 by default) + * @param {Function} options.getGridLineNames: + * Will be called before offering autocomplete sugestions, if the property is + * a member of GRID_PROPERTY_NAMES. + * @param {Boolean} options.showSuggestCompletionOnEmpty: + * If true, show the suggestions in case that the current text becomes empty. + * Defaults to false. + * @param {Boolean} options.focusEditableFieldAfterApply + * If true, try to focus the next editable field after the input value is commited. + * When set to true, focusEditableFieldContainerSelector is mandatory. + * If no editable field can be found within the element retrieved with + * focusEditableFieldContainerSelector, the focus will be moved to the next focusable + * element (which won't be an editable field) + * @param {String} options.focusEditableFieldContainerSelector + * A CSS selector that will be used to retrieve the container element into which + * the next focused element should be in, when focusEditableFieldAfterApply + * is set to true. This allows to bail out if we can't find a suitable + * focusable field. + * @param {String} options.inputAriaLabel + * Optional aria-label attribute value that will be added to the input. + * @param {String} options.inputAriaLabelledBy + * Optional aria-labelled-by attribute value that will be added to the input. + */ +function editableField(options) { + return editableItem(options, function (element, event) { + if (!options.element.inplaceEditor) { + new InplaceEditor(options, event); + } + }); +} + +exports.editableField = editableField; + +/** + * Handle events for an element that should respond to + * clicks and sit in the editing tab order, and call + * a callback when it is activated. + * + * @param {Object} options + * The options for this editor, including: + * {Element} element: The DOM element. + * {String} trigger: The DOM event that should trigger editing, + * defaults to "click" + * @param {Function} callback + * Called when the editor is activated. + * @return {Function} function which calls callback + */ +function editableItem(options, callback) { + const trigger = options.trigger || "click"; + const element = options.element; + element.addEventListener(trigger, function (evt) { + if (evt.target.nodeName !== "a") { + const win = this.ownerDocument.defaultView; + const selection = win.getSelection(); + if (trigger != "click" || selection.isCollapsed) { + callback(element, evt); + } + evt.stopPropagation(); + } + }); + + // If focused by means other than a click, start editing by + // pressing enter or space. + element.addEventListener( + "keypress", + function (evt) { + if (evt.target.nodeName === "button") { + return; + } + + if (isKeyIn(evt.keyCode, "RETURN") || isKeyIn(evt.charCode, "SPACE")) { + callback(element); + } + }, + true + ); + + // Ugly workaround - the element is focused on mousedown but + // the editor is activated on click/mouseup. This leads + // to an ugly flash of the focus ring before showing the editor. + // So hide the focus ring while the mouse is down. + element.addEventListener("mousedown", function (evt) { + if (evt.target.nodeName !== "a") { + const cleanup = function () { + element.style.removeProperty("outline-style"); + element.removeEventListener("mouseup", cleanup); + element.removeEventListener("mouseout", cleanup); + }; + element.style.setProperty("outline-style", "none"); + element.addEventListener("mouseup", cleanup); + element.addEventListener("mouseout", cleanup); + } + }); + + // Mark the element editable field for tab + // navigation while editing. + element._editable = true; + + // Save the trigger type so we can dispatch this later + element._trigger = trigger; + + // Add button semantics to the element, to indicate that it can be activated. + element.setAttribute("role", "button"); + + return function turnOnEditMode() { + callback(element); + }; +} + +exports.editableItem = editableItem; + +/* + * Various API consumers (especially tests) sometimes want to grab the + * inplaceEditor expando off span elements. However, when each global has its + * own compartment, those expandos live on Xray wrappers that are only visible + * within this JSM. So we provide a little workaround here. + */ + +function getInplaceEditorForSpan(span) { + return span.inplaceEditor; +} + +exports.getInplaceEditorForSpan = getInplaceEditorForSpan; + +class InplaceEditor extends EventEmitter { + constructor(options, event) { + super(); + + this.elt = options.element; + const doc = this.elt.ownerDocument; + this.doc = doc; + this.elt.inplaceEditor = this; + this.cssProperties = options.cssProperties; + this.cssVariables = options.cssVariables || new Map(); + this.change = options.change; + this.done = options.done; + this.contextMenu = options.contextMenu; + this.defaultIncrement = options.defaultIncrement || 1; + this.destroy = options.destroy; + this.initial = options.initial ? options.initial : this.elt.textContent; + this.multiline = options.multiline || false; + this.maxWidth = options.maxWidth; + if (typeof this.maxWidth == "function") { + this.maxWidth = this.maxWidth(); + } + + this.trimOutput = + options.trimOutput === undefined ? true : !!options.trimOutput; + this.stopOnShiftTab = !!options.stopOnShiftTab; + this.stopOnTab = !!options.stopOnTab; + this.stopOnReturn = !!options.stopOnReturn; + this.contentType = options.contentType || CONTENT_TYPES.PLAIN_TEXT; + this.property = options.property; + this.popup = options.popup; + this.preserveTextStyles = + options.preserveTextStyles === undefined + ? false + : !!options.preserveTextStyles; + this.showSuggestCompletionOnEmpty = !!options.showSuggestCompletionOnEmpty; + this.focusEditableFieldAfterApply = + options.focusEditableFieldAfterApply === true; + this.focusEditableFieldContainerSelector = + options.focusEditableFieldContainerSelector; + + if ( + this.focusEditableFieldAfterApply && + !this.focusEditableFieldContainerSelector + ) { + throw new Error( + "focusEditableFieldContainerSelector is mandatory when focusEditableFieldAfterApply is true" + ); + } + + this.#createInput(options); + + // Hide the provided element and add our editor. + this.originalDisplay = this.elt.style.display; + this.elt.style.display = "none"; + this.elt.parentNode.insertBefore(this.input, this.elt); + + // After inserting the input to have all CSS styles applied, start autosizing. + this.#autosize(); + + this.inputCharDimensions = this.#getInputCharDimensions(); + // Pull out character codes for advanceChars, listing the + // characters that should trigger a blur. + if (typeof options.advanceChars === "function") { + this.#advanceChars = options.advanceChars; + } else { + const advanceCharcodes = {}; + const advanceChars = options.advanceChars || ""; + for (let i = 0; i < advanceChars.length; i++) { + advanceCharcodes[advanceChars.charCodeAt(i)] = true; + } + this.#advanceChars = charCode => charCode in advanceCharcodes; + } + + this.input.focus(); + + if (typeof options.selectAll == "undefined" || options.selectAll) { + this.input.select(); + } + + const win = doc.defaultView; + this.#abortController = new win.AbortController(); + const eventListenerConfig = { signal: this.#abortController.signal }; + + this.input.addEventListener("blur", this.#onBlur, eventListenerConfig); + this.input.addEventListener( + "keypress", + this.#onKeyPress, + eventListenerConfig + ); + this.input.addEventListener("input", this.#onInput, eventListenerConfig); + this.input.addEventListener( + "dblclick", + this.#stopEventPropagation, + eventListenerConfig + ); + this.input.addEventListener( + "click", + this.#stopEventPropagation, + eventListenerConfig + ); + this.input.addEventListener( + "mousedown", + this.#stopEventPropagation, + eventListenerConfig + ); + this.input.addEventListener( + "contextmenu", + this.#onContextMenu, + eventListenerConfig + ); + win.addEventListener("blur", this.#onWindowBlur, eventListenerConfig); + + this.validate = options.validate; + + if (this.validate) { + this.input.addEventListener("keyup", this.#onKeyup, eventListenerConfig); + } + + this.#updateSize(); + + if (options.start) { + options.start(this, event); + } + + this.#getGridNamesBeforeCompletion(options.getGridLineNames); + } + static CONTENT_TYPES = CONTENT_TYPES; + + #abortController; + #advanceChars; + #applied; + #measurement; + #openPopupTimeout; + #pressedKey; + #preventSuggestions; + #selectedIndex; + + get currentInputValue() { + const val = this.trimOutput ? this.input.value.trim() : this.input.value; + return val; + } + + /** + * Create the input element. + * + * @param {Object} options + * @param {String} options.inputAriaLabel + * Optional aria-label attribute value that will be added to the input. + * @param {String} options.inputAriaLabelledBy + * Optional aria-labelledby attribute value that will be added to the input. + */ + #createInput(options = {}) { + this.input = this.doc.createElementNS( + HTML_NS, + this.multiline ? "textarea" : "input" + ); + this.input.inplaceEditor = this; + + if (this.multiline) { + // Hide the textarea resize handle. + this.input.style.resize = "none"; + this.input.style.overflow = "hidden"; + // Also reset padding. + this.input.style.padding = "0"; + } + + this.input.classList.add("styleinspector-propertyeditor"); + this.input.value = this.initial; + if (options.inputAriaLabel) { + this.input.setAttribute("aria-label", options.inputAriaLabel); + } else if (options.inputAriaLabelledBy) { + this.input.setAttribute("aria-labelledby", options.inputAriaLabelledBy); + } + + if (!this.preserveTextStyles) { + copyTextStyles(this.elt, this.input); + } + } + + /** + * Get rid of the editor. + */ + #clear() { + if (!this.input) { + // Already cleared. + return; + } + + this.#abortController.abort(); + this.#stopAutosize(); + + this.elt.style.display = this.originalDisplay; + + if (this.doc.activeElement == this.input) { + this.elt.focus(); + } + + this.input.remove(); + this.input = null; + + delete this.elt.inplaceEditor; + delete this.elt; + + if (this.destroy) { + this.destroy(); + } + } + + /** + * Keeps the editor close to the size of its input string. This is pretty + * crappy, suggestions for improvement welcome. + */ + #autosize() { + // Create a hidden, absolutely-positioned span to measure the text + // in the input. Boo. + + // We can't just measure the original element because a) we don't + // change the underlying element's text ourselves (we leave that + // up to the client), and b) without tweaking the style of the + // original element, it might wrap differently or something. + this.#measurement = this.doc.createElementNS( + HTML_NS, + this.multiline ? "pre" : "span" + ); + this.#measurement.className = "autosizer"; + this.elt.parentNode.appendChild(this.#measurement); + const style = this.#measurement.style; + style.visibility = "hidden"; + style.position = "absolute"; + style.top = "0"; + style.left = "0"; + + if (this.multiline) { + style.whiteSpace = "pre-wrap"; + style.wordWrap = "break-word"; + if (this.maxWidth) { + style.maxWidth = this.maxWidth + "px"; + // Use position fixed to measure dimensions without any influence from + // the container of the editor. + style.position = "fixed"; + } + } + + copyAllStyles(this.input, this.#measurement); + this.#updateSize(); + } + + /** + * Clean up the mess created by _autosize(). + */ + #stopAutosize() { + if (!this.#measurement) { + return; + } + this.#measurement.remove(); + this.#measurement = null; + } + + /** + * Size the editor to fit its current contents. + */ + #updateSize() { + // Replace spaces with non-breaking spaces. Otherwise setting + // the span's textContent will collapse spaces and the measurement + // will be wrong. + let content = this.input.value; + const unbreakableSpace = "\u00a0"; + + // Make sure the content is not empty. + if (content === "") { + content = unbreakableSpace; + } + + // If content ends with a new line, add a blank space to force the autosize + // element to adapt its height. + if (content.lastIndexOf("\n") === content.length - 1) { + content = content + unbreakableSpace; + } + + if (!this.multiline) { + content = content.replace(/ /g, unbreakableSpace); + } + + this.#measurement.textContent = content; + + // Do not use offsetWidth: it will round floating width values. + let width = this.#measurement.getBoundingClientRect().width; + if (this.multiline) { + if (this.maxWidth) { + width = Math.min(this.maxWidth, width); + } + const height = this.#measurement.getBoundingClientRect().height; + this.input.style.height = height + "px"; + } + this.input.style.width = width + "px"; + } + + /** + * Get the width and height of a single character in the input to properly + * position the autocompletion popup. + */ + #getInputCharDimensions() { + // Just make the text content to be 'x' to get the width and height of any + // character in a monospace font. + this.#measurement.textContent = "x"; + const width = this.#measurement.clientWidth; + const height = this.#measurement.clientHeight; + return { width, height }; + } + + /** + * Increment property values in rule view. + * + * @param {Number} increment + * The amount to increase/decrease the property value. + * @return {Boolean} true if value has been incremented. + */ + #incrementValue(increment) { + const value = this.input.value; + const selectionStart = this.input.selectionStart; + const selectionEnd = this.input.selectionEnd; + + const newValue = this.#incrementCSSValue( + value, + increment, + selectionStart, + selectionEnd + ); + + if (!newValue) { + return false; + } + + this.input.value = newValue.value; + this.input.setSelectionRange(newValue.start, newValue.end); + this.#doValidation(); + + // Call the user's change handler if available. + if (this.change) { + this.change(this.currentInputValue); + } + + return true; + } + + /** + * Increment the property value based on the property type. + * + * @param {String} value + * Property value. + * @param {Number} increment + * Amount to increase/decrease the property value. + * @param {Number} selStart + * Starting index of the value. + * @param {Number} selEnd + * Ending index of the value. + * @return {Object} object with properties 'value', 'start', and 'end'. + */ + #incrementCSSValue(value, increment, selStart, selEnd) { + const range = this.#parseCSSValue(value, selStart); + const type = range?.type || ""; + const rawValue = range ? value.substring(range.start, range.end) : ""; + const preRawValue = range ? value.substr(0, range.start) : ""; + const postRawValue = range ? value.substr(range.end) : ""; + let info; + + let incrementedValue = null, + selection; + if (type === "num") { + if (rawValue == "0") { + info = {}; + info.units = this.#findCompatibleUnit(preRawValue, postRawValue); + } + + const newValue = this.#incrementRawValue(rawValue, increment, info); + if (newValue !== null) { + incrementedValue = newValue; + selection = [0, incrementedValue.length]; + } + } else if (type === "hex") { + const exprOffset = selStart - range.start; + const exprOffsetEnd = selEnd - range.start; + const newValue = this.#incHexColor( + rawValue, + increment, + exprOffset, + exprOffsetEnd + ); + if (newValue) { + incrementedValue = newValue.value; + selection = newValue.selection; + } + } else { + if (type === "rgb" || type === "hsl" || type === "hwb") { + info = {}; + const isCSS4Color = !value.includes(","); + // In case the value uses the new syntax of the CSS Color 4 specification, + // it is split by the spaces and the slash separating the alpha value + // between the different color components. + // Example: rgb(255 0 0 / 0.5) + // Otherwise, the value is represented using the old color syntax and is + // split by the commas between the color components. + // Example: rgba(255, 0, 0, 0.5) + const part = + value + .substring(range.start, selStart) + .split(isCSS4Color ? / ?\/ ?| / : ",").length - 1; + if (part === 3) { + // alpha + info.minValue = 0; + info.maxValue = 1; + } else if (type === "rgb") { + info.minValue = 0; + info.maxValue = 255; + } else if (part !== 0) { + // hsl or hwb percentage + info.minValue = 0; + info.maxValue = 100; + + // select the previous number if the selection is at the end of a + // percentage sign. + if (value.charAt(selStart - 1) === "%") { + --selStart; + } + } + } + return this.#incrementGenericValue( + value, + increment, + selStart, + selEnd, + info + ); + } + + if (incrementedValue === null) { + return null; + } + + return { + value: preRawValue + incrementedValue + postRawValue, + start: range.start + selection[0], + end: range.start + selection[1], + }; + } + + /** + * Find a compatible unit to use for a CSS number value inserted between the + * provided beforeValue and afterValue. The compatible unit will be picked + * from a selection of default units corresponding to supported CSS value + * dimensions (distance, angle, duration). + * + * @param {String} beforeValue + * The string preceeding the number value in the current property + * value. + * @param {String} afterValue + * The string following the number value in the current property value. + * @return {String} a valid unit that can be used for this number value or + * empty string if no match could be found. + */ + #findCompatibleUnit(beforeValue, afterValue) { + if (!this.property || !this.property.name) { + return ""; + } + + // A DOM element is used to test the validity of various units. This is to + // avoid having to do an async call to the server to get this information. + const el = this.doc.createElement("div"); + + // Cycle through unitless (""), pixels, degrees and seconds. + const units = ["", "px", "deg", "s"]; + for (const unit of units) { + const value = beforeValue + "1" + unit + afterValue; + el.style.setProperty(this.property.name, ""); + el.style.setProperty(this.property.name, value); + // The property was set to `""` first, so if the value is no longer `""`, + // it means that the second `setProperty` call set a valid property and we + // can use this unit. + if (el.style.getPropertyValue(this.property.name) !== "") { + return unit; + } + } + return ""; + } + + /** + * Parses the property value and type. + * + * @param {String} value + * Property value. + * @param {Number} offset + * Starting index of value. + * @return {Object} object with properties 'value', 'start', 'end', and + * 'type'. + */ + #parseCSSValue(value, offset) { + /* eslint-disable max-len */ + const reSplitCSS = + /(?<url>url\("?[^"\)]+"?\)?)|(?<rgb>rgba?\([^)]*\)?)|(?<hsl>hsla?\([^)]*\)?)|(?<hwb>hwb\([^)]*\)?)|(?<hex>#[\dA-Fa-f]+)|(?<number>-?\d*\.?\d+(%|[a-z]{1,4})?)|"([^"]*)"?|'([^']*)'?|([^,\s\/!\(\)]+)|(!(.*)?)/; + /* eslint-enable */ + let start = 0; + let m; + + // retreive values from left to right until we find the one at our offset + while ((m = reSplitCSS.exec(value)) && m.index + m[0].length < offset) { + value = value.substring(m.index + m[0].length); + start += m.index + m[0].length; + offset -= m.index + m[0].length; + } + + if (!m) { + return null; + } + + let type; + if (m.groups.url) { + type = "url"; + } else if (m.groups.rgb) { + type = "rgb"; + } else if (m.groups.hsl) { + type = "hsl"; + } else if (m.groups.hwb) { + type = "hwb"; + } else if (m.groups.hex) { + type = "hex"; + } else if (m.groups.number) { + type = "num"; + } + + return { + value: m[0], + start: start + m.index, + end: start + m.index + m[0].length, + type, + }; + } + + /** + * Increment the property value for types other than + * number or hex, such as rgb, hsl, hwb, and file names. + * + * @param {String} value + * Property value. + * @param {Number} increment + * Amount to increment/decrement. + * @param {Number} offset + * Starting index of the property value. + * @param {Number} offsetEnd + * Ending index of the property value. + * @param {Object} info + * Object with details about the property value. + * @return {Object} object with properties 'value', 'start', and 'end'. + */ + #incrementGenericValue(value, increment, offset, offsetEnd, info) { + // Try to find a number around the cursor to increment. + let start, end; + // Check if we are incrementing in a non-number context (such as a URL) + if ( + /^-?[0-9.]/.test(value.substring(offset, offsetEnd)) && + !/\d/.test(value.charAt(offset - 1) + value.charAt(offsetEnd)) + ) { + // We have a number selected, possibly with a suffix, and we are not in + // the disallowed case of just part of a known number being selected. + // Use that number. + start = offset; + end = offsetEnd; + } else { + // Parse periods as belonging to the number only if we are in a known + // number context. (This makes incrementing the 1 in 'image1.gif' work.) + const pattern = "[" + (info ? "0-9." : "0-9") + "]*"; + const before = new RegExp(pattern + "$").exec(value.substr(0, offset))[0] + .length; + const after = new RegExp("^" + pattern).exec(value.substr(offset))[0] + .length; + + start = offset - before; + end = offset + after; + + // Expand the number to contain an initial minus sign if it seems + // free-standing. + if ( + value.charAt(start - 1) === "-" && + (start - 1 === 0 || /[ (:,='"]/.test(value.charAt(start - 2))) + ) { + --start; + } + } + + if (start !== end) { + // Include percentages as part of the incremented number (they are + // common enough). + if (value.charAt(end) === "%") { + ++end; + } + + const first = value.substr(0, start); + let mid = value.substring(start, end); + const last = value.substr(end); + + mid = this.#incrementRawValue(mid, increment, info); + + if (mid !== null) { + return { + value: first + mid + last, + start, + end: start + mid.length, + }; + } + } + + return null; + } + + /** + * Increment the property value for numbers. + * + * @param {String} rawValue + * Raw value to increment. + * @param {Number} increment + * Amount to increase/decrease the raw value. + * @param {Object} info + * Object with info about the property value. + * @return {String} the incremented value. + */ + #incrementRawValue(rawValue, increment, info) { + const num = parseFloat(rawValue); + + if (isNaN(num)) { + return null; + } + + const number = /\d+(\.\d+)?/.exec(rawValue); + + let units = rawValue.substr(number.index + number[0].length); + if (info && "units" in info) { + units = info.units; + } + + // avoid rounding errors + let newValue = Math.round((num + increment) * 1000) / 1000; + + if (info && "minValue" in info) { + newValue = Math.max(newValue, info.minValue); + } + if (info && "maxValue" in info) { + newValue = Math.min(newValue, info.maxValue); + } + + newValue = newValue.toString(); + + return newValue + units; + } + + /** + * Increment the property value for hex. + * + * @param {String} value + * Property value. + * @param {Number} increment + * Amount to increase/decrease the property value. + * @param {Number} offset + * Starting index of the property value. + * @param {Number} offsetEnd + * Ending index of the property value. + * @return {Object} object with properties 'value' and 'selection'. + */ + #incHexColor(rawValue, increment, offset, offsetEnd) { + // Return early if no part of the rawValue is selected. + if (offsetEnd > rawValue.length && offset >= rawValue.length) { + return null; + } + if (offset < 1 && offsetEnd <= 1) { + return null; + } + // Ignore the leading #. + rawValue = rawValue.substr(1); + --offset; + --offsetEnd; + + // Clamp the selection to within the actual value. + offset = Math.max(offset, 0); + offsetEnd = Math.min(offsetEnd, rawValue.length); + offsetEnd = Math.max(offsetEnd, offset); + + // Normalize #ABC -> #AABBCC. + if (rawValue.length === 3) { + rawValue = + rawValue.charAt(0) + + rawValue.charAt(0) + + rawValue.charAt(1) + + rawValue.charAt(1) + + rawValue.charAt(2) + + rawValue.charAt(2); + offset *= 2; + offsetEnd *= 2; + } + + // Normalize #ABCD -> #AABBCCDD. + if (rawValue.length === 4) { + rawValue = + rawValue.charAt(0) + + rawValue.charAt(0) + + rawValue.charAt(1) + + rawValue.charAt(1) + + rawValue.charAt(2) + + rawValue.charAt(2) + + rawValue.charAt(3) + + rawValue.charAt(3); + offset *= 2; + offsetEnd *= 2; + } + + if (rawValue.length !== 6 && rawValue.length !== 8) { + return null; + } + + // If no selection, increment an adjacent color, preferably one to the left. + if (offset === offsetEnd) { + if (offset === 0) { + offsetEnd = 1; + } else { + offset = offsetEnd - 1; + } + } + + // Make the selection cover entire parts. + offset -= offset % 2; + offsetEnd += offsetEnd % 2; + + // Remap the increments from [0.1, 1, 10] to [1, 1, 16]. + if (increment > -1 && increment < 1) { + increment = increment < 0 ? -1 : 1; + } + if (Math.abs(increment) === 10) { + increment = increment < 0 ? -16 : 16; + } + + const isUpper = rawValue.toUpperCase() === rawValue; + + for (let pos = offset; pos < offsetEnd; pos += 2) { + // Increment the part in [pos, pos+2). + let mid = rawValue.substr(pos, 2); + const value = parseInt(mid, 16); + + if (isNaN(value)) { + return null; + } + + mid = Math.min(Math.max(value + increment, 0), 255).toString(16); + + while (mid.length < 2) { + mid = "0" + mid; + } + if (isUpper) { + mid = mid.toUpperCase(); + } + + rawValue = rawValue.substr(0, pos) + mid + rawValue.substr(pos + 2); + } + + return { + value: "#" + rawValue, + selection: [offset + 1, offsetEnd + 1], + }; + } + + /** + * Cycle through the autocompletion suggestions in the popup. + * + * @param {Boolean} reverse + * true to select previous item from the popup. + * @param {Boolean} noSelect + * true to not select the text after selecting the newly selectedItem + * from the popup. + */ + #cycleCSSSuggestion(reverse, noSelect) { + // selectedItem can be null when nothing is selected in an empty editor. + const { label, preLabel } = this.popup.selectedItem || { + label: "", + preLabel: "", + }; + if (reverse) { + this.popup.selectPreviousItem(); + } else { + this.popup.selectNextItem(); + } + + this.#selectedIndex = this.popup.selectedIndex; + const input = this.input; + let pre = ""; + + if (input.selectionStart < input.selectionEnd) { + pre = input.value.slice(0, input.selectionStart); + } else { + pre = input.value.slice( + 0, + input.selectionStart - label.length + preLabel.length + ); + } + + const post = input.value.slice(input.selectionEnd, input.value.length); + const item = this.popup.selectedItem; + const toComplete = item.label.slice(item.preLabel.length); + input.value = pre + toComplete + post; + + if (!noSelect) { + input.setSelectionRange(pre.length, pre.length + toComplete.length); + } else { + input.setSelectionRange( + pre.length + toComplete.length, + pre.length + toComplete.length + ); + } + + this.#updateSize(); + // This emit is mainly for the purpose of making the test flow simpler. + this.emit("after-suggest"); + } + + /** + * Call the client's done handler and clear out. + */ + #apply(direction, key) { + if (this.#applied) { + return null; + } + + this.#applied = true; + + if (this.done) { + const val = this.cancelled ? this.initial : this.currentInputValue; + return this.done(val, !this.cancelled, direction, key); + } + + return null; + } + + /** + * Hide the popup and cancel any pending popup opening. + */ + #onWindowBlur = () => { + if (this.popup && this.popup.isOpen) { + this.popup.hidePopup(); + } + + if (this.#openPopupTimeout) { + this.doc.defaultView.clearTimeout(this.#openPopupTimeout); + } + }; + + /** + * Event handler called when the inplace-editor's input loses focus. + */ + #onBlur = event => { + if ( + event && + this.popup && + this.popup.isOpen && + this.popup.selectedIndex >= 0 + ) { + this.#acceptPopupSuggestion(); + } else { + this.#apply(); + this.#clear(); + } + }; + + /** + * Before offering autocomplete, set this.gridLineNames as the line names + * of the current grid, if they exist. + * + * @param {Function} getGridLineNames + * A function which gets the line names of the current grid. + */ + async #getGridNamesBeforeCompletion(getGridLineNames) { + if ( + getGridLineNames && + this.property && + GRID_PROPERTY_NAMES.includes(this.property.name) + ) { + this.gridLineNames = await getGridLineNames(); + } + + if ( + this.contentType == CONTENT_TYPES.CSS_VALUE && + this.input && + this.input.value == "" + ) { + this.#maybeSuggestCompletion(false); + } + } + + /** + * Event handler called by the autocomplete popup when receiving a click + * event. + */ + #onAutocompletePopupClick = () => { + this.#acceptPopupSuggestion(); + }; + + #acceptPopupSuggestion() { + let label, preLabel; + + if (this.#selectedIndex === undefined) { + ({ label, preLabel } = this.popup.getItemAtIndex( + this.popup.selectedIndex + )); + } else { + ({ label, preLabel } = this.popup.getItemAtIndex(this.#selectedIndex)); + } + + const input = this.input; + + let pre = ""; + + // CSS_MIXED needs special treatment here to make it so that + // multiple presses of tab will cycle through completions, but + // without selecting the completed text. However, this same + // special treatment will do the wrong thing for other editing + // styles. + if ( + input.selectionStart < input.selectionEnd || + this.contentType !== CONTENT_TYPES.CSS_MIXED + ) { + pre = input.value.slice(0, input.selectionStart); + } else { + pre = input.value.slice( + 0, + input.selectionStart - label.length + preLabel.length + ); + } + const post = input.value.slice(input.selectionEnd, input.value.length); + const item = this.popup.selectedItem; + this.#selectedIndex = this.popup.selectedIndex; + const toComplete = item.label.slice(item.preLabel.length); + input.value = pre + toComplete + post; + input.setSelectionRange( + pre.length + toComplete.length, + pre.length + toComplete.length + ); + this.#updateSize(); + // Wait for the popup to hide and then focus input async otherwise it does + // not work. + const onPopupHidden = () => { + this.popup.off("popup-closed", onPopupHidden); + this.doc.defaultView.setTimeout(() => { + input.focus(); + this.emit("after-suggest"); + }, 0); + }; + this.popup.on("popup-closed", onPopupHidden); + this.#hideAutocompletePopup(); + } + + /** + * Handle the input field's keypress event. + */ + // eslint-disable-next-line complexity + #onKeyPress = event => { + let prevent = false; + + const key = event.keyCode; + const input = this.input; + + // We want to autoclose some characters, remember the pressed key in order to process + // it later on in maybeSuggestionCompletion(). + this.#pressedKey = event.key; + + const multilineNavigation = + !this.#isSingleLine() && isKeyIn(key, "UP", "DOWN", "LEFT", "RIGHT"); + const isPlainText = this.contentType == CONTENT_TYPES.PLAIN_TEXT; + const isPopupOpen = this.popup && this.popup.isOpen; + + let increment = 0; + if (!isPlainText && !multilineNavigation) { + increment = this.#getIncrement(event); + } + + if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) { + this.#preventSuggestions = true; + } + + let cycling = false; + if (increment && this.#incrementValue(increment)) { + this.#updateSize(); + prevent = true; + cycling = true; + } + + if (isPopupOpen && isKeyIn(key, "UP", "DOWN", "PAGE_UP", "PAGE_DOWN")) { + prevent = true; + cycling = true; + this.#cycleCSSSuggestion(isKeyIn(key, "UP", "PAGE_UP")); + this.#doValidation(); + } + + if (isKeyIn(key, "BACK_SPACE", "DELETE", "LEFT", "RIGHT", "HOME", "END")) { + if (isPopupOpen && this.currentInputValue !== "") { + this.#hideAutocompletePopup(); + } + } else if ( + // We may show the suggestion completion if Ctrl+space is pressed, or if an + // otherwise unhandled key is pressed and the user is not cycling through the + // options in the pop-up menu, it is not an expanded shorthand property, and no + // modifier key is pressed. + (event.key === " " && event.ctrlKey) || + (!cycling && + !multilineNavigation && + !event.metaKey && + !event.altKey && + !event.ctrlKey) + ) { + this.#maybeSuggestCompletion(true); + } + + if (this.multiline && event.shiftKey && isKeyIn(key, "RETURN")) { + prevent = false; + } else if ( + this.#advanceChars(event.charCode, input.value, input.selectionStart) || + isKeyIn(key, "RETURN", "TAB") + ) { + prevent = true; + + const ctrlOrCmd = isOSX ? event.metaKey : event.ctrlKey; + + let direction; + if ( + (this.stopOnReturn && isKeyIn(key, "RETURN") && !ctrlOrCmd) || + (this.stopOnTab && !event.shiftKey && isKeyIn(key, "TAB")) || + (this.stopOnShiftTab && event.shiftKey && isKeyIn(key, "TAB")) + ) { + direction = null; + } else if (event.shiftKey && isKeyIn(key, "TAB")) { + direction = FOCUS_BACKWARD; + } else { + direction = FOCUS_FORWARD; + } + + // Now we don't want to suggest anything as we are moving out. + this.#preventSuggestions = true; + // But we still want to show suggestions for css values. i.e. moving out + // of css property input box in forward direction + if ( + this.contentType == CONTENT_TYPES.CSS_PROPERTY && + direction == FOCUS_FORWARD + ) { + this.#preventSuggestions = false; + } + + if (isKeyIn(key, "TAB") && this.contentType == CONTENT_TYPES.CSS_MIXED) { + if (this.popup && input.selectionStart < input.selectionEnd) { + event.preventDefault(); + input.setSelectionRange(input.selectionEnd, input.selectionEnd); + this.emit("after-suggest"); + return; + } else if (this.popup && this.popup.isOpen) { + event.preventDefault(); + this.#cycleCSSSuggestion(event.shiftKey, true); + return; + } + } + + this.#apply(direction, key); + + // Close the popup if open + if (this.popup && this.popup.isOpen) { + this.#hideAutocompletePopup(); + } + + if (direction !== null && focusManager.focusedElement === input) { + // If the focused element wasn't changed by the done callback, + // move the focus as requested. + const next = moveFocus( + this.doc.defaultView, + direction, + this.focusEditableFieldAfterApply, + this.focusEditableFieldContainerSelector + ); + + // If the next node to be focused has been tagged as an editable + // node, trigger editing using the configured event + if (next && next.ownerDocument === this.doc && next._editable) { + const e = this.doc.createEvent("Event"); + e.initEvent(next._trigger, true, true); + next.dispatchEvent(e); + } + } + + this.#clear(); + } else if (isKeyIn(key, "ESCAPE")) { + // Cancel and blur ourselves. + // Now we don't want to suggest anything as we are moving out. + this.#preventSuggestions = true; + // Close the popup if open + if (this.popup && this.popup.isOpen) { + this.#hideAutocompletePopup(); + } + prevent = true; + this.cancelled = true; + this.#apply(); + this.#clear(); + event.stopPropagation(); + } else if (isKeyIn(key, "SPACE")) { + // No need for leading spaces here. This is particularly + // noticable when adding a property: it's very natural to type + // <name>: (which advances to the next property) then spacebar. + prevent = !input.value; + } + + if (prevent) { + event.preventDefault(); + } + }; + + #onContextMenu = event => { + if (this.contextMenu) { + // Call stopPropagation() and preventDefault() here so that avoid to show default + // context menu in about:devtools-toolbox. See Bug 1515265. + event.stopPropagation(); + event.preventDefault(); + this.contextMenu(event); + } + }; + + /** + * Open the autocomplete popup, adding a custom click handler and classname. + * + * @param {Number} offset + * X-offset relative to the input starting edge. + * @param {Number} selectedIndex + * The index of the item that should be selected. Use -1 to have no + * item selected. + */ + #openAutocompletePopup(offset, selectedIndex) { + this.popup.on("popup-click", this.#onAutocompletePopupClick); + this.popup.openPopup(this.input, offset, 0, selectedIndex); + } + + /** + * Remove the custom classname and click handler and close the autocomplete + * popup. + */ + #hideAutocompletePopup() { + this.popup.off("popup-click", this.#onAutocompletePopupClick); + this.popup.hidePopup(); + } + + /** + * Get the increment/decrement step to use for the provided key event. + */ + #getIncrement(event) { + const getSmallIncrementKey = evt => { + if (isOSX) { + return evt.altKey; + } + return evt.ctrlKey; + }; + + const largeIncrement = 100; + const mediumIncrement = 10; + const smallIncrement = 0.1; + + let increment = 0; + const key = event.keyCode; + + if (isKeyIn(key, "UP", "PAGE_UP")) { + increment = 1 * this.defaultIncrement; + } else if (isKeyIn(key, "DOWN", "PAGE_DOWN")) { + increment = -1 * this.defaultIncrement; + } + + if (event.shiftKey && !getSmallIncrementKey(event)) { + if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) { + increment *= largeIncrement; + } else { + increment *= mediumIncrement; + } + } else if (getSmallIncrementKey(event) && !event.shiftKey) { + increment *= smallIncrement; + } + + return increment; + } + + /** + * Handle the input field's keyup event. + */ + #onKeyup = () => { + this.#applied = false; + }; + + /** + * Handle changes to the input text. + */ + #onInput = () => { + // Validate the entered value. + this.#doValidation(); + + // Update size if we're autosizing. + if (this.#measurement) { + this.#updateSize(); + } + + // Call the user's change handler if available. + if (this.change) { + this.change(this.currentInputValue); + } + + // In case that the current value becomes empty, show the suggestions if needed. + if (this.currentInputValue === "" && this.showSuggestCompletionOnEmpty) { + this.#maybeSuggestCompletion(false); + } + }; + + /** + * Stop propagation on the provided event + */ + #stopEventPropagation(e) { + e.stopPropagation(); + } + + /** + * Fire validation callback with current input + */ + #doValidation() { + if (this.validate && this.input) { + this.validate(this.input.value); + } + } + + /** + * Handles displaying suggestions based on the current input. + * + * @param {Boolean} autoInsert + * Pass true to automatically insert the most relevant suggestion. + */ + #maybeSuggestCompletion(autoInsert) { + // Input can be null in cases when you intantaneously switch out of it. + if (!this.input) { + return; + } + + const preTimeoutQuery = this.input.value; + + // Since we are calling this method from a keypress event handler, the + // |input.value| does not include currently typed character. Thus we perform + // this method async. + // eslint-disable-next-line complexity + this.#openPopupTimeout = this.doc.defaultView.setTimeout(() => { + if (this.#preventSuggestions) { + this.#preventSuggestions = false; + return; + } + if (this.contentType == CONTENT_TYPES.PLAIN_TEXT) { + return; + } + if (!this.input) { + return; + } + const input = this.input; + // The length of input.value should be increased by 1 + if (input.value.length - preTimeoutQuery.length > 1) { + return; + } + const query = input.value.slice(0, input.selectionStart); + let startCheckQuery = query; + if (query == null) { + return; + } + // If nothing is selected and there is a word (\w) character after the cursor, do + // not autocomplete. + if ( + input.selectionStart == input.selectionEnd && + input.selectionStart < input.value.length + ) { + const nextChar = input.value.slice(input.selectionStart)[0]; + // Check if the next character is a valid word character, no suggestion should be + // provided when preceeding a word. + if (/[\w-]/.test(nextChar)) { + // This emit is mainly to make the test flow simpler. + this.emit("after-suggest", "nothing to autocomplete"); + return; + } + } + + let list = []; + let postLabelValues = []; + + if (this.contentType == CONTENT_TYPES.CSS_PROPERTY) { + list = this.#getCSSVariableNames().concat(this.#getCSSPropertyList()); + } else if (this.contentType == CONTENT_TYPES.CSS_VALUE) { + // Get the last query to be completed before the caret. + const match = /([^\s,.\/]+$)/.exec(query); + if (match) { + startCheckQuery = match[0]; + } else { + startCheckQuery = ""; + } + + // Check if the query to be completed is a CSS variable. + const varMatch = /^var\(([^\s]+$)/.exec(startCheckQuery); + + if (varMatch && varMatch.length == 2) { + startCheckQuery = varMatch[1]; + list = this.#getCSSVariableNames(); + postLabelValues = list.map(varName => + this.#getCSSVariableValue(varName) + ); + } else { + list = [ + "!important", + ...this.#getCSSValuesForPropertyName(this.property.name), + ]; + } + + if (query == "") { + // Do not suggest '!important' without any manually typed character. + list.splice(0, 1); + } + } else if ( + this.contentType == CONTENT_TYPES.CSS_MIXED && + /^\s*style\s*=/.test(query) + ) { + // Check if the style attribute is closed before the selection. + const styleValue = query.replace(/^\s*style\s*=\s*/, ""); + // Look for a quote matching the opening quote (single or double). + if (/^("[^"]*"|'[^']*')/.test(styleValue)) { + // This emit is mainly to make the test flow simpler. + this.emit("after-suggest", "nothing to autocomplete"); + return; + } + + // Detecting if cursor is at property or value; + const match = query.match(/([:;"'=]?)\s*([^"';:=]+)?$/); + if (match && match.length >= 2) { + if (match[1] == ":") { + // We are in CSS value completion + const propertyName = query.match( + /[;"'=]\s*([^"';:= ]+)\s*:\s*[^"';:=]*$/ + )[1]; + list = [ + "!important;", + ...this.#getCSSValuesForPropertyName(propertyName), + ]; + const matchLastQuery = /([^\s,.\/]+$)/.exec(match[2] || ""); + if (matchLastQuery) { + startCheckQuery = matchLastQuery[0]; + } else { + startCheckQuery = ""; + } + if (!match[2]) { + // Don't suggest '!important' without any manually typed character + list.splice(0, 1); + } + } else if (match[1]) { + // We are in CSS property name completion + list = this.#getCSSVariableNames().concat( + this.#getCSSPropertyList() + ); + startCheckQuery = match[2]; + } + if (startCheckQuery == null) { + // This emit is mainly to make the test flow simpler. + this.emit("after-suggest", "nothing to autocomplete"); + return; + } + } + } + + if (!this.popup) { + // This emit is mainly to make the test flow simpler. + this.emit("after-suggest", "no popup"); + return; + } + + const finalList = []; + const length = list.length; + for (let i = 0, count = 0; i < length && count < MAX_POPUP_ENTRIES; i++) { + if (startCheckQuery != null && list[i].startsWith(startCheckQuery)) { + count++; + finalList.push({ + preLabel: startCheckQuery, + label: list[i], + postLabel: postLabelValues[i] ? postLabelValues[i] : "", + }); + } else if (count > 0) { + // Since count was incremented, we had already crossed the entries + // which would have started with query, assuming that list is sorted. + break; + } else if (startCheckQuery != null && list[i][0] > startCheckQuery[0]) { + // We have crossed all possible matches alphabetically. + break; + } + } + + // Sort items starting with [a-z0-9] first, to make sure vendor-prefixed + // values and "!important" are suggested only after standard values. + finalList.sort((item1, item2) => { + // Get the expected alphabetical comparison between the items. + let comparison = item1.label.localeCompare(item2.label); + if (/^\w/.test(item1.label) != /^\w/.test(item2.label)) { + // One starts with [a-z0-9], one does not: flip the comparison. + comparison = -1 * comparison; + } + return comparison; + }); + + let index = 0; + if (startCheckQuery) { + // Only select a "best" suggestion when the user started a query. + const cssValues = finalList.map(item => item.label); + index = findMostRelevantCssPropertyIndex(cssValues); + } + + // Insert the most relevant item from the final list as the input value. + if (autoInsert && finalList[index]) { + const item = finalList[index].label; + input.value = + query + + item.slice(startCheckQuery.length) + + input.value.slice(query.length); + input.setSelectionRange( + query.length, + query.length + item.length - startCheckQuery.length + ); + this.#updateSize(); + } + + // Display the list of suggestions if there are more than one. + if (finalList.length > 1) { + // Calculate the popup horizontal offset. + const indent = this.input.selectionStart - startCheckQuery.length; + let offset = indent * this.inputCharDimensions.width; + offset = this.#isSingleLine() ? offset : 0; + + // Select the most relevantItem if autoInsert is allowed + const selectedIndex = autoInsert ? index : -1; + + // Open the suggestions popup. + this.popup.setItems(finalList, selectedIndex); + this.#openAutocompletePopup(offset, selectedIndex); + } else { + this.#hideAutocompletePopup(); + } + + this.#autocloseParenthesis(); + + // This emit is mainly for the purpose of making the test flow simpler. + this.emit("after-suggest"); + this.#doValidation(); + }, 0); + } + + /** + * Automatically add closing parenthesis and skip closing parenthesis when needed. + */ + #autocloseParenthesis() { + // Split the current value at the cursor index to rebuild the string. + const parts = this.#splitStringAt( + this.input.value, + this.input.selectionStart + ); + + // Lookup the character following the caret to know if the string should be modified. + const nextChar = parts[1][0]; + + // Autocomplete closing parenthesis if the last key pressed was "(" and the next + // character is not a "word" character. + if (this.#pressedKey == "(" && !isWordChar(nextChar)) { + this.#updateValue(parts[0] + ")" + parts[1]); + } + + // Skip inserting ")" if the next character is already a ")" (note that we actually + // insert and remove the extra ")" here, as the input has already been modified). + if (this.#pressedKey == ")" && nextChar == ")") { + this.#updateValue(parts[0] + parts[1].substring(1)); + } + + this.#pressedKey = null; + } + + /** + * Update the current value of the input while preserving the caret position. + */ + #updateValue(str) { + const start = this.input.selectionStart; + this.input.value = str; + this.input.setSelectionRange(start, start); + this.#updateSize(); + } + + /** + * Split the provided string at the provided index. Returns an array of two strings. + * _splitStringAt("1234567", 3) will return ["123", "4567"] + */ + #splitStringAt(str, index) { + return [str.substring(0, index), str.substring(index, str.length)]; + } + + /** + * Check if the current input is displaying more than one line of text. + * + * @return {Boolean} true if the input has a single line of text + */ + #isSingleLine() { + if (!this.multiline) { + // Checking the inputCharDimensions.height only makes sense with multiline + // editors, because the textarea is directly sized using + // inputCharDimensions (see _updateSize()). + // By definition if !this.multiline, then we are in single line mode. + return true; + } + const inputRect = this.input.getBoundingClientRect(); + return inputRect.height < 2 * this.inputCharDimensions.height; + } + + /** + * Returns the list of CSS properties to use for the autocompletion. This + * method is overridden by tests in order to use mocked suggestion lists. + * + * @return {Array} array of CSS property names (Strings) + */ + #getCSSPropertyList() { + return this.cssProperties.getNames().sort(); + } + + /** + * Returns a list of CSS values valid for a provided property name to use for + * the autocompletion. This method is overridden by tests in order to use + * mocked suggestion lists. + * + * @param {String} propertyName + * @return {Array} array of CSS property values (Strings) + */ + #getCSSValuesForPropertyName(propertyName) { + const gridLineList = []; + if (this.gridLineNames) { + if (GRID_ROW_PROPERTY_NAMES.includes(this.property.name)) { + gridLineList.push(...this.gridLineNames.rows); + } + if (GRID_COL_PROPERTY_NAMES.includes(this.property.name)) { + gridLineList.push(...this.gridLineNames.cols); + } + } + // Must be alphabetically sorted before comparing the results with + // the user input, otherwise we will lose some results. + return gridLineList + .concat(this.cssProperties.getValues(propertyName)) + .sort(); + } + + /** + * Returns the list of all CSS variables to use for the autocompletion. + * + * @return {Array} array of CSS variable names (Strings) + */ + #getCSSVariableNames() { + return Array.from(this.cssVariables.keys()).sort(); + } + + /** + * Returns the variable's value for the given CSS variable name. + * + * @param {String} varName + * The variable name to retrieve the value of + * @return {String} the variable value to the given CSS variable name + */ + #getCSSVariableValue(varName) { + return this.cssVariables.get(varName); + } +} + +exports.InplaceEditor = InplaceEditor; + +/** + * Copy text-related styles from one element to another. + */ +function copyTextStyles(from, to) { + const win = from.ownerDocument.defaultView; + const style = win.getComputedStyle(from); + + to.style.fontFamily = style.fontFamily; + to.style.fontSize = style.fontSize; + to.style.fontWeight = style.fontWeight; + to.style.fontStyle = style.fontStyle; +} + +/** + * Copy all styles which could have an impact on the element size. + */ +function copyAllStyles(from, to) { + const win = from.ownerDocument.defaultView; + const style = win.getComputedStyle(from); + + copyTextStyles(from, to); + to.style.lineHeight = style.lineHeight; + + // If box-sizing is set to border-box, box model styles also need to be + // copied. + const boxSizing = style.boxSizing; + if (boxSizing === "border-box") { + to.style.boxSizing = boxSizing; + copyBoxModelStyles(from, to); + } +} + +/** + * Copy box model styles that can impact width and height measurements when box- + * sizing is set to "border-box" instead of "content-box". + * + * @param {DOMNode} from + * the element from which styles are copied + * @param {DOMNode} to + * the element on which copied styles are applied + */ +function copyBoxModelStyles(from, to) { + const properties = [ + // Copy all paddings. + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + // Copy border styles. + "borderTopStyle", + "borderRightStyle", + "borderBottomStyle", + "borderLeftStyle", + // Copy border widths. + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + ]; + + const win = from.ownerDocument.defaultView; + const style = win.getComputedStyle(from); + for (const property of properties) { + to.style[property] = style[property]; + } +} + +/** + * Trigger a focus change similar to pressing tab/shift-tab. + * + * @param {Window} win: The window into which the focus should be moved + * @param {Number} direction: See Services.focus.MOVEFOCUS_* + * @param {Boolean} focusEditableField: Set to true to move the focus to the previous/next + * editable field. If not set, the focus will be set on the next focusable element. + * The function might still put the focus on a non-editable field, if none is found + * within the element matching focusEditableFieldContainerSelector + * @param {String} focusEditableFieldContainerSelector: A CSS selector the editabled element + * we want to focus should be in. This is only used when focusEditableField is set + * to true. + * It's important to pass a boundary otherwise we might hit an infinite loop + * @returns {Element} The element that received the focus + */ +function moveFocus( + win, + direction, + focusEditableField, + focusEditableFieldContainerSelector +) { + if (!focusEditableField) { + return focusManager.moveFocus(win, null, direction, 0); + } + + if (!win.document.querySelector(focusEditableFieldContainerSelector)) { + console.error( + focusEditableFieldContainerSelector, + "can't be found in document.", + `focusEditableFieldContainerSelector should match an existing element` + ); + return focusManager.moveFocus(win, null, direction, 0); + } + + // Let's look for the next/previous editable element to focus + while (true) { + const focusedElement = focusManager.moveFocus(win, null, direction, 0); + // The _editable property is set by the InplaceEditor on the target element + if (focusedElement._editable) { + return focusedElement; + } + + // If the focus was moved outside of the container, simply return the focused element + if (!focusedElement.closest(focusEditableFieldContainerSelector)) { + return focusedElement; + } + } +} |