/* -*- Mode: javascript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ /* vim: set ts=2 et sw=2 tw=80: */ /** * 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) { * if (commit) { * spanToEdit.textContent = value; * } * }, * trigger: "dblclick" * }); * * See editableField() for more options. */ "use strict"; const {Ci, Cu, Cc} = require("chrome"); const HTML_NS = "http://www.w3.org/1999/xhtml"; const CONTENT_TYPES = { PLAIN_TEXT: 0, CSS_VALUE: 1, CSS_MIXED: 2, CSS_PROPERTY: 3, }; const MAX_POPUP_ENTRIES = 10; const FOCUS_FORWARD = Ci.nsIFocusManager.MOVEFOCUS_FORWARD; const FOCUS_BACKWARD = Ci.nsIFocusManager.MOVEFOCUS_BACKWARD; Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource:///modules/devtools/shared/event-emitter.js"); /** * 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} aOptions * Options for the editable field, including: * {Element} element: * (required) The span to be edited on focus. * {function} canEdit: * Will be called before creating the inplace editor. Editor * won't be created if canEdit returns false. * {function} start: * Will be called when the inplace editor is initialized. * {function} change: * Will be called when the text input changes. Will be called * with the current value of the text input. * {function} done: * Called when input is committed or blurred. Called with * current value and a boolean telling the caller whether to * commit the change. This function is called before the editor * has been torn down. * {function} destroy: * Called when the editor is destroyed and has been torn down. * {string} advanceChars: * If any characters in advanceChars are typed, focus will advance * to the next element. * {boolean} stopOnReturn: * If true, the return key will not advance the editor to the next * focusable element. * {string} trigger: The DOM event that should trigger editing, * defaults to "click" */ function editableField(aOptions) { return editableItem(aOptions, function(aElement, aEvent) { new InplaceEditor(aOptions, aEvent); }); } 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} aOptions * 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} aCallback * Called when the editor is activated. */ function editableItem(aOptions, aCallback) { let trigger = aOptions.trigger || "click" let element = aOptions.element; element.addEventListener(trigger, function(evt) { if (evt.target.nodeName !== "a") { let win = this.ownerDocument.defaultView; let selection = win.getSelection(); if (trigger != "click" || selection.isCollapsed) { aCallback(element, evt); } evt.stopPropagation(); } }, false); // If focused by means other than a click, start editing by // pressing enter or space. element.addEventListener("keypress", function(evt) { if (evt.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN || evt.charCode === Ci.nsIDOMKeyEvent.DOM_VK_SPACE) { aCallback(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") { let cleanup = function() { element.style.removeProperty("outline-style"); element.removeEventListener("mouseup", cleanup, false); element.removeEventListener("mouseout", cleanup, false); }; element.style.setProperty("outline-style", "none"); element.addEventListener("mouseup", cleanup, false); element.addEventListener("mouseout", cleanup, false); } }, false); // Mark the element editable field for tab // navigation while editing. element._editable = true; } exports.editableItem = this.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(aSpan) { return aSpan.inplaceEditor; }; exports.getInplaceEditorForSpan = getInplaceEditorForSpan; function InplaceEditor(aOptions, aEvent) { this.elt = aOptions.element; let doc = this.elt.ownerDocument; this.doc = doc; this.elt.inplaceEditor = this; this.change = aOptions.change; this.done = aOptions.done; this.destroy = aOptions.destroy; this.initial = aOptions.initial ? aOptions.initial : this.elt.textContent; this.multiline = aOptions.multiline || false; this.stopOnReturn = !!aOptions.stopOnReturn; this.contentType = aOptions.contentType || CONTENT_TYPES.PLAIN_TEXT; this.property = aOptions.property; this.popup = aOptions.popup; this._onBlur = this._onBlur.bind(this); this._onKeyPress = this._onKeyPress.bind(this); this._onInput = this._onInput.bind(this); this._onKeyup = this._onKeyup.bind(this); this._createInput(); this._autosize(); this.inputCharWidth = this._getInputCharWidth(); // Pull out character codes for advanceChars, listing the // characters that should trigger a blur. this._advanceCharCodes = {}; let advanceChars = aOptions.advanceChars || ''; for (let i = 0; i < advanceChars.length; i++) { this._advanceCharCodes[advanceChars.charCodeAt(i)] = true; } // 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); if (typeof(aOptions.selectAll) == "undefined" || aOptions.selectAll) { this.input.select(); } this.input.focus(); this.input.addEventListener("blur", this._onBlur, false); this.input.addEventListener("keypress", this._onKeyPress, false); this.input.addEventListener("input", this._onInput, false); this.input.addEventListener("dblclick", (e) => { e.stopPropagation(); }, false); this.input.addEventListener("mousedown", (e) => { e.stopPropagation(); }, false); this.validate = aOptions.validate; if (this.validate) { this.input.addEventListener("keyup", this._onKeyup, false); } if (aOptions.start) { aOptions.start(this, aEvent); } EventEmitter.decorate(this); } exports.InplaceEditor = InplaceEditor; InplaceEditor.CONTENT_TYPES = CONTENT_TYPES; InplaceEditor.prototype = { _createInput: function InplaceEditor_createEditor() { this.input = this.doc.createElementNS(HTML_NS, this.multiline ? "textarea" : "input"); this.input.inplaceEditor = this; this.input.classList.add("styleinspector-propertyeditor"); this.input.value = this.initial; copyTextStyles(this.elt, this.input); }, /** * Get rid of the editor. */ _clear: function InplaceEditor_clear() { if (!this.input) { // Already cleared. return; } this.input.removeEventListener("blur", this._onBlur, false); this.input.removeEventListener("keypress", this._onKeyPress, false); this.input.removeEventListener("keyup", this._onKeyup, false); this.input.removeEventListener("oninput", this._onInput, false); this._stopAutosize(); this.elt.style.display = this.originalDisplay; this.elt.focus(); this.elt.parentNode.removeChild(this.input); this.input = null; delete this.elt.inplaceEditor; delete this.elt; if (this.destroy) { this.destroy(); } }, /** * Keeps the editor close to the size of its input string. This is pretty * crappy, suggestions for improvement welcome. */ _autosize: function InplaceEditor_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); let style = this._measurement.style; style.visibility = "hidden"; style.position = "absolute"; style.top = "0"; style.left = "0"; copyTextStyles(this.input, this._measurement); this._updateSize(); }, /** * Clean up the mess created by _autosize(). */ _stopAutosize: function InplaceEditor_stopAutosize() { if (!this._measurement) { return; } this._measurement.parentNode.removeChild(this._measurement); delete this._measurement; }, /** * Size the editor to fit its current contents. */ _updateSize: function InplaceEditor_updateSize() { // Replace spaces with non-breaking spaces. Otherwise setting // the span's textContent will collapse spaces and the measurement // will be wrong. this._measurement.textContent = this.input.value.replace(/ /g, '\u00a0'); // We add a bit of padding to the end. Should be enough to fit // any letter that could be typed, otherwise we'll scroll before // we get a chance to resize. Yuck. let width = this._measurement.offsetWidth + 10; if (this.multiline) { // Make sure there's some content in the current line. This is a hack to // account for the fact that after adding a newline the
doesn't grow // unless there's text content on the line. width += 15; this._measurement.textContent += "M"; this.input.style.height = this._measurement.offsetHeight + "px"; } this.input.style.width = width + "px"; }, /** * Get the width of a single character in the input to properly position the * autocompletion popup. */ _getInputCharWidth: function InplaceEditor_getInputCharWidth() { // Just make the text content to be 'x' to get the width of any character in // a monospace font. this._measurement.textContent = "x"; return this._measurement.offsetWidth; }, /** * Increment property values in rule view. * * @param {number} increment * The amount to increase/decrease the property value. * @return {bool} true if value has been incremented. */ _incrementValue: function InplaceEditor_incrementValue(increment) { let value = this.input.value; let selectionStart = this.input.selectionStart; let selectionEnd = this.input.selectionEnd; let 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(); 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: function InplaceEditor_incrementCSSValue(value, increment, selStart, selEnd) { let range = this._parseCSSValue(value, selStart); let type = (range && range.type) || ""; let rawValue = (range ? value.substring(range.start, range.end) : ""); let incrementedValue = null, selection; if (type === "num") { let newValue = this._incrementRawValue(rawValue, increment); if (newValue !== null) { incrementedValue = newValue; selection = [0, incrementedValue.length]; } } else if (type === "hex") { let exprOffset = selStart - range.start; let exprOffsetEnd = selEnd - range.start; let newValue = this._incHexColor(rawValue, increment, exprOffset, exprOffsetEnd); if (newValue) { incrementedValue = newValue.value; selection = newValue.selection; } } else { let info; if (type === "rgb" || type === "hsl") { info = {}; let part = value.substring(range.start, selStart).split(",").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 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; } let preRawValue = value.substr(0, range.start); let postRawValue = value.substr(range.end); return { value: preRawValue + incrementedValue + postRawValue, start: range.start + selection[0], end: range.start + selection[1] }; }, /** * 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: function InplaceEditor_parseCSSValue(value, offset) { const reSplitCSS = /(url\("?[^"\)]+"?\)?)|(rgba?\([^)]*\)?)|(hsla?\([^)]*\)?)|(#[\dA-Fa-f]+)|(-?\d+(\.\d+)?(%|[a-z]{1,4})?)|"([^"]*)"?|'([^']*)'?|([^,\s\/!\(\)]+)|(!(.*)?)/; 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.substr(m.index + m[0].length); start += m.index + m[0].length; offset -= m.index + m[0].length; } if (!m) { return; } let type; if (m[1]) { type = "url"; } else if (m[2]) { type = "rgb"; } else if (m[3]) { type = "hsl"; } else if (m[4]) { type = "hex"; } else if (m[5]) { type = "num"; } return { value: m[0], start: start + m.index, end: start + m.index + m[0].length, type: type }; }, /** * Increment the property value for types other than * number or hex, such as rgb, hsl, 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: function InplaceEditor_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.) let pattern = "[" + (info ? "0-9." : "0-9") + "]*"; let before = new RegExp(pattern + "$").exec(value.substr(0, offset))[0].length; let 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; } let first = value.substr(0, start); let mid = value.substring(start, end); let last = value.substr(end); mid = this._incrementRawValue(mid, increment, info); if (mid !== null) { return { value: first + mid + last, start: start, end: start + mid.length }; } } }, /** * 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: function InplaceEditor_incrementRawValue(rawValue, increment, info) { let num = parseFloat(rawValue); if (isNaN(num)) { return null; } let number = /\d+(\.\d+)?/.exec(rawValue); let units = rawValue.substr(number.index + number[0].length); // 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: function InplaceEditor_incHexColor(rawValue, increment, offset, offsetEnd) { // Return early if no part of the rawValue is selected. if (offsetEnd > rawValue.length && offset >= rawValue.length) { return; } if (offset < 1 && offsetEnd <= 1) { return; } // 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; } if (rawValue.length !== 6) { return; } // 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 (-1 < increment && increment < 1) { increment = (increment < 0 ? -1 : 1); } if (Math.abs(increment) === 10) { increment = (increment < 0 ? -16 : 16); } let 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); let value = parseInt(mid, 16); if (isNaN(value)) { return; } 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} aReverse * true to select previous item from the popup. * @param {boolean} aNoSelect * true to not select the text after selecting the newly selectedItem * from the popup. */ _cycleCSSSuggestion: function InplaceEditor_cycleCSSSuggestion(aReverse, aNoSelect) { let {label, preLabel} = this.popup.selectedItem; if (aReverse) { this.popup.selectPreviousItem(); } else { this.popup.selectNextItem(); } this._selectedIndex = this.popup.selectedIndex; let 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); } let post = input.value.slice(input.selectionEnd, input.value.length); let item = this.popup.selectedItem; let toComplete = item.label.slice(item.preLabel.length); input.value = pre + toComplete + post; if (!aNoSelect) { 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: function InplaceEditor_apply(aEvent) { if (this._applied) { return; } this._applied = true; if (this.done) { let val = this.input.value.trim(); return this.done(this.cancelled ? this.initial : val, !this.cancelled); } return null; }, /** * Handle loss of focus by calling done if it hasn't been called yet. */ _onBlur: function InplaceEditor_onBlur(aEvent, aDoNotClear) { if (aEvent && this.popup && this.popup.isOpen && this.contentType == CONTENT_TYPES.CSS_MIXED) { let label, preLabel; if (this._selectedIndex === undefined) { ({label, preLabel}) = this.popup.getItemAtIndex(this.popup.selectedIndex); } else { ({label, preLabel}) = this.popup.getItemAtIndex(this._selectedIndex); } let 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); } let post = input.value.slice(input.selectionEnd, input.value.length); let item = this.popup.selectedItem; this._selectedIndex = this.popup.selectedIndex; let 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. let onPopupHidden = () => { this.popup._panel.removeEventListener("popuphidden", onPopupHidden); this.doc.defaultView.setTimeout(()=> { input.focus(); this.emit("after-suggest"); }, 0); }; this.popup._panel.addEventListener("popuphidden", onPopupHidden); this.popup.hidePopup(); return; } this._apply(); if (!aDoNotClear) { this._clear(); } }, /** * Handle the input field's keypress event. */ _onKeyPress: function InplaceEditor_onKeyPress(aEvent) { let prevent = false; const largeIncrement = 100; const mediumIncrement = 10; const smallIncrement = 0.1; let increment = 0; if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_UP || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP) { increment = 1; } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_DOWN || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN) { increment = -1; } if (aEvent.shiftKey && !aEvent.altKey) { if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN) { increment *= largeIncrement; } else { increment *= mediumIncrement; } } else if (aEvent.altKey && !aEvent.shiftKey) { increment *= smallIncrement; } let cycling = false; if (increment && this._incrementValue(increment) ) { this._updateSize(); prevent = true; } else if (increment && this.popup && this.popup.isOpen) { cycling = true; prevent = true; this._cycleCSSSuggestion(increment > 0); this._doValidation(); } if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_BACK_SPACE || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_DELETE || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_LEFT || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RIGHT) { if (this.popup && this.popup.isOpen) { this.popup.hidePopup(); } } else if (!cycling) { this._maybeSuggestCompletion(); } if (this.multiline && aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN && aEvent.shiftKey) { prevent = false; } else if (aEvent.charCode in this._advanceCharCodes || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB) { prevent = true; let direction = FOCUS_FORWARD; if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB && aEvent.shiftKey) { direction = FOCUS_BACKWARD; } if (this.stopOnReturn && aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN) { direction = null; } // Now we don't want to suggest anything as we are moving out. this._preventSuggestions = true; let input = this.input; if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB && this.contentType == CONTENT_TYPES.CSS_MIXED) { if (this.popup && input.selectionStart < input.selectionEnd) { aEvent.preventDefault(); input.setSelectionRange(input.selectionEnd, input.selectionEnd); this.emit("after-suggest"); return; } else if (this.popup && this.popup.isOpen) { aEvent.preventDefault(); this._cycleCSSSuggestion(aEvent.shiftKey, true); return; } } this._apply(); // Close the popup if open if (this.popup && this.popup.isOpen) { this.popup.hidePopup(); } if (direction !== null && focusManager.focusedElement === input) { // If the focused element wasn't changed by the done callback, // move the focus as requested. let next = moveFocus(this.doc.defaultView, direction); // If the next node to be focused has been tagged as an editable // node, send it a click event to trigger if (next && next.ownerDocument === this.doc && next._editable) { next.click(); } } this._clear(); } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_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.popup.hidePopup(); } prevent = true; this.cancelled = true; this._apply(); this._clear(); aEvent.stopPropagation(); } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_SPACE) { // No need for leading spaces here. This is particularly // noticable when adding a property: it's very natural to type //: (which advances to the next property) then spacebar. prevent = !this.input.value; } if (prevent) { aEvent.preventDefault(); } }, /** * Handle the input field's keyup event. */ _onKeyup: function(aEvent) { this._applied = false; }, /** * Handle changes to the input text. */ _onInput: function InplaceEditor_onInput(aEvent) { // 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.input.value.trim()); } }, /** * Fire validation callback with current input */ _doValidation: function() { if (this.validate && this.input) { this.validate(this.input.value); } }, /** * Handles displaying suggestions based on the current input. */ _maybeSuggestCompletion: function() { // 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. this.doc.defaultView.setTimeout(() => { if (this._preventSuggestions) { this._preventSuggestions = false; return; } if (this.contentType == CONTENT_TYPES.PLAIN_TEXT) { return; } let input = this.input; // Input can be null in cases when you intantaneously switch out of it. if (!input) { return; } let query = input.value.slice(0, input.selectionStart); let startCheckQuery = query; if (!query) { return; } let list = []; if (this.contentType == CONTENT_TYPES.CSS_PROPERTY) { list = CSSPropertyList; } else if (this.contentType == CONTENT_TYPES.CSS_VALUE) { // Get the last query to be completed before the caret. let match = /([^\s,.\/]+$)/.exec(query); if (match) { startCheckQuery = match[0]; } else { startCheckQuery = ""; } list = ["!important", ...domUtils.getCSSValuesForProperty(this.property.name)]; } else if (this.contentType == CONTENT_TYPES.CSS_MIXED && /^\s*style\s*=/.test(query)) { // Detecting if cursor is at property or value; let match = query.match(/([:;"'=]?)\s*([^"';:=]+)$/); if (match && match.length == 3) { if (match[1] == ":") { // We are in CSS value completion let propertyName = query.match(/[;"'=]\s*([^"';:= ]+)\s*:\s*[^"';:=]+$/)[1]; list = ["!important;", ...domUtils.getCSSValuesForProperty(propertyName)]; let matchLastQuery = /([^\s,.\/]+$)/.exec(match[2]); if (matchLastQuery) { startCheckQuery = matchLastQuery[0]; } else { startCheckQuery = ""; } } else if (match[1]) { // We are in CSS property name completion list = CSSPropertyList; startCheckQuery = match[2]; } if (!startCheckQuery) { // This emit is mainly to make the test flow simpler. this.emit("after-suggest", "nothing to autocomplete"); return; } } } list.some(item => { if (startCheckQuery && item.startsWith(startCheckQuery)) { input.value = query + item.slice(startCheckQuery.length) + input.value.slice(query.length); input.setSelectionRange(query.length, query.length + item.length - startCheckQuery.length); this._updateSize(); return true; } }); if (!this.popup) { // This emit is mainly to make the test flow simpler. this.emit("after-suggest", "no popup"); return; } let finalList = []; let length = list.length; for (let i = 0, count = 0; i < length && count < MAX_POPUP_ENTRIES; i++) { if (startCheckQuery && list[i].startsWith(startCheckQuery)) { count++; finalList.push({ preLabel: startCheckQuery, label: list[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 (list[i][0] > startCheckQuery[0]) { // We have crossed all possible matches alphabetically. break; } } if (finalList.length > 1) { // Calculate the offset for the popup to be opened. let x = (this.input.selectionStart - startCheckQuery.length) * this.inputCharWidth; this.popup.setItems(finalList); this.popup.openPopup(this.input, x); } else { this.popup.hidePopup(); } // This emit is mainly for the purpose of making the test flow simpler. this.emit("after-suggest"); this._doValidation(); }, 0); } }; /** * Copy text-related styles from one element to another. */ function copyTextStyles(aFrom, aTo) { let win = aFrom.ownerDocument.defaultView; let style = win.getComputedStyle(aFrom); aTo.style.fontFamily = style.getPropertyCSSValue("font-family").cssText; aTo.style.fontSize = style.getPropertyCSSValue("font-size").cssText; aTo.style.fontWeight = style.getPropertyCSSValue("font-weight").cssText; aTo.style.fontStyle = style.getPropertyCSSValue("font-style").cssText; } /** * Trigger a focus change similar to pressing tab/shift-tab. */ function moveFocus(aWin, aDirection) { return focusManager.moveFocus(aWin, null, aDirection, 0); } XPCOMUtils.defineLazyGetter(this, "focusManager", function() { return Services.focus; }); XPCOMUtils.defineLazyGetter(this, "CSSPropertyList", function() { return domUtils.getCSSPropertyNames(domUtils.INCLUDE_ALIASES).sort(); }); XPCOMUtils.defineLazyGetter(this, "domUtils", function() { return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); });