/* 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");
loader.lazyRequireGetter(
this,
"InspectorCSSParserWrapper",
"resource://devtools/shared/css/lexer.js",
true
);
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 {String} options.inputClass:
* An optional class to be added to the input.
* @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.getCssVariables: A function that returns a Map containing
* all CSS variables. The Map key is the variable name, the value is the variable value
* @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;
// And an attribute that can be used to target
element.setAttribute(
"editable",
"");
// 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.getCssVariables = options.getCssVariables
? options.getCssVariables.bind(
this)
:
null;
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(
"wheel",
this.#onWheel, 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;
#variableNames;
#variables;
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.
* @param {String} options.inputClass:
* Optional class to 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");
if (options.inputClass) {
this.input.classList.add(options.inputClass);
}
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\(
"?[^"\)]+
"?\)?)|(?rgba?\([^)]*\)?)|(?hsla?\([^)]*\)?)|(?hwb\([^)]*\)?)|(?#[\dA-Fa-f]+)|(?-?\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, no
// modifier key is pressed, and the pressed key isn't Shift+Arrow(Up|Down).
(event.key ===
" " && event.ctrlKey) ||
(!cycling &&
!multilineNavigation &&
!event.metaKey &&
!event.altKey &&
!event.ctrlKey &&
// We only need to handle the case where the Shift key is pressed because maybeSuggestCompletion
// will trigger the completion because there are selected character here, and it
// will look like a "regular" completion with a suggested value. We don't need
// to care about other shift + key (e.g. LEFT, HOME, …), since we're not coming
// here for them.
!(isKeyIn(key,
"UP",
"DOWN") && event.shiftKey))
) {
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();
}
else {
this.cancelled =
true;
this.#apply();
this.#clear();
}
prevent =
true;
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 or wheel
* event.
*
* @param {Event} event
* The event from which the increment should be comuted
* @return {number} The computed increment value.
*/
#getIncrement(event) {
const largeIncrement = 100;
const mediumIncrement = 10;
const smallIncrement = 0.1;
let increment = 0;
let wheelUp =
false;
let wheelDown =
false;
if (event.type ===
"wheel") {
if (event.wheelDelta > 0) {
wheelUp =
true;
}
else if (event.wheelDelta < 0) {
wheelDown =
true;
}
}
const key = event.keyCode;
if (wheelUp || isKeyIn(key,
"UP",
"PAGE_UP")) {
increment = 1 *
this.defaultIncrement;
}
else if (wheelDown || isKeyIn(key,
"DOWN",
"PAGE_DOWN")) {
increment = -1 *
this.defaultIncrement;
}
const largeIncrementKeyPressed = event.shiftKey;
const smallIncrementKeyPressed =
this.#isSmallIncrementKeyPressed(event);
if (largeIncrementKeyPressed && !smallIncrementKeyPressed) {
if (isKeyIn(key,
"PAGE_UP",
"PAGE_DOWN")) {
increment *= largeIncrement;
}
else {
increment *= mediumIncrement;
}
}
else if (smallIncrementKeyPressed && !largeIncrementKeyPressed) {
increment *= smallIncrement;
}
return increment;
}
#isSmallIncrementKeyPressed = evt => {
if (isOSX) {
return evt.altKey;
}
return evt.ctrlKey;
};
/**
* 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);
}
};
/**
* Handle the input field's wheel event.
*
* @param {WheelEvent} event
*/
#onWheel = event => {
const isPlainText =
this.contentType == CONTENT_TYPES.PLAIN_TEXT;
let increment = 0;
if (!isPlainText) {
increment =
this.#getIncrement(event);
}
if (increment &&
this.#incrementValue(increment)) {
this.#updateSize();
event.preventDefault();
}
};
/**
* 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) {
// Build the context for the autocomplete
// TODO: We may want to parse the whole input, or at least, until we get into
// an empty state (e.g. if cursor is in a function, we might check what's after
// the cursor to build good autocomplete).
const lexer =
new InspectorCSSParserWrapper(query);
const functionStack = [];
let token;
// The last parsed token that isn't a whitespace or a comment
let lastMeaningfulToken;
let foundImportant =
false;
let importantState =
"";
let queryStartIndex = 0;
while ((token = lexer.nextToken())) {
const currentFunction = functionStack.at(-1);
if (
token.tokenType !==
"WhiteSpace" &&
token.tokenType !==
"Comment"
) {
lastMeaningfulToken = token;
if (currentFunction) {
currentFunction.tokens.push(token);
}
}
if (
token.tokenType ===
"Function" ||
token.tokenType ===
"ParenthesisBlock"
) {
functionStack.push({ fnToken: token, tokens: [] });
}
else if (token.tokenType ===
"CloseParenthesis") {
functionStack.pop();
}
if (
token.tokenType ===
"WhiteSpace" ||
token.tokenType ===
"Comma" ||
token.tokenType ===
"Function" ||
(token.tokenType ===
"Comment" &&
// The parser already returns a comment token for non-closed comment, like "/*".
// But we only want to start the completion after the comment is closed
// Make sure we have a closed comment,i.e. at least `/**/`
token.text.length >= 4 &&
token.text.endsWith(
"*/"))
) {
queryStartIndex = token.endOffset;
}
// Checking for the presence of !important (once is enough)
if (!foundImportant) {
// !important is composed of 2 tokens, `!` is a Delim, and `important` is an Ident.
// Here we have a potential start
if (token.tokenType ===
"Delim" && token.text ===
"!") {
importantState =
"!";
}
else if (importantState ===
"!") {
// If we saw the "!" char, then we need to have an "important" Ident
if (token.tokenType ===
"Ident" && token.text ===
"important") {
foundImportant =
true;
break;
}
else {
// otherwise, we can reset the state.
importantState =
"";
}
}
}
}
startCheckQuery = query.substring(queryStartIndex);
const lastFunctionEntry = functionStack.at(-1);
const functionValues = lastFunctionEntry
?
this.#getAutocompleteDataForFunction(lastFunctionEntry)
:
null;
// Don't autocomplete after !important
if (foundImportant) {
list = [];
postLabelValues = [];
}
else if (functionValues) {
list = functionValues.list;
postLabelValues = functionValues.postLabelValues;
}
else {
list =
this.#getCSSValuesForPropertyName(
this.property.name);
// Only show !important if:
if (
// we're not in a function
!functionStack.length &&
// and there is no non-whitespace items after the cursor
!input.value.slice(input.selectionStart).trim() &&
// and the last meaningful token wasn't a delimiter or a comma
lastMeaningfulToken &&
(lastMeaningfulToken.tokenType !==
"Delim" ||
lastMeaningfulToken.text !==
"/") &&
lastMeaningfulToken.tokenType !==
"Comma" &&
// and the input value doesn't start with ! ("!important" is parsed as a
// Delim, "!", and then an indent, "important", so we can't just check the
// last token)
!input.value.trim().startsWith(
"!")
) {
list.unshift(
"!important");
}
}
}
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);
}
/**
* Returns the autocomplete data for the passed function.
*
* @param {Object} functionStackEntry
* @param {InspectorCSSToken} functionStackEntry.fnToken: The token for the
* function call
* @returns {Object|null} Return null if there
's nothing specific to display for the function.
* Otherwise, return an object of the following shape:
* - {Array<String>} list: The list of autocomplete items
* - {Array<String>} postLabelValue: The list of autocomplete items
* post labels (e.g. for variable names, their values).
*/
#getAutocompleteDataForFunction(functionStackEntry) {
const functionName = functionStackEntry?.fnToken?.value;
if (!functionName) {
return null;
}
let list = [];
let postLabelValues = [];
if (functionName ===
"var") {
// We only want to return variables for the first parameters of var(), not for its
// fallback. If we get more than one tokens, and given we don
't get comments or
// whitespace, this means we
're in the fallback value already.
if (functionStackEntry.tokens.length > 1) {
// In such case we
'll use the default behavior
return null;
}
list = this.#getCSSVariableNames();
postLabelValues = list.map(varName => this.#getCSSVariableValue(varName));
} else if (functionName.includes(
"gradient")) {
// For gradient functions we want to
display named colors and color functions,
// but only if the user didn
't already entered a color token after the last comma.
list = this.#getCSSValuesForPropertyName(
"color");
}
// TODO: Handle other functions, e.g. color functions to autocomplete on relative
// color format (Bug 1898273), `color()` to suggest color space (Bug 1898277),
// `anchor()` to
display existing anchor names (Bug 1903278)
return { list, postLabelValues };
}
/**
* Automatically add closing parenthesis and skip closing parenthesis when needed.
*/
#autocloseParenthesis() {
// Split the current value at the cursor index to rebuild the string.
const { selectionStart, selectionEnd } = this.input;
const parts = this.#splitStringAt(
this.input.value,
// Use selectionEnd, so when an autocomplete item was inserted, we put the closing
// parenthesis after the suggestion
selectionEnd
);
// 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));
}
// set original selection range
this.input.setSelectionRange(selectionStart, selectionEnd);
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);
}
}
--> --------------------
--> maximum size reached
--> --------------------