browser/devtools/shared/inplace-editor.js

changeset 0
6474c204b198
equal deleted inserted replaced
-1:000000000000 0:d03402c1e0e9
1 /* -*- Mode: javascript; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
2 /* vim: set ts=2 et sw=2 tw=80: */
3
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 */
24
25 "use strict";
26
27 const {Ci, Cu, Cc} = require("chrome");
28
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;
37
38 const FOCUS_FORWARD = Ci.nsIFocusManager.MOVEFOCUS_FORWARD;
39 const FOCUS_BACKWARD = Ci.nsIFocusManager.MOVEFOCUS_BACKWARD;
40
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");
44
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 }
85
86 exports.editableField = editableField;
87
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);
115
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);
124
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);
141
142 // Mark the element editable field for tab
143 // navigation while editing.
144 element._editable = true;
145 }
146
147 exports.editableItem = this.editableItem;
148
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 */
155
156 function getInplaceEditorForSpan(aSpan)
157 {
158 return aSpan.inplaceEditor;
159 };
160 exports.getInplaceEditorForSpan = getInplaceEditorForSpan;
161
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;
168
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;
178
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);
183
184 this._createInput();
185 this._autosize();
186 this.inputCharWidth = this._getInputCharWidth();
187
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 }
195
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);
200
201 if (typeof(aOptions.selectAll) == "undefined" || aOptions.selectAll) {
202 this.input.select();
203 }
204 this.input.focus();
205
206 if (this.contentType == CONTENT_TYPES.CSS_VALUE && this.input.value == "") {
207 this._maybeSuggestCompletion(true);
208 }
209
210 this.input.addEventListener("blur", this._onBlur, false);
211 this.input.addEventListener("keypress", this._onKeyPress, false);
212 this.input.addEventListener("input", this._onInput, false);
213
214 this.input.addEventListener("dblclick",
215 (e) => { e.stopPropagation(); }, false);
216 this.input.addEventListener("mousedown",
217 (e) => { e.stopPropagation(); }, false);
218
219 this.validate = aOptions.validate;
220
221 if (this.validate) {
222 this.input.addEventListener("keyup", this._onKeyup, false);
223 }
224
225 if (aOptions.start) {
226 aOptions.start(this, aEvent);
227 }
228
229 EventEmitter.decorate(this);
230 }
231
232 exports.InplaceEditor = InplaceEditor;
233
234 InplaceEditor.CONTENT_TYPES = CONTENT_TYPES;
235
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;
244
245 copyTextStyles(this.elt, this.input);
246 },
247
248 /**
249 * Get rid of the editor.
250 */
251 _clear: function InplaceEditor_clear()
252 {
253 if (!this.input) {
254 // Already cleared.
255 return;
256 }
257
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();
263
264 this.elt.style.display = this.originalDisplay;
265 this.elt.focus();
266
267 this.elt.parentNode.removeChild(this.input);
268 this.input = null;
269
270 delete this.elt.inplaceEditor;
271 delete this.elt;
272
273 if (this.destroy) {
274 this.destroy();
275 }
276 },
277
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.
286
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 },
303
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 },
315
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');
325
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;
330
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 }
339
340 this.input.style.width = width + "px";
341 },
342
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 },
354
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;
367
368 let newValue = this._incrementCSSValue(value, increment, selectionStart,
369 selectionEnd);
370
371 if (!newValue) {
372 return false;
373 }
374
375 this.input.value = newValue.value;
376 this.input.setSelectionRange(newValue.start, newValue.end);
377 this._doValidation();
378
379 // Call the user's change handler if available.
380 if (this.change) {
381 this.change(this.input.value.trim());
382 }
383
384 return true;
385 },
386
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;
407
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;
437
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 }
447
448 if (incrementedValue === null) {
449 return;
450 }
451
452 let preRawValue = value.substr(0, range.start);
453 let postRawValue = value.substr(range.end);
454
455 return {
456 value: preRawValue + incrementedValue + postRawValue,
457 start: range.start + selection[0],
458 end: range.start + selection[1]
459 };
460 },
461
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;
476
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 }
484
485 if (!m) {
486 return;
487 }
488
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 }
501
502 return {
503 value: m[0],
504 start: start + m.index,
505 end: start + m.index + m[0].length,
506 type: type
507 };
508 },
509
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;
546
547 start = offset - before;
548 end = offset + after;
549
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 }
557
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 }
565
566 let first = value.substr(0, start);
567 let mid = value.substring(start, end);
568 let last = value.substr(end);
569
570 mid = this._incrementRawValue(mid, increment, info);
571
572 if (mid !== null) {
573 return {
574 value: first + mid + last,
575 start: start,
576 end: start + mid.length
577 };
578 }
579 }
580 },
581
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);
597
598 if (isNaN(num)) {
599 return null;
600 }
601
602 let number = /\d+(\.\d+)?/.exec(rawValue);
603 let units = rawValue.substr(number.index + number[0].length);
604
605 // avoid rounding errors
606 let newValue = Math.round((num + increment) * 1000) / 1000;
607
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 }
614
615 newValue = newValue.toString();
616
617 return newValue + units;
618 },
619
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;
647
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);
652
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 }
661
662 if (rawValue.length !== 6) {
663 return;
664 }
665
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 }
674
675 // Make the selection cover entire parts.
676 offset -= offset % 2;
677 offsetEnd += offsetEnd % 2;
678
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 }
686
687 let isUpper = (rawValue.toUpperCase() === rawValue);
688
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);
693
694 if (isNaN(value)) {
695 return;
696 }
697
698 mid = Math.min(Math.max(value + increment, 0), 255).toString(16);
699
700 while (mid.length < 2) {
701 mid = "0" + mid;
702 }
703 if (isUpper) {
704 mid = mid.toUpperCase();
705 }
706
707 rawValue = rawValue.substr(0, pos) + mid + rawValue.substr(pos + 2);
708 }
709
710 return {
711 value: "#" + rawValue,
712 selection: [offset + 1, offsetEnd + 1]
713 };
714 },
715
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 },
760
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 }
769
770 this._applied = true;
771
772 if (this.done) {
773 let val = this.input.value.trim();
774 return this.done(this.cancelled ? this.initial : val, !this.cancelled);
775 }
776
777 return null;
778 },
779
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 },
834
835 /**
836 * Handle the input field's keypress event.
837 */
838 _onKeyPress: function InplaceEditor_onKeyPress(aEvent)
839 {
840 let prevent = false;
841
842 const largeIncrement = 100;
843 const mediumIncrement = 10;
844 const smallIncrement = 0.1;
845
846 let increment = 0;
847
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 }
855
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 }
866
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 }
878
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 }
889
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;
898
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 }
907
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 }
916
917 let input = this.input;
918
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 }
933
934 this._apply();
935
936 // Close the popup if open
937 if (this.popup && this.popup.isOpen) {
938 this.popup.hidePopup();
939 }
940
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);
945
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 }
952
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 }
973
974 if (prevent) {
975 aEvent.preventDefault();
976 }
977 },
978
979 /**
980 * Handle the input field's keyup event.
981 */
982 _onKeyup: function(aEvent) {
983 this._applied = false;
984 },
985
986 /**
987 * Handle changes to the input text.
988 */
989 _onInput: function InplaceEditor_onInput(aEvent)
990 {
991 // Validate the entered value.
992 this._doValidation();
993
994 // Update size if we're autosizing.
995 if (this._measurement) {
996 this._updateSize();
997 }
998
999 // Call the user's change handler if available.
1000 if (this.change) {
1001 this.change(this.input.value.trim());
1002 }
1003 },
1004
1005 /**
1006 * Fire validation callback with current input
1007 */
1008 _doValidation: function()
1009 {
1010 if (this.validate && this.input) {
1011 this.validate(this.input.value);
1012 }
1013 },
1014
1015 /**
1016 * Handles displaying suggestions based on the current input.
1017 *
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;
1025 }
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;
1034 }
1035 if (this.contentType == CONTENT_TYPES.PLAIN_TEXT) {
1036 return;
1037 }
1038 if (!this.input) {
1039 return;
1040 }
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;
1045 }
1046 let query = input.value.slice(0, input.selectionStart);
1047 let startCheckQuery = query;
1048 if (query == null) {
1049 return;
1050 }
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;
1059 }
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 = "";
1070 }
1071
1072 list =
1073 ["!important", ...domUtils.getCSSValuesForProperty(this.property.name)];
1074
1075 if (query == "") {
1076 // Do not suggest '!important' without any manually typed character.
1077 list.splice(0, 1);
1078 }
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 = "";
1094 }
1095 if (!match[2]) {
1096 // Don't suggest '!important' without any manually typed character
1097 list.splice(0, 1);
1098 }
1099 } else if (match[1]) { // We are in CSS property name completion
1100 list = CSSPropertyList;
1101 startCheckQuery = match[2];
1102 }
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;
1107 }
1108 }
1109 }
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;
1119 }
1120 });
1121 }
1122
1123 if (!this.popup) {
1124 // This emit is mainly to make the test flow simpler.
1125 this.emit("after-suggest", "no popup");
1126 return;
1127 }
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 });
1137 }
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;
1142 }
1143 else if (startCheckQuery != null && list[i][0] > startCheckQuery[0]) {
1144 // We have crossed all possible matches alphabetically.
1145 break;
1146 }
1147 }
1148
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;
1157 }
1158 } else {
1159 this.popup.hidePopup();
1160 }
1161 // This emit is mainly for the purpose of making the test flow simpler.
1162 this.emit("after-suggest");
1163 this._doValidation();
1164 }, 0);
1165 }
1166 };
1167
1168 /**
1169 * Copy text-related styles from one element to another.
1170 */
1171 function copyTextStyles(aFrom, aTo)
1172 {
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;
1179 }
1180
1181 /**
1182 * Trigger a focus change similar to pressing tab/shift-tab.
1183 */
1184 function moveFocus(aWin, aDirection)
1185 {
1186 return focusManager.moveFocus(aWin, null, aDirection, 0);
1187 }
1188
1189
1190 XPCOMUtils.defineLazyGetter(this, "focusManager", function() {
1191 return Services.focus;
1192 });
1193
1194 XPCOMUtils.defineLazyGetter(this, "CSSPropertyList", function() {
1195 return domUtils.getCSSPropertyNames(domUtils.INCLUDE_ALIASES).sort();
1196 });
1197
1198 XPCOMUtils.defineLazyGetter(this, "domUtils", function() {
1199 return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
1200 });

mercurial