browser/devtools/shared/inplace-editor.js

Wed, 31 Dec 2014 06:09:35 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 06:09:35 +0100
changeset 0
6474c204b198
permissions
-rw-r--r--

Cloned upstream origin tor-browser at tor-browser-31.3.0esr-4.5-1-build1
revision ID fc1c9ff7c1b2defdbc039f12214767608f46423f for hacking purpose.

     1 /* -*- Mode: javascript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
     2 /* vim: set ts=2 et sw=2 tw=80: */
     4 /**
     5  * This Source Code Form is subject to the terms of the Mozilla Public
     6  * License, v. 2.0. If a copy of the MPL was not distributed with this
     7  * file, You can obtain one at http://mozilla.org/MPL/2.0/.
     8  *
     9  * Basic use:
    10  * let spanToEdit = document.getElementById("somespan");
    11  *
    12  * editableField({
    13  *   element: spanToEdit,
    14  *   done: function(value, commit) {
    15  *     if (commit) {
    16  *       spanToEdit.textContent = value;
    17  *     }
    18  *   },
    19  *   trigger: "dblclick"
    20  * });
    21  *
    22  * See editableField() for more options.
    23  */
    25 "use strict";
    27 const {Ci, Cu, Cc} = require("chrome");
    29 const HTML_NS = "http://www.w3.org/1999/xhtml";
    30 const CONTENT_TYPES = {
    31   PLAIN_TEXT: 0,
    32   CSS_VALUE: 1,
    33   CSS_MIXED: 2,
    34   CSS_PROPERTY: 3,
    35 };
    36 const MAX_POPUP_ENTRIES = 10;
    38 const FOCUS_FORWARD = Ci.nsIFocusManager.MOVEFOCUS_FORWARD;
    39 const FOCUS_BACKWARD = Ci.nsIFocusManager.MOVEFOCUS_BACKWARD;
    41 Cu.import("resource://gre/modules/Services.jsm");
    42 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
    43 Cu.import("resource://gre/modules/devtools/event-emitter.js");
    45 /**
    46  * Mark a span editable.  |editableField| will listen for the span to
    47  * be focused and create an InlineEditor to handle text input.
    48  * Changes will be committed when the InlineEditor's input is blurred
    49  * or dropped when the user presses escape.
    50  *
    51  * @param {object} aOptions
    52  *    Options for the editable field, including:
    53  *    {Element} element:
    54  *      (required) The span to be edited on focus.
    55  *    {function} canEdit:
    56  *       Will be called before creating the inplace editor.  Editor
    57  *       won't be created if canEdit returns false.
    58  *    {function} start:
    59  *       Will be called when the inplace editor is initialized.
    60  *    {function} change:
    61  *       Will be called when the text input changes.  Will be called
    62  *       with the current value of the text input.
    63  *    {function} done:
    64  *       Called when input is committed or blurred.  Called with
    65  *       current value and a boolean telling the caller whether to
    66  *       commit the change.  This function is called before the editor
    67  *       has been torn down.
    68  *    {function} destroy:
    69  *       Called when the editor is destroyed and has been torn down.
    70  *    {string} advanceChars:
    71  *       If any characters in advanceChars are typed, focus will advance
    72  *       to the next element.
    73  *    {boolean} stopOnReturn:
    74  *       If true, the return key will not advance the editor to the next
    75  *       focusable element.
    76  *    {string} trigger: The DOM event that should trigger editing,
    77  *      defaults to "click"
    78  */
    79 function editableField(aOptions)
    80 {
    81   return editableItem(aOptions, function(aElement, aEvent) {
    82     new InplaceEditor(aOptions, aEvent);
    83   });
    84 }
    86 exports.editableField = editableField;
    88 /**
    89  * Handle events for an element that should respond to
    90  * clicks and sit in the editing tab order, and call
    91  * a callback when it is activated.
    92  *
    93  * @param {object} aOptions
    94  *    The options for this editor, including:
    95  *    {Element} element: The DOM element.
    96  *    {string} trigger: The DOM event that should trigger editing,
    97  *      defaults to "click"
    98  * @param {function} aCallback
    99  *        Called when the editor is activated.
   100  */
   101 function editableItem(aOptions, aCallback)
   102 {
   103   let trigger = aOptions.trigger || "click"
   104   let element = aOptions.element;
   105   element.addEventListener(trigger, function(evt) {
   106     if (evt.target.nodeName !== "a") {
   107       let win = this.ownerDocument.defaultView;
   108       let selection = win.getSelection();
   109       if (trigger != "click" || selection.isCollapsed) {
   110         aCallback(element, evt);
   111       }
   112       evt.stopPropagation();
   113     }
   114   }, false);
   116   // If focused by means other than a click, start editing by
   117   // pressing enter or space.
   118   element.addEventListener("keypress", function(evt) {
   119     if (evt.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN ||
   120         evt.charCode === Ci.nsIDOMKeyEvent.DOM_VK_SPACE) {
   121       aCallback(element);
   122     }
   123   }, true);
   125   // Ugly workaround - the element is focused on mousedown but
   126   // the editor is activated on click/mouseup.  This leads
   127   // to an ugly flash of the focus ring before showing the editor.
   128   // So hide the focus ring while the mouse is down.
   129   element.addEventListener("mousedown", function(evt) {
   130     if (evt.target.nodeName !== "a") {
   131       let cleanup = function() {
   132         element.style.removeProperty("outline-style");
   133         element.removeEventListener("mouseup", cleanup, false);
   134         element.removeEventListener("mouseout", cleanup, false);
   135       };
   136       element.style.setProperty("outline-style", "none");
   137       element.addEventListener("mouseup", cleanup, false);
   138       element.addEventListener("mouseout", cleanup, false);
   139     }
   140   }, false);
   142   // Mark the element editable field for tab
   143   // navigation while editing.
   144   element._editable = true;
   145 }
   147 exports.editableItem = this.editableItem;
   149 /*
   150  * Various API consumers (especially tests) sometimes want to grab the
   151  * inplaceEditor expando off span elements. However, when each global has its
   152  * own compartment, those expandos live on Xray wrappers that are only visible
   153  * within this JSM. So we provide a little workaround here.
   154  */
   156 function getInplaceEditorForSpan(aSpan)
   157 {
   158   return aSpan.inplaceEditor;
   159 };
   160 exports.getInplaceEditorForSpan = getInplaceEditorForSpan;
   162 function InplaceEditor(aOptions, aEvent)
   163 {
   164   this.elt = aOptions.element;
   165   let doc = this.elt.ownerDocument;
   166   this.doc = doc;
   167   this.elt.inplaceEditor = this;
   169   this.change = aOptions.change;
   170   this.done = aOptions.done;
   171   this.destroy = aOptions.destroy;
   172   this.initial = aOptions.initial ? aOptions.initial : this.elt.textContent;
   173   this.multiline = aOptions.multiline || false;
   174   this.stopOnReturn = !!aOptions.stopOnReturn;
   175   this.contentType = aOptions.contentType || CONTENT_TYPES.PLAIN_TEXT;
   176   this.property = aOptions.property;
   177   this.popup = aOptions.popup;
   179   this._onBlur = this._onBlur.bind(this);
   180   this._onKeyPress = this._onKeyPress.bind(this);
   181   this._onInput = this._onInput.bind(this);
   182   this._onKeyup = this._onKeyup.bind(this);
   184   this._createInput();
   185   this._autosize();
   186   this.inputCharWidth = this._getInputCharWidth();
   188   // Pull out character codes for advanceChars, listing the
   189   // characters that should trigger a blur.
   190   this._advanceCharCodes = {};
   191   let advanceChars = aOptions.advanceChars || '';
   192   for (let i = 0; i < advanceChars.length; i++) {
   193     this._advanceCharCodes[advanceChars.charCodeAt(i)] = true;
   194   }
   196   // Hide the provided element and add our editor.
   197   this.originalDisplay = this.elt.style.display;
   198   this.elt.style.display = "none";
   199   this.elt.parentNode.insertBefore(this.input, this.elt);
   201   if (typeof(aOptions.selectAll) == "undefined" || aOptions.selectAll) {
   202     this.input.select();
   203   }
   204   this.input.focus();
   206   if (this.contentType == CONTENT_TYPES.CSS_VALUE && this.input.value == "") {
   207     this._maybeSuggestCompletion(true);
   208   }
   210   this.input.addEventListener("blur", this._onBlur, false);
   211   this.input.addEventListener("keypress", this._onKeyPress, false);
   212   this.input.addEventListener("input", this._onInput, false);
   214   this.input.addEventListener("dblclick",
   215     (e) => { e.stopPropagation(); }, false);
   216   this.input.addEventListener("mousedown",
   217     (e) => { e.stopPropagation(); }, false);
   219   this.validate = aOptions.validate;
   221   if (this.validate) {
   222     this.input.addEventListener("keyup", this._onKeyup, false);
   223   }
   225   if (aOptions.start) {
   226     aOptions.start(this, aEvent);
   227   }
   229   EventEmitter.decorate(this);
   230 }
   232 exports.InplaceEditor = InplaceEditor;
   234 InplaceEditor.CONTENT_TYPES = CONTENT_TYPES;
   236 InplaceEditor.prototype = {
   237   _createInput: function InplaceEditor_createEditor()
   238   {
   239     this.input =
   240       this.doc.createElementNS(HTML_NS, this.multiline ? "textarea" : "input");
   241     this.input.inplaceEditor = this;
   242     this.input.classList.add("styleinspector-propertyeditor");
   243     this.input.value = this.initial;
   245     copyTextStyles(this.elt, this.input);
   246   },
   248   /**
   249    * Get rid of the editor.
   250    */
   251   _clear: function InplaceEditor_clear()
   252   {
   253     if (!this.input) {
   254       // Already cleared.
   255       return;
   256     }
   258     this.input.removeEventListener("blur", this._onBlur, false);
   259     this.input.removeEventListener("keypress", this._onKeyPress, false);
   260     this.input.removeEventListener("keyup", this._onKeyup, false);
   261     this.input.removeEventListener("oninput", this._onInput, false);
   262     this._stopAutosize();
   264     this.elt.style.display = this.originalDisplay;
   265     this.elt.focus();
   267     this.elt.parentNode.removeChild(this.input);
   268     this.input = null;
   270     delete this.elt.inplaceEditor;
   271     delete this.elt;
   273     if (this.destroy) {
   274       this.destroy();
   275     }
   276   },
   278   /**
   279    * Keeps the editor close to the size of its input string.  This is pretty
   280    * crappy, suggestions for improvement welcome.
   281    */
   282   _autosize: function InplaceEditor_autosize()
   283   {
   284     // Create a hidden, absolutely-positioned span to measure the text
   285     // in the input.  Boo.
   287     // We can't just measure the original element because a) we don't
   288     // change the underlying element's text ourselves (we leave that
   289     // up to the client), and b) without tweaking the style of the
   290     // original element, it might wrap differently or something.
   291     this._measurement =
   292       this.doc.createElementNS(HTML_NS, this.multiline ? "pre" : "span");
   293     this._measurement.className = "autosizer";
   294     this.elt.parentNode.appendChild(this._measurement);
   295     let style = this._measurement.style;
   296     style.visibility = "hidden";
   297     style.position = "absolute";
   298     style.top = "0";
   299     style.left = "0";
   300     copyTextStyles(this.input, this._measurement);
   301     this._updateSize();
   302   },
   304   /**
   305    * Clean up the mess created by _autosize().
   306    */
   307   _stopAutosize: function InplaceEditor_stopAutosize()
   308   {
   309     if (!this._measurement) {
   310       return;
   311     }
   312     this._measurement.parentNode.removeChild(this._measurement);
   313     delete this._measurement;
   314   },
   316   /**
   317    * Size the editor to fit its current contents.
   318    */
   319   _updateSize: function InplaceEditor_updateSize()
   320   {
   321     // Replace spaces with non-breaking spaces.  Otherwise setting
   322     // the span's textContent will collapse spaces and the measurement
   323     // will be wrong.
   324     this._measurement.textContent = this.input.value.replace(/ /g, '\u00a0');
   326     // We add a bit of padding to the end.  Should be enough to fit
   327     // any letter that could be typed, otherwise we'll scroll before
   328     // we get a chance to resize.  Yuck.
   329     let width = this._measurement.offsetWidth + 10;
   331     if (this.multiline) {
   332       // Make sure there's some content in the current line.  This is a hack to
   333       // account for the fact that after adding a newline the <pre> doesn't grow
   334       // unless there's text content on the line.
   335       width += 15;
   336       this._measurement.textContent += "M";
   337       this.input.style.height = this._measurement.offsetHeight + "px";
   338     }
   340     this.input.style.width = width + "px";
   341   },
   343   /**
   344    * Get the width of a single character in the input to properly position the
   345    * autocompletion popup.
   346    */
   347   _getInputCharWidth: function InplaceEditor_getInputCharWidth()
   348   {
   349     // Just make the text content to be 'x' to get the width of any character in
   350     // a monospace font.
   351     this._measurement.textContent = "x";
   352     return this._measurement.offsetWidth;
   353   },
   355    /**
   356    * Increment property values in rule view.
   357    *
   358    * @param {number} increment
   359    *        The amount to increase/decrease the property value.
   360    * @return {bool} true if value has been incremented.
   361    */
   362   _incrementValue: function InplaceEditor_incrementValue(increment)
   363   {
   364     let value = this.input.value;
   365     let selectionStart = this.input.selectionStart;
   366     let selectionEnd = this.input.selectionEnd;
   368     let newValue = this._incrementCSSValue(value, increment, selectionStart,
   369                                            selectionEnd);
   371     if (!newValue) {
   372       return false;
   373     }
   375     this.input.value = newValue.value;
   376     this.input.setSelectionRange(newValue.start, newValue.end);
   377     this._doValidation();
   379     // Call the user's change handler if available.
   380     if (this.change) {
   381       this.change(this.input.value.trim());
   382     }
   384     return true;
   385   },
   387   /**
   388    * Increment the property value based on the property type.
   389    *
   390    * @param {string} value
   391    *        Property value.
   392    * @param {number} increment
   393    *        Amount to increase/decrease the property value.
   394    * @param {number} selStart
   395    *        Starting index of the value.
   396    * @param {number} selEnd
   397    *        Ending index of the value.
   398    * @return {object} object with properties 'value', 'start', and 'end'.
   399    */
   400   _incrementCSSValue: function InplaceEditor_incrementCSSValue(value, increment,
   401                                                                selStart, selEnd)
   402   {
   403     let range = this._parseCSSValue(value, selStart);
   404     let type = (range && range.type) || "";
   405     let rawValue = (range ? value.substring(range.start, range.end) : "");
   406     let incrementedValue = null, selection;
   408     if (type === "num") {
   409       let newValue = this._incrementRawValue(rawValue, increment);
   410       if (newValue !== null) {
   411         incrementedValue = newValue;
   412         selection = [0, incrementedValue.length];
   413       }
   414     } else if (type === "hex") {
   415       let exprOffset = selStart - range.start;
   416       let exprOffsetEnd = selEnd - range.start;
   417       let newValue = this._incHexColor(rawValue, increment, exprOffset,
   418                                        exprOffsetEnd);
   419       if (newValue) {
   420         incrementedValue = newValue.value;
   421         selection = newValue.selection;
   422       }
   423     } else {
   424       let info;
   425       if (type === "rgb" || type === "hsl") {
   426         info = {};
   427         let part = value.substring(range.start, selStart).split(",").length - 1;
   428         if (part === 3) { // alpha
   429           info.minValue = 0;
   430           info.maxValue = 1;
   431         } else if (type === "rgb") {
   432           info.minValue = 0;
   433           info.maxValue = 255;
   434         } else if (part !== 0) { // hsl percentage
   435           info.minValue = 0;
   436           info.maxValue = 100;
   438           // select the previous number if the selection is at the end of a
   439           // percentage sign.
   440           if (value.charAt(selStart - 1) === "%") {
   441             --selStart;
   442           }
   443         }
   444       }
   445       return this._incrementGenericValue(value, increment, selStart, selEnd, info);
   446     }
   448     if (incrementedValue === null) {
   449       return;
   450     }
   452     let preRawValue = value.substr(0, range.start);
   453     let postRawValue = value.substr(range.end);
   455     return {
   456       value: preRawValue + incrementedValue + postRawValue,
   457       start: range.start + selection[0],
   458       end: range.start + selection[1]
   459     };
   460   },
   462   /**
   463    * Parses the property value and type.
   464    *
   465    * @param {string} value
   466    *        Property value.
   467    * @param {number} offset
   468    *        Starting index of value.
   469    * @return {object} object with properties 'value', 'start', 'end', and 'type'.
   470    */
   471    _parseCSSValue: function InplaceEditor_parseCSSValue(value, offset)
   472   {
   473     const reSplitCSS = /(url\("?[^"\)]+"?\)?)|(rgba?\([^)]*\)?)|(hsla?\([^)]*\)?)|(#[\dA-Fa-f]+)|(-?\d+(\.\d+)?(%|[a-z]{1,4})?)|"([^"]*)"?|'([^']*)'?|([^,\s\/!\(\)]+)|(!(.*)?)/;
   474     let start = 0;
   475     let m;
   477     // retreive values from left to right until we find the one at our offset
   478     while ((m = reSplitCSS.exec(value)) &&
   479           (m.index + m[0].length < offset)) {
   480       value = value.substr(m.index + m[0].length);
   481       start += m.index + m[0].length;
   482       offset -= m.index + m[0].length;
   483     }
   485     if (!m) {
   486       return;
   487     }
   489     let type;
   490     if (m[1]) {
   491       type = "url";
   492     } else if (m[2]) {
   493       type = "rgb";
   494     } else if (m[3]) {
   495       type = "hsl";
   496     } else if (m[4]) {
   497       type = "hex";
   498     } else if (m[5]) {
   499       type = "num";
   500     }
   502     return {
   503       value: m[0],
   504       start: start + m.index,
   505       end: start + m.index + m[0].length,
   506       type: type
   507     };
   508   },
   510   /**
   511    * Increment the property value for types other than
   512    * number or hex, such as rgb, hsl, and file names.
   513    *
   514    * @param {string} value
   515    *        Property value.
   516    * @param {number} increment
   517    *        Amount to increment/decrement.
   518    * @param {number} offset
   519    *        Starting index of the property value.
   520    * @param {number} offsetEnd
   521    *        Ending index of the property value.
   522    * @param {object} info
   523    *        Object with details about the property value.
   524    * @return {object} object with properties 'value', 'start', and 'end'.
   525    */
   526   _incrementGenericValue:
   527   function InplaceEditor_incrementGenericValue(value, increment, offset,
   528                                                offsetEnd, info)
   529   {
   530     // Try to find a number around the cursor to increment.
   531     let start, end;
   532     // Check if we are incrementing in a non-number context (such as a URL)
   533     if (/^-?[0-9.]/.test(value.substring(offset, offsetEnd)) &&
   534       !(/\d/.test(value.charAt(offset - 1) + value.charAt(offsetEnd)))) {
   535       // We have a number selected, possibly with a suffix, and we are not in
   536       // the disallowed case of just part of a known number being selected.
   537       // Use that number.
   538       start = offset;
   539       end = offsetEnd;
   540     } else {
   541       // Parse periods as belonging to the number only if we are in a known number
   542       // context. (This makes incrementing the 1 in 'image1.gif' work.)
   543       let pattern = "[" + (info ? "0-9." : "0-9") + "]*";
   544       let before = new RegExp(pattern + "$").exec(value.substr(0, offset))[0].length;
   545       let after = new RegExp("^" + pattern).exec(value.substr(offset))[0].length;
   547       start = offset - before;
   548       end = offset + after;
   550       // Expand the number to contain an initial minus sign if it seems
   551       // free-standing.
   552       if (value.charAt(start - 1) === "-" &&
   553          (start - 1 === 0 || /[ (:,='"]/.test(value.charAt(start - 2)))) {
   554         --start;
   555       }
   556     }
   558     if (start !== end)
   559     {
   560       // Include percentages as part of the incremented number (they are
   561       // common enough).
   562       if (value.charAt(end) === "%") {
   563         ++end;
   564       }
   566       let first = value.substr(0, start);
   567       let mid = value.substring(start, end);
   568       let last = value.substr(end);
   570       mid = this._incrementRawValue(mid, increment, info);
   572       if (mid !== null) {
   573         return {
   574           value: first + mid + last,
   575           start: start,
   576           end: start + mid.length
   577         };
   578       }
   579     }
   580   },
   582   /**
   583    * Increment the property value for numbers.
   584    *
   585    * @param {string} rawValue
   586    *        Raw value to increment.
   587    * @param {number} increment
   588    *        Amount to increase/decrease the raw value.
   589    * @param {object} info
   590    *        Object with info about the property value.
   591    * @return {string} the incremented value.
   592    */
   593   _incrementRawValue:
   594   function InplaceEditor_incrementRawValue(rawValue, increment, info)
   595   {
   596     let num = parseFloat(rawValue);
   598     if (isNaN(num)) {
   599       return null;
   600     }
   602     let number = /\d+(\.\d+)?/.exec(rawValue);
   603     let units = rawValue.substr(number.index + number[0].length);
   605     // avoid rounding errors
   606     let newValue = Math.round((num + increment) * 1000) / 1000;
   608     if (info && "minValue" in info) {
   609       newValue = Math.max(newValue, info.minValue);
   610     }
   611     if (info && "maxValue" in info) {
   612       newValue = Math.min(newValue, info.maxValue);
   613     }
   615     newValue = newValue.toString();
   617     return newValue + units;
   618   },
   620   /**
   621    * Increment the property value for hex.
   622    *
   623    * @param {string} value
   624    *        Property value.
   625    * @param {number} increment
   626    *        Amount to increase/decrease the property value.
   627    * @param {number} offset
   628    *        Starting index of the property value.
   629    * @param {number} offsetEnd
   630    *        Ending index of the property value.
   631    * @return {object} object with properties 'value' and 'selection'.
   632    */
   633   _incHexColor:
   634   function InplaceEditor_incHexColor(rawValue, increment, offset, offsetEnd)
   635   {
   636     // Return early if no part of the rawValue is selected.
   637     if (offsetEnd > rawValue.length && offset >= rawValue.length) {
   638       return;
   639     }
   640     if (offset < 1 && offsetEnd <= 1) {
   641       return;
   642     }
   643     // Ignore the leading #.
   644     rawValue = rawValue.substr(1);
   645     --offset;
   646     --offsetEnd;
   648     // Clamp the selection to within the actual value.
   649     offset = Math.max(offset, 0);
   650     offsetEnd = Math.min(offsetEnd, rawValue.length);
   651     offsetEnd = Math.max(offsetEnd, offset);
   653     // Normalize #ABC -> #AABBCC.
   654     if (rawValue.length === 3) {
   655       rawValue = rawValue.charAt(0) + rawValue.charAt(0) +
   656                  rawValue.charAt(1) + rawValue.charAt(1) +
   657                  rawValue.charAt(2) + rawValue.charAt(2);
   658       offset *= 2;
   659       offsetEnd *= 2;
   660     }
   662     if (rawValue.length !== 6) {
   663       return;
   664     }
   666     // If no selection, increment an adjacent color, preferably one to the left.
   667     if (offset === offsetEnd) {
   668       if (offset === 0) {
   669         offsetEnd = 1;
   670       } else {
   671         offset = offsetEnd - 1;
   672       }
   673     }
   675     // Make the selection cover entire parts.
   676     offset -= offset % 2;
   677     offsetEnd += offsetEnd % 2;
   679     // Remap the increments from [0.1, 1, 10] to [1, 1, 16].
   680     if (-1 < increment && increment < 1) {
   681       increment = (increment < 0 ? -1 : 1);
   682     }
   683     if (Math.abs(increment) === 10) {
   684       increment = (increment < 0 ? -16 : 16);
   685     }
   687     let isUpper = (rawValue.toUpperCase() === rawValue);
   689     for (let pos = offset; pos < offsetEnd; pos += 2) {
   690       // Increment the part in [pos, pos+2).
   691       let mid = rawValue.substr(pos, 2);
   692       let value = parseInt(mid, 16);
   694       if (isNaN(value)) {
   695         return;
   696       }
   698       mid = Math.min(Math.max(value + increment, 0), 255).toString(16);
   700       while (mid.length < 2) {
   701         mid = "0" + mid;
   702       }
   703       if (isUpper) {
   704         mid = mid.toUpperCase();
   705       }
   707       rawValue = rawValue.substr(0, pos) + mid + rawValue.substr(pos + 2);
   708     }
   710     return {
   711       value: "#" + rawValue,
   712       selection: [offset + 1, offsetEnd + 1]
   713     };
   714   },
   716   /**
   717    * Cycle through the autocompletion suggestions in the popup.
   718    *
   719    * @param {boolean} aReverse
   720    *        true to select previous item from the popup.
   721    * @param {boolean} aNoSelect
   722    *        true to not select the text after selecting the newly selectedItem
   723    *        from the popup.
   724    */
   725   _cycleCSSSuggestion:
   726   function InplaceEditor_cycleCSSSuggestion(aReverse, aNoSelect)
   727   {
   728     // selectedItem can be null when nothing is selected in an empty editor.
   729     let {label, preLabel} = this.popup.selectedItem || {label: "", preLabel: ""};
   730     if (aReverse) {
   731       this.popup.selectPreviousItem();
   732     } else {
   733       this.popup.selectNextItem();
   734     }
   735     this._selectedIndex = this.popup.selectedIndex;
   736     let input = this.input;
   737     let pre = "";
   738     if (input.selectionStart < input.selectionEnd) {
   739       pre = input.value.slice(0, input.selectionStart);
   740     }
   741     else {
   742       pre = input.value.slice(0, input.selectionStart - label.length +
   743                                  preLabel.length);
   744     }
   745     let post = input.value.slice(input.selectionEnd, input.value.length);
   746     let item = this.popup.selectedItem;
   747     let toComplete = item.label.slice(item.preLabel.length);
   748     input.value = pre + toComplete + post;
   749     if (!aNoSelect) {
   750       input.setSelectionRange(pre.length, pre.length + toComplete.length);
   751     }
   752     else {
   753       input.setSelectionRange(pre.length + toComplete.length,
   754                               pre.length + toComplete.length);
   755     }
   756     this._updateSize();
   757     // This emit is mainly for the purpose of making the test flow simpler.
   758     this.emit("after-suggest");
   759   },
   761   /**
   762    * Call the client's done handler and clear out.
   763    */
   764   _apply: function InplaceEditor_apply(aEvent)
   765   {
   766     if (this._applied) {
   767       return;
   768     }
   770     this._applied = true;
   772     if (this.done) {
   773       let val = this.input.value.trim();
   774       return this.done(this.cancelled ? this.initial : val, !this.cancelled);
   775     }
   777     return null;
   778   },
   780   /**
   781    * Handle loss of focus by calling done if it hasn't been called yet.
   782    */
   783   _onBlur: function InplaceEditor_onBlur(aEvent, aDoNotClear)
   784   {
   785     if (aEvent && this.popup && this.popup.isOpen &&
   786         this.popup.selectedIndex >= 0) {
   787       let label, preLabel;
   788       if (this._selectedIndex === undefined) {
   789         ({label, preLabel}) = this.popup.getItemAtIndex(this.popup.selectedIndex);
   790       }
   791       else {
   792         ({label, preLabel}) = this.popup.getItemAtIndex(this._selectedIndex);
   793       }
   794       let input = this.input;
   795       let pre = "";
   796       if (input.selectionStart < input.selectionEnd) {
   797         pre = input.value.slice(0, input.selectionStart);
   798       }
   799       else {
   800         pre = input.value.slice(0, input.selectionStart - label.length +
   801                                    preLabel.length);
   802       }
   803       let post = input.value.slice(input.selectionEnd, input.value.length);
   804       let item = this.popup.selectedItem;
   805       this._selectedIndex = this.popup.selectedIndex;
   806       let toComplete = item.label.slice(item.preLabel.length);
   807       input.value = pre + toComplete + post;
   808       input.setSelectionRange(pre.length + toComplete.length,
   809                               pre.length + toComplete.length);
   810       this._updateSize();
   811       // Wait for the popup to hide and then focus input async otherwise it does
   812       // not work.
   813       let onPopupHidden = () => {
   814         this.popup._panel.removeEventListener("popuphidden", onPopupHidden);
   815         this.doc.defaultView.setTimeout(()=> {
   816           input.focus();
   817           this.emit("after-suggest");
   818         }, 0);
   819       };
   820       this.popup._panel.addEventListener("popuphidden", onPopupHidden);
   821       this.popup.hidePopup();
   822       // Content type other than CSS_MIXED is used in rule-view where the values
   823       // are live previewed. So we apply the value before returning.
   824       if (this.contentType != CONTENT_TYPES.CSS_MIXED) {
   825         this._apply();
   826       }
   827       return;
   828     }
   829     this._apply();
   830     if (!aDoNotClear) {
   831       this._clear();
   832     }
   833   },
   835   /**
   836    * Handle the input field's keypress event.
   837    */
   838   _onKeyPress: function InplaceEditor_onKeyPress(aEvent)
   839   {
   840     let prevent = false;
   842     const largeIncrement = 100;
   843     const mediumIncrement = 10;
   844     const smallIncrement = 0.1;
   846     let increment = 0;
   848     if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_UP
   849        || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP) {
   850       increment = 1;
   851     } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_DOWN
   852        || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN) {
   853       increment = -1;
   854     }
   856     if (aEvent.shiftKey && !aEvent.altKey) {
   857       if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP
   858            ||  aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN) {
   859         increment *= largeIncrement;
   860       } else {
   861         increment *= mediumIncrement;
   862       }
   863     } else if (aEvent.altKey && !aEvent.shiftKey) {
   864       increment *= smallIncrement;
   865     }
   867     let cycling = false;
   868     if (increment && this._incrementValue(increment) ) {
   869       this._updateSize();
   870       prevent = true;
   871       cycling = true;
   872     } else if (increment && this.popup && this.popup.isOpen) {
   873       cycling = true;
   874       prevent = true;
   875       this._cycleCSSSuggestion(increment > 0);
   876       this._doValidation();
   877     }
   879     if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_BACK_SPACE ||
   880         aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_DELETE ||
   881         aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_LEFT ||
   882         aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RIGHT) {
   883       if (this.popup && this.popup.isOpen) {
   884         this.popup.hidePopup();
   885       }
   886     } else if (!cycling && !aEvent.metaKey && !aEvent.altKey && !aEvent.ctrlKey) {
   887       this._maybeSuggestCompletion();
   888     }
   890     if (this.multiline &&
   891         aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN &&
   892         aEvent.shiftKey) {
   893       prevent = false;
   894     } else if (aEvent.charCode in this._advanceCharCodes
   895        || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN
   896        || aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB) {
   897       prevent = true;
   899       let direction = FOCUS_FORWARD;
   900       if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB &&
   901           aEvent.shiftKey) {
   902         direction = FOCUS_BACKWARD;
   903       }
   904       if (this.stopOnReturn && aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_RETURN) {
   905         direction = null;
   906       }
   908       // Now we don't want to suggest anything as we are moving out.
   909       this._preventSuggestions = true;
   910       // But we still want to show suggestions for css values. i.e. moving out
   911       // of css property input box in forward direction
   912       if (this.contentType == CONTENT_TYPES.CSS_PROPERTY &&
   913           direction == FOCUS_FORWARD) {
   914         this._preventSuggestions = false;
   915       }
   917       let input = this.input;
   919       if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_TAB &&
   920           this.contentType == CONTENT_TYPES.CSS_MIXED) {
   921         if (this.popup && input.selectionStart < input.selectionEnd) {
   922           aEvent.preventDefault();
   923           input.setSelectionRange(input.selectionEnd, input.selectionEnd);
   924           this.emit("after-suggest");
   925           return;
   926         }
   927         else if (this.popup && this.popup.isOpen) {
   928           aEvent.preventDefault();
   929           this._cycleCSSSuggestion(aEvent.shiftKey, true);
   930           return;
   931         }
   932       }
   934       this._apply();
   936       // Close the popup if open
   937       if (this.popup && this.popup.isOpen) {
   938         this.popup.hidePopup();
   939       }
   941       if (direction !== null && focusManager.focusedElement === input) {
   942         // If the focused element wasn't changed by the done callback,
   943         // move the focus as requested.
   944         let next = moveFocus(this.doc.defaultView, direction);
   946         // If the next node to be focused has been tagged as an editable
   947         // node, send it a click event to trigger
   948         if (next && next.ownerDocument === this.doc && next._editable) {
   949           next.click();
   950         }
   951       }
   953       this._clear();
   954     } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE) {
   955       // Cancel and blur ourselves.
   956       // Now we don't want to suggest anything as we are moving out.
   957       this._preventSuggestions = true;
   958       // Close the popup if open
   959       if (this.popup && this.popup.isOpen) {
   960         this.popup.hidePopup();
   961       }
   962       prevent = true;
   963       this.cancelled = true;
   964       this._apply();
   965       this._clear();
   966       aEvent.stopPropagation();
   967     } else if (aEvent.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_SPACE) {
   968       // No need for leading spaces here.  This is particularly
   969       // noticable when adding a property: it's very natural to type
   970       // <name>: (which advances to the next property) then spacebar.
   971       prevent = !this.input.value;
   972     }
   974     if (prevent) {
   975       aEvent.preventDefault();
   976     }
   977   },
   979   /**
   980    * Handle the input field's keyup event.
   981    */
   982   _onKeyup: function(aEvent) {
   983     this._applied = false;
   984   },
   986   /**
   987    * Handle changes to the input text.
   988    */
   989   _onInput: function InplaceEditor_onInput(aEvent)
   990   {
   991     // Validate the entered value.
   992     this._doValidation();
   994     // Update size if we're autosizing.
   995     if (this._measurement) {
   996       this._updateSize();
   997     }
   999     // Call the user's change handler if available.
  1000     if (this.change) {
  1001       this.change(this.input.value.trim());
  1003   },
  1005   /**
  1006    * Fire validation callback with current input
  1007    */
  1008   _doValidation: function()
  1010     if (this.validate && this.input) {
  1011       this.validate(this.input.value);
  1013   },
  1015   /**
  1016    * Handles displaying suggestions based on the current input.
  1018    * @param {boolean} aNoAutoInsert
  1019    *        true if you don't want to automatically insert the first suggestion
  1020    */
  1021   _maybeSuggestCompletion: function(aNoAutoInsert) {
  1022     // Input can be null in cases when you intantaneously switch out of it.
  1023     if (!this.input) {
  1024       return;
  1026     let preTimeoutQuery = this.input.value;
  1027     // Since we are calling this method from a keypress event handler, the
  1028     // |input.value| does not include currently typed character. Thus we perform
  1029     // this method async.
  1030     this.doc.defaultView.setTimeout(() => {
  1031       if (this._preventSuggestions) {
  1032         this._preventSuggestions = false;
  1033         return;
  1035       if (this.contentType == CONTENT_TYPES.PLAIN_TEXT) {
  1036         return;
  1038       if (!this.input) {
  1039         return;
  1041       let input = this.input;
  1042       // The length of input.value should be increased by 1
  1043       if (input.value.length - preTimeoutQuery.length > 1) {
  1044         return;
  1046       let query = input.value.slice(0, input.selectionStart);
  1047       let startCheckQuery = query;
  1048       if (query == null) {
  1049         return;
  1051       // If nothing is selected and there is a non-space character after the
  1052       // cursor, do not autocomplete.
  1053       if (input.selectionStart == input.selectionEnd &&
  1054           input.selectionStart < input.value.length &&
  1055           input.value.slice(input.selectionStart)[0] != " ") {
  1056         // This emit is mainly to make the test flow simpler.
  1057         this.emit("after-suggest", "nothing to autocomplete");
  1058         return;
  1060       let list = [];
  1061       if (this.contentType == CONTENT_TYPES.CSS_PROPERTY) {
  1062         list = CSSPropertyList;
  1063       } else if (this.contentType == CONTENT_TYPES.CSS_VALUE) {
  1064         // Get the last query to be completed before the caret.
  1065         let match = /([^\s,.\/]+$)/.exec(query);
  1066         if (match) {
  1067           startCheckQuery = match[0];
  1068         } else {
  1069           startCheckQuery = "";
  1072         list =
  1073           ["!important", ...domUtils.getCSSValuesForProperty(this.property.name)];
  1075         if (query == "") {
  1076           // Do not suggest '!important' without any manually typed character.
  1077           list.splice(0, 1);
  1079       } else if (this.contentType == CONTENT_TYPES.CSS_MIXED &&
  1080                  /^\s*style\s*=/.test(query)) {
  1081         // Detecting if cursor is at property or value;
  1082         let match = query.match(/([:;"'=]?)\s*([^"';:=]+)?$/);
  1083         if (match && match.length >= 2) {
  1084           if (match[1] == ":") { // We are in CSS value completion
  1085             let propertyName =
  1086               query.match(/[;"'=]\s*([^"';:= ]+)\s*:\s*[^"';:=]*$/)[1];
  1087             list =
  1088               ["!important;", ...domUtils.getCSSValuesForProperty(propertyName)];
  1089             let matchLastQuery = /([^\s,.\/]+$)/.exec(match[2] || "");
  1090             if (matchLastQuery) {
  1091               startCheckQuery = matchLastQuery[0];
  1092             } else {
  1093               startCheckQuery = "";
  1095             if (!match[2]) {
  1096               // Don't suggest '!important' without any manually typed character
  1097               list.splice(0, 1);
  1099           } else if (match[1]) { // We are in CSS property name completion
  1100             list = CSSPropertyList;
  1101             startCheckQuery = match[2];
  1103           if (startCheckQuery == null) {
  1104             // This emit is mainly to make the test flow simpler.
  1105             this.emit("after-suggest", "nothing to autocomplete");
  1106             return;
  1110       if (!aNoAutoInsert) {
  1111         list.some(item => {
  1112           if (startCheckQuery != null && item.startsWith(startCheckQuery)) {
  1113             input.value = query + item.slice(startCheckQuery.length) +
  1114                           input.value.slice(query.length);
  1115             input.setSelectionRange(query.length, query.length + item.length -
  1116                                                   startCheckQuery.length);
  1117             this._updateSize();
  1118             return true;
  1120         });
  1123       if (!this.popup) {
  1124         // This emit is mainly to make the test flow simpler.
  1125         this.emit("after-suggest", "no popup");
  1126         return;
  1128       let finalList = [];
  1129       let length = list.length;
  1130       for (let i = 0, count = 0; i < length && count < MAX_POPUP_ENTRIES; i++) {
  1131         if (startCheckQuery != null && list[i].startsWith(startCheckQuery)) {
  1132           count++;
  1133           finalList.push({
  1134             preLabel: startCheckQuery,
  1135             label: list[i]
  1136           });
  1138         else if (count > 0) {
  1139           // Since count was incremented, we had already crossed the entries
  1140           // which would have started with query, assuming that list is sorted.
  1141           break;
  1143         else if (startCheckQuery != null && list[i][0] > startCheckQuery[0]) {
  1144           // We have crossed all possible matches alphabetically.
  1145           break;
  1149       if (finalList.length > 1) {
  1150         // Calculate the offset for the popup to be opened.
  1151         let x = (this.input.selectionStart - startCheckQuery.length) *
  1152                 this.inputCharWidth;
  1153         this.popup.setItems(finalList);
  1154         this.popup.openPopup(this.input, x);
  1155         if (aNoAutoInsert) {
  1156           this.popup.selectedIndex = -1;
  1158       } else {
  1159         this.popup.hidePopup();
  1161       // This emit is mainly for the purpose of making the test flow simpler.
  1162       this.emit("after-suggest");
  1163       this._doValidation();
  1164     }, 0);
  1166 };
  1168 /**
  1169  * Copy text-related styles from one element to another.
  1170  */
  1171 function copyTextStyles(aFrom, aTo)
  1173   let win = aFrom.ownerDocument.defaultView;
  1174   let style = win.getComputedStyle(aFrom);
  1175   aTo.style.fontFamily = style.getPropertyCSSValue("font-family").cssText;
  1176   aTo.style.fontSize = style.getPropertyCSSValue("font-size").cssText;
  1177   aTo.style.fontWeight = style.getPropertyCSSValue("font-weight").cssText;
  1178   aTo.style.fontStyle = style.getPropertyCSSValue("font-style").cssText;
  1181 /**
  1182  * Trigger a focus change similar to pressing tab/shift-tab.
  1183  */
  1184 function moveFocus(aWin, aDirection)
  1186   return focusManager.moveFocus(aWin, null, aDirection, 0);
  1190 XPCOMUtils.defineLazyGetter(this, "focusManager", function() {
  1191   return Services.focus;
  1192 });
  1194 XPCOMUtils.defineLazyGetter(this, "CSSPropertyList", function() {
  1195   return domUtils.getCSSPropertyNames(domUtils.INCLUDE_ALIASES).sort();
  1196 });
  1198 XPCOMUtils.defineLazyGetter(this, "domUtils", function() {
  1199   return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
  1200 });

mercurial