gecko/browser/devtools/shared/inplace-editor.js
Jim Blandy b6b202b6bb Bug 914753: Make Emacs file variable header lines correct, or at least consistent. DONTBUILD r=ehsan
The -*- file variable lines -*- establish per-file settings that Emacs will
pick up. This patch makes the following changes to those lines (and touches
nothing else):

 - Never set the buffer's mode.

   Years ago, Emacs did not have a good JavaScript mode, so it made sense
   to use Java or C++ mode in .js files. However, Emacs has had js-mode for
   years now; it's perfectly serviceable, and is available and enabled by
   default in all major Emacs packagings.

   Selecting a mode in the -*- file variable line -*- is almost always the
   wrong thing to do anyway. It overrides Emacs's default choice, which is
   (now) reasonable; and even worse, it overrides settings the user might
   have made in their '.emacs' file for that file extension. It's only
   useful when there's something specific about that particular file that
   makes a particular mode appropriate.

 - Correctly propagate settings that establish the correct indentation
   level for this file: c-basic-offset and js2-basic-offset should be
   js-indent-level. Whatever value they're given should be preserved;
   different parts of our tree use different indentation styles.

 - We don't use tabs in Mozilla JS code. Always set indent-tabs-mode: nil.
   Remove tab-width: settings, at least in files that don't contain tab
   characters.

 - Remove js2-mode settings that belong in the user's .emacs file, like
   js2-skip-preprocessor-directives.
2014-06-24 22:12:07 -07:00

1215 lines
38 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 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://gre/modules/devtools/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.
* {boolean} stopOnTab:
* If true, the tab key will not advance the editor to the next
* focusable element.
* {boolean} stopOnShiftTab:
* If true, shift tab will not advance the editor to the previous
* 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.stopOnShiftTab = !!aOptions.stopOnShiftTab;
this.stopOnTab = !!aOptions.stopOnTab;
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();
if (this.contentType == CONTENT_TYPES.CSS_VALUE && this.input.value == "") {
this._maybeSuggestCompletion(true);
}
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 <pre> 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();
// Call the user's change handler if available.
if (this.change) {
this.change(this.input.value.trim());
}
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)
{
// selectedItem can be null when nothing is selected in an empty editor.
let {label, preLabel} = this.popup.selectedItem || {label: "", preLabel: ""};
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.popup.selectedIndex >= 0) {
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();
// Content type other than CSS_MIXED is used in rule-view where the values
// are live previewed. So we apply the value before returning.
if (this.contentType != CONTENT_TYPES.CSS_MIXED) {
this._apply();
}
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;
cycling = 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 && !aEvent.metaKey && !aEvent.altKey && !aEvent.ctrlKey) {
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) {
if (this.stopOnShiftTab) {
direction = null;
} else {
direction = FOCUS_BACKWARD;
}
}
if ((this.stopOnReturn &&
aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN) ||
(this.stopOnTab && aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB)) {
direction = null;
}
// 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;
}
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
// <name>: (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.
*
* @param {boolean} aNoAutoInsert
* true if you don't want to automatically insert the first suggestion
*/
_maybeSuggestCompletion: function(aNoAutoInsert) {
// Input can be null in cases when you intantaneously switch out of it.
if (!this.input) {
return;
}
let 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.
this.doc.defaultView.setTimeout(() => {
if (this._preventSuggestions) {
this._preventSuggestions = false;
return;
}
if (this.contentType == CONTENT_TYPES.PLAIN_TEXT) {
return;
}
if (!this.input) {
return;
}
let input = this.input;
// The length of input.value should be increased by 1
if (input.value.length - preTimeoutQuery.length > 1) {
return;
}
let query = input.value.slice(0, input.selectionStart);
let startCheckQuery = query;
if (query == null) {
return;
}
// If nothing is selected and there is a non-space character after the
// cursor, do not autocomplete.
if (input.selectionStart == input.selectionEnd &&
input.selectionStart < input.value.length &&
input.value.slice(input.selectionStart)[0] != " ") {
// This emit is mainly to make the test flow simpler.
this.emit("after-suggest", "nothing to autocomplete");
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)];
if (query == "") {
// Do not suggest '!important' without any manually typed character.
list.splice(0, 1);
}
} else if (this.contentType == CONTENT_TYPES.CSS_MIXED &&
/^\s*style\s*=/.test(query)) {
// Detecting if cursor is at property or value;
let match = query.match(/([:;"'=]?)\s*([^"';:=]+)?$/);
if (match && match.length >= 2) {
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 = "";
}
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 = CSSPropertyList;
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 (!aNoAutoInsert) {
list.some(item => {
if (startCheckQuery != null && 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 != null && 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 (startCheckQuery != null && 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);
if (aNoAutoInsert) {
this.popup.selectedIndex = -1;
}
} 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);
});