/* 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.
*/
/** * 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;
}
// 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");
/* * 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;
}
if ( this.focusEditableFieldAfterApply &&
!this.focusEditableFieldContainerSelector
) { thrownew 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();
}
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";
}
if (this.doc.activeElement == this.input) { this.elt.focus();
}
this.input.remove(); this.input = null;
deletethis.elt.inplaceEditor; deletethis.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";
}
}
/** * 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;
// Call the user's change handler if available. if (this.change) { this.change(this.currentInputValue);
}
returntrue;
}
/** * 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];
}
} elseif (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;
} elseif (type === "rgb") {
info.minValue = 0;
info.maxValue = 255;
} elseif (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;
}
}
} returnthis.#incrementGenericValue(
value,
increment,
selStart,
selEnd,
info
);
}
/** * 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) { returnnull;
}
let type; if (m.groups.url) {
type = "url";
} elseif (m.groups.rgb) {
type = "rgb";
} elseif (m.groups.hsl) {
type = "hsl";
} elseif (m.groups.hwb) {
type = "hwb";
} elseif (m.groups.hex) {
type = "hex";
} elseif (m.groups.number) {
type = "num";
}
/** * 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);
if (mid !== null) { return {
value: first + mid + last,
start,
end: start + mid.length,
};
}
}
returnnull;
}
/** * 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)) { returnnull;
}
const number = /\d+(\.\d+)?/.exec(rawValue);
let units = rawValue.substr(number.index + number[0].length); if (info && "units" in info) {
units = info.units;
}
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) { returnnull;
} if (offset < 1 && offsetEnd <= 1) { returnnull;
} // 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);
// 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;
}
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);
/** * 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;
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) { returnnull;
}
this.#applied = true;
if (this.done) { const val = this.cancelled ? this.initial : this.currentInputValue; returnthis.done(val, !this.cancelled, direction, key);
}
returnnull;
}
/** * 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();
}
/** * Event handler called by the autocomplete popup when receiving a click * event.
*/
#onAutocompletePopupClick = () => { this.#acceptPopupSuggestion();
};
// 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();
}
// We want to autoclose some characters, remember the pressed key in order to process // it later on in maybeSuggestionCompletion(). this.#pressedKey = event.key;
if (isKeyIn(key, "BACK_SPACE", "DELETE", "LEFT", "RIGHT", "HOME", "END")) { if (isPopupOpen && this.currentInputValue !== "") { this.#hideAutocompletePopup();
}
} elseif ( // 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);
}
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;
} elseif (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;
}
// 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();
} elseif (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();
} elseif (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;
} elseif (event.wheelDelta < 0) {
wheelDown = true;
}
}
/** * 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);
}
};
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());
} elseif (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 = "";
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 = "!";
} elseif (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 = "";
}
}
}
}
// Don't autocomplete after !important if (foundImportant) {
list = [];
postLabelValues = [];
} elseif (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");
}
}
} elseif ( 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);
}
} elseif (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] : "",
});
} elseif (count > 0) { // Since count was incremented, we had already crossed the entries // which would have started with query, assuming that list is sorted. break;
} elseif (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) { returnnull;
}
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 returnnull;
}
list = this.#getCSSVariableNames();
postLabelValues = list.map(varName => this.#getCSSVariableValue(varName));
} elseif (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. returntrue;
} 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() { returnthis.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
--> --------------------
¤ Dauer der Verarbeitung: 0.37 Sekunden
(vorverarbeitet)
¤
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung ist noch experimentell.