2176 lines
69 KiB
JavaScript
2176 lines
69 KiB
JavaScript
/* 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.
|
|
* This may be called with the return value of the options.done callback (if it is passed).
|
|
* @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.
|
|
*
|
|
* @param {*|null} doneCallResult: When #clear is called after calling #apply, this will
|
|
* be the returned value of the call to options.done that is done there.
|
|
* Will be null when options.done is undefined.
|
|
*/
|
|
#clear(doneCallResult) {
|
|
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(doneCallResult);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
const onApplied = this.#apply();
|
|
this.#clear(onApplied);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
const onApplied = 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(onApplied);
|
|
} 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;
|
|
const onApplied = this.#apply();
|
|
this.#clear(onApplied);
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
// 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();
|
|
}
|
|
|
|
#getCSSVariablesMap() {
|
|
if (!this.getCssVariables) {
|
|
return null;
|
|
}
|
|
|
|
if (!this.#variables) {
|
|
this.#variables = this.getCssVariables();
|
|
}
|
|
return this.#variables;
|
|
}
|
|
|
|
/**
|
|
* Returns the list of all CSS variables to use for the autocompletion.
|
|
*
|
|
* @return {Array} array of CSS variable names (Strings)
|
|
*/
|
|
#getCSSVariableNames() {
|
|
if (!this.#variableNames) {
|
|
const variables = this.#getCSSVariablesMap();
|
|
if (!variables) {
|
|
return [];
|
|
}
|
|
this.#variableNames = Array.from(variables.keys()).sort();
|
|
}
|
|
return this.#variableNames;
|
|
}
|
|
|
|
/**
|
|
* 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.#getCSSVariablesMap()?.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;
|
|
}
|
|
}
|
|
}
|