gecko-dev/devtools/client/shared/inplace-editor.js

1568 lines
50 KiB
JavaScript
Raw Normal View History

/* -*- 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, direction) {
* if (commit) {
* spanToEdit.textContent = value;
* }
* },
* trigger: "dblclick"
* });
*
* See editableField() for more options.
*/
"use strict";
const Services = require("Services");
const focusManager = Services.focus;
const {KeyCodes} = require("devtools/client/shared/keycodes");
const HTML_NS = "http://www.w3.org/1999/xhtml";
const CONTENT_TYPES = {
PLAIN_TEXT: 0,
CSS_VALUE: 1,
CSS_MIXED: 2,
CSS_PROPERTY: 3,
};
// The limit of 500 autocomplete suggestions should not be reached but is kept
// for safety.
const MAX_POPUP_ENTRIES = 500;
const FOCUS_FORWARD = focusManager.MOVEFOCUS_FORWARD;
const FOCUS_BACKWARD = focusManager.MOVEFOCUS_BACKWARD;
const EventEmitter = require("devtools/shared/old-event-emitter");
const { findMostRelevantCssPropertyIndex } = require("./suggestion-picker");
/**
* Helper to check if the provided key matches one of the expected keys.
* Keys will be prefixed with DOM_VK_ and should match a key in KeyCodes.
*
* @param {String} key
* the key to check (can be a keyCode).
* @param {...String} keys
* list of possible keys allowed.
* @return {Boolean} true if the key matches one of the keys.
*/
function isKeyIn(key, ...keys) {
return keys.some(expectedKey => {
return key === KeyCodes["DOM_VK_" + expectedKey];
});
}
/**
* Mark a span editable. |editableField| will listen for the span to
* be focused and create an InlineEditor to handle text input.
* Changes will be committed when the InlineEditor's input is blurred
* or dropped when the user presses escape.
*
* @param {Object} options
* Options for the editable field, 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, a boolean telling the caller whether to
* commit the change, and the direction of the next element to be
* selected. Direction may be one of Services.focus.MOVEFOCUS_FORWARD,
* Services.focus.MOVEFOCUS_BACKWARD, or null (no movement).
* This function is called before the editor has been torn down.
* {Function} destroy:
* Called when the editor is destroyed and has been torn down.
* {Function} contextMenu:
* Called when the user triggers a contextmenu event on the input.
* {Object} advanceChars:
* This can be either a string or a function.
* If it is a string, then if any characters in it are typed,
* focus will advance to the next element.
* Otherwise, if it is a function, then the function will
* be called with three arguments: a key code, the current text,
* and the insertion point. If the function returns true,
* then the focus advance takes place. If it returns false,
* then the character is inserted instead.
* {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"
* {Boolean} multiline: Should the editor be a multiline textarea?
* defaults to false
* {Function or Number} maxWidth:
* Should the editor wrap to remain below the provided max width. Only
* available if multiline is true. If a function is provided, it will be
* called when replacing the element by the inplace input.
* {Boolean} trimOutput: Should the returned string be trimmed?
* defaults to true
* {Boolean} preserveTextStyles: If true, do not copy text-related styles
* from `element` to the new input.
* defaults to false
* {Object} cssProperties: An instance of CSSProperties.
* {Number} defaultIncrement: The value by which the input is incremented
* or decremented by default (0.1 for properties like opacity and 1 by default)
*/
function editableField(options) {
return editableItem(options, function (element, event) {
if (!options.element.inplaceEditor) {
new InplaceEditor(options, event);
}
});
}
Bug 855914 - Start using the jetpack loader in devtools. r=jwalker, f=ochameau --HG-- rename : browser/devtools/framework/Sidebar.jsm => browser/devtools/framework/sidebar.js rename : browser/devtools/framework/Target.jsm => browser/devtools/framework/target.js rename : browser/devtools/framework/ToolboxHosts.jsm => browser/devtools/framework/toolbox-hosts.js rename : browser/devtools/framework/Toolbox.jsm => browser/devtools/framework/toolbox.js rename : browser/devtools/inspector/Breadcrumbs.jsm => browser/devtools/inspector/breadcrumbs.js rename : browser/devtools/inspector/Highlighter.jsm => browser/devtools/inspector/highlighter.js rename : browser/devtools/inspector/InspectorPanel.jsm => browser/devtools/inspector/inspector-panel.js rename : browser/devtools/inspector/Selection.jsm => browser/devtools/inspector/selection.js rename : browser/devtools/inspector/SelectorSearch.jsm => browser/devtools/inspector/selector-search.js rename : browser/devtools/framework/ToolDefinitions.jsm => browser/devtools/main.js rename : browser/devtools/markupview/MarkupView.jsm => browser/devtools/markupview/markup-view.js rename : browser/devtools/shared/EventEmitter.jsm => browser/devtools/shared/event-emitter.js rename : browser/devtools/shared/InplaceEditor.jsm => browser/devtools/shared/inplace-editor.js rename : browser/devtools/shared/Undo.jsm => browser/devtools/shared/undo.js rename : browser/devtools/styleinspector/CssHtmlTree.jsm => browser/devtools/styleinspector/computed-view.js rename : browser/devtools/styleinspector/CssLogic.jsm => browser/devtools/styleinspector/css-logic.js rename : browser/devtools/styleinspector/CssRuleView.jsm => browser/devtools/styleinspector/rule-view.js rename : browser/devtools/styleinspector/StyleInspector.jsm => browser/devtools/styleinspector/style-inspector.js rename : browser/devtools/tilt/TiltGL.jsm => browser/devtools/tilt/tilt-gl.js rename : browser/devtools/tilt/TiltMath.jsm => browser/devtools/tilt/tilt-math.js rename : browser/devtools/tilt/TiltUtils.jsm => browser/devtools/tilt/tilt-utils.js rename : browser/devtools/tilt/TiltVisualizerStyle.jsm => browser/devtools/tilt/tilt-visualizer-style.js rename : browser/devtools/tilt/TiltVisualizer.jsm => browser/devtools/tilt/tilt-visualizer.js rename : browser/devtools/tilt/Tilt.jsm => browser/devtools/tilt/tilt.js
2013-04-11 20:59:08 +00:00
exports.editableField = editableField;
/**
* Handle events for an element that should respond to
* clicks and sit in the editing tab order, and call
* a callback when it is activated.
*
* @param {Object} options
* The options for this editor, including:
* {Element} element: The DOM element.
* {String} trigger: The DOM event that should trigger editing,
* defaults to "click"
* @param {Function} callback
* Called when the editor is activated.
* @return {Function} function which calls callback
*/
function editableItem(options, callback) {
let trigger = options.trigger || "click";
let element = options.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) {
callback(element, evt);
}
evt.stopPropagation();
}
});
// If focused by means other than a click, start editing by
// pressing enter or space.
element.addEventListener("keypress", function (evt) {
if (isKeyIn(evt.keyCode, "RETURN") || isKeyIn(evt.charCode, "SPACE")) {
callback(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);
element.removeEventListener("mouseout", cleanup);
};
element.style.setProperty("outline-style", "none");
element.addEventListener("mouseup", cleanup);
element.addEventListener("mouseout", cleanup);
}
});
// Mark the element editable field for tab
// navigation while editing.
element._editable = true;
// Save the trigger type so we can dispatch this later
element._trigger = trigger;
// Add button semantics to the element, to indicate that it can be activated.
element.setAttribute("role", "button");
return function turnOnEditMode() {
callback(element);
};
}
exports.editableItem = editableItem;
Bug 855914 - Start using the jetpack loader in devtools. r=jwalker, f=ochameau --HG-- rename : browser/devtools/framework/Sidebar.jsm => browser/devtools/framework/sidebar.js rename : browser/devtools/framework/Target.jsm => browser/devtools/framework/target.js rename : browser/devtools/framework/ToolboxHosts.jsm => browser/devtools/framework/toolbox-hosts.js rename : browser/devtools/framework/Toolbox.jsm => browser/devtools/framework/toolbox.js rename : browser/devtools/inspector/Breadcrumbs.jsm => browser/devtools/inspector/breadcrumbs.js rename : browser/devtools/inspector/Highlighter.jsm => browser/devtools/inspector/highlighter.js rename : browser/devtools/inspector/InspectorPanel.jsm => browser/devtools/inspector/inspector-panel.js rename : browser/devtools/inspector/Selection.jsm => browser/devtools/inspector/selection.js rename : browser/devtools/inspector/SelectorSearch.jsm => browser/devtools/inspector/selector-search.js rename : browser/devtools/framework/ToolDefinitions.jsm => browser/devtools/main.js rename : browser/devtools/markupview/MarkupView.jsm => browser/devtools/markupview/markup-view.js rename : browser/devtools/shared/EventEmitter.jsm => browser/devtools/shared/event-emitter.js rename : browser/devtools/shared/InplaceEditor.jsm => browser/devtools/shared/inplace-editor.js rename : browser/devtools/shared/Undo.jsm => browser/devtools/shared/undo.js rename : browser/devtools/styleinspector/CssHtmlTree.jsm => browser/devtools/styleinspector/computed-view.js rename : browser/devtools/styleinspector/CssLogic.jsm => browser/devtools/styleinspector/css-logic.js rename : browser/devtools/styleinspector/CssRuleView.jsm => browser/devtools/styleinspector/rule-view.js rename : browser/devtools/styleinspector/StyleInspector.jsm => browser/devtools/styleinspector/style-inspector.js rename : browser/devtools/tilt/TiltGL.jsm => browser/devtools/tilt/tilt-gl.js rename : browser/devtools/tilt/TiltMath.jsm => browser/devtools/tilt/tilt-math.js rename : browser/devtools/tilt/TiltUtils.jsm => browser/devtools/tilt/tilt-utils.js rename : browser/devtools/tilt/TiltVisualizerStyle.jsm => browser/devtools/tilt/tilt-visualizer-style.js rename : browser/devtools/tilt/TiltVisualizer.jsm => browser/devtools/tilt/tilt-visualizer.js rename : browser/devtools/tilt/Tilt.jsm => browser/devtools/tilt/tilt.js
2013-04-11 20:59:08 +00:00
/*
* 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.
*/
Bug 855914 - Start using the jetpack loader in devtools. r=jwalker, f=ochameau --HG-- rename : browser/devtools/framework/Sidebar.jsm => browser/devtools/framework/sidebar.js rename : browser/devtools/framework/Target.jsm => browser/devtools/framework/target.js rename : browser/devtools/framework/ToolboxHosts.jsm => browser/devtools/framework/toolbox-hosts.js rename : browser/devtools/framework/Toolbox.jsm => browser/devtools/framework/toolbox.js rename : browser/devtools/inspector/Breadcrumbs.jsm => browser/devtools/inspector/breadcrumbs.js rename : browser/devtools/inspector/Highlighter.jsm => browser/devtools/inspector/highlighter.js rename : browser/devtools/inspector/InspectorPanel.jsm => browser/devtools/inspector/inspector-panel.js rename : browser/devtools/inspector/Selection.jsm => browser/devtools/inspector/selection.js rename : browser/devtools/inspector/SelectorSearch.jsm => browser/devtools/inspector/selector-search.js rename : browser/devtools/framework/ToolDefinitions.jsm => browser/devtools/main.js rename : browser/devtools/markupview/MarkupView.jsm => browser/devtools/markupview/markup-view.js rename : browser/devtools/shared/EventEmitter.jsm => browser/devtools/shared/event-emitter.js rename : browser/devtools/shared/InplaceEditor.jsm => browser/devtools/shared/inplace-editor.js rename : browser/devtools/shared/Undo.jsm => browser/devtools/shared/undo.js rename : browser/devtools/styleinspector/CssHtmlTree.jsm => browser/devtools/styleinspector/computed-view.js rename : browser/devtools/styleinspector/CssLogic.jsm => browser/devtools/styleinspector/css-logic.js rename : browser/devtools/styleinspector/CssRuleView.jsm => browser/devtools/styleinspector/rule-view.js rename : browser/devtools/styleinspector/StyleInspector.jsm => browser/devtools/styleinspector/style-inspector.js rename : browser/devtools/tilt/TiltGL.jsm => browser/devtools/tilt/tilt-gl.js rename : browser/devtools/tilt/TiltMath.jsm => browser/devtools/tilt/tilt-math.js rename : browser/devtools/tilt/TiltUtils.jsm => browser/devtools/tilt/tilt-utils.js rename : browser/devtools/tilt/TiltVisualizerStyle.jsm => browser/devtools/tilt/tilt-visualizer-style.js rename : browser/devtools/tilt/TiltVisualizer.jsm => browser/devtools/tilt/tilt-visualizer.js rename : browser/devtools/tilt/Tilt.jsm => browser/devtools/tilt/tilt.js
2013-04-11 20:59:08 +00:00
function getInplaceEditorForSpan(span) {
return span.inplaceEditor;
}
Bug 855914 - Start using the jetpack loader in devtools. r=jwalker, f=ochameau --HG-- rename : browser/devtools/framework/Sidebar.jsm => browser/devtools/framework/sidebar.js rename : browser/devtools/framework/Target.jsm => browser/devtools/framework/target.js rename : browser/devtools/framework/ToolboxHosts.jsm => browser/devtools/framework/toolbox-hosts.js rename : browser/devtools/framework/Toolbox.jsm => browser/devtools/framework/toolbox.js rename : browser/devtools/inspector/Breadcrumbs.jsm => browser/devtools/inspector/breadcrumbs.js rename : browser/devtools/inspector/Highlighter.jsm => browser/devtools/inspector/highlighter.js rename : browser/devtools/inspector/InspectorPanel.jsm => browser/devtools/inspector/inspector-panel.js rename : browser/devtools/inspector/Selection.jsm => browser/devtools/inspector/selection.js rename : browser/devtools/inspector/SelectorSearch.jsm => browser/devtools/inspector/selector-search.js rename : browser/devtools/framework/ToolDefinitions.jsm => browser/devtools/main.js rename : browser/devtools/markupview/MarkupView.jsm => browser/devtools/markupview/markup-view.js rename : browser/devtools/shared/EventEmitter.jsm => browser/devtools/shared/event-emitter.js rename : browser/devtools/shared/InplaceEditor.jsm => browser/devtools/shared/inplace-editor.js rename : browser/devtools/shared/Undo.jsm => browser/devtools/shared/undo.js rename : browser/devtools/styleinspector/CssHtmlTree.jsm => browser/devtools/styleinspector/computed-view.js rename : browser/devtools/styleinspector/CssLogic.jsm => browser/devtools/styleinspector/css-logic.js rename : browser/devtools/styleinspector/CssRuleView.jsm => browser/devtools/styleinspector/rule-view.js rename : browser/devtools/styleinspector/StyleInspector.jsm => browser/devtools/styleinspector/style-inspector.js rename : browser/devtools/tilt/TiltGL.jsm => browser/devtools/tilt/tilt-gl.js rename : browser/devtools/tilt/TiltMath.jsm => browser/devtools/tilt/tilt-math.js rename : browser/devtools/tilt/TiltUtils.jsm => browser/devtools/tilt/tilt-utils.js rename : browser/devtools/tilt/TiltVisualizerStyle.jsm => browser/devtools/tilt/tilt-visualizer-style.js rename : browser/devtools/tilt/TiltVisualizer.jsm => browser/devtools/tilt/tilt-visualizer.js rename : browser/devtools/tilt/Tilt.jsm => browser/devtools/tilt/tilt.js
2013-04-11 20:59:08 +00:00
exports.getInplaceEditorForSpan = getInplaceEditorForSpan;
function InplaceEditor(options, event) {
this.elt = options.element;
let doc = this.elt.ownerDocument;
this.doc = doc;
this.elt.inplaceEditor = this;
this.cssProperties = options.cssProperties;
this.change = options.change;
this.done = options.done;
this.contextMenu = options.contextMenu;
this.defaultIncrement = options.defaultIncrement || 1;
this.destroy = options.destroy;
this.initial = options.initial ? options.initial : this.elt.textContent;
this.multiline = options.multiline || false;
this.maxWidth = options.maxWidth;
if (typeof this.maxWidth == "function") {
this.maxWidth = this.maxWidth();
}
this.trimOutput = options.trimOutput === undefined
? true
: !!options.trimOutput;
this.stopOnShiftTab = !!options.stopOnShiftTab;
this.stopOnTab = !!options.stopOnTab;
this.stopOnReturn = !!options.stopOnReturn;
this.contentType = options.contentType || CONTENT_TYPES.PLAIN_TEXT;
this.property = options.property;
this.popup = options.popup;
this.preserveTextStyles = options.preserveTextStyles === undefined
? false
: !!options.preserveTextStyles;
this._onBlur = this._onBlur.bind(this);
this._onWindowBlur = this._onWindowBlur.bind(this);
this._onKeyPress = this._onKeyPress.bind(this);
this._onInput = this._onInput.bind(this);
this._onKeyup = this._onKeyup.bind(this);
this._onAutocompletePopupClick = this._onAutocompletePopupClick.bind(this);
this._onContextMenu = this._onContextMenu.bind(this);
this._createInput();
// Hide the provided element and add our editor.
this.originalDisplay = this.elt.style.display;
this.elt.style.display = "none";
this.elt.parentNode.insertBefore(this.input, this.elt);
// After inserting the input to have all CSS styles applied, start autosizing.
this._autosize();
this.inputCharDimensions = this._getInputCharDimensions();
// Pull out character codes for advanceChars, listing the
// characters that should trigger a blur.
if (typeof options.advanceChars === "function") {
this._advanceChars = options.advanceChars;
} else {
let advanceCharcodes = {};
let advanceChars = options.advanceChars || "";
for (let i = 0; i < advanceChars.length; i++) {
advanceCharcodes[advanceChars.charCodeAt(i)] = true;
}
this._advanceChars = charCode => charCode in advanceCharcodes;
}
this.input.focus();
if (typeof options.selectAll == "undefined" || options.selectAll) {
this.input.select();
}
if (this.contentType == CONTENT_TYPES.CSS_VALUE && this.input.value == "") {
this._maybeSuggestCompletion(false);
}
this.input.addEventListener("blur", this._onBlur);
this.input.addEventListener("keypress", this._onKeyPress);
this.input.addEventListener("input", this._onInput);
this.input.addEventListener("dblclick", this._stopEventPropagation);
this.input.addEventListener("click", this._stopEventPropagation);
this.input.addEventListener("mousedown", this._stopEventPropagation);
this.input.addEventListener("contextmenu", this._onContextMenu);
this.doc.defaultView.addEventListener("blur", this._onWindowBlur);
this.validate = options.validate;
if (this.validate) {
this.input.addEventListener("keyup", this._onKeyup);
}
this._updateSize();
EventEmitter.decorate(this);
if (options.start) {
options.start(this, event);
}
}
Bug 855914 - Start using the jetpack loader in devtools. r=jwalker, f=ochameau --HG-- rename : browser/devtools/framework/Sidebar.jsm => browser/devtools/framework/sidebar.js rename : browser/devtools/framework/Target.jsm => browser/devtools/framework/target.js rename : browser/devtools/framework/ToolboxHosts.jsm => browser/devtools/framework/toolbox-hosts.js rename : browser/devtools/framework/Toolbox.jsm => browser/devtools/framework/toolbox.js rename : browser/devtools/inspector/Breadcrumbs.jsm => browser/devtools/inspector/breadcrumbs.js rename : browser/devtools/inspector/Highlighter.jsm => browser/devtools/inspector/highlighter.js rename : browser/devtools/inspector/InspectorPanel.jsm => browser/devtools/inspector/inspector-panel.js rename : browser/devtools/inspector/Selection.jsm => browser/devtools/inspector/selection.js rename : browser/devtools/inspector/SelectorSearch.jsm => browser/devtools/inspector/selector-search.js rename : browser/devtools/framework/ToolDefinitions.jsm => browser/devtools/main.js rename : browser/devtools/markupview/MarkupView.jsm => browser/devtools/markupview/markup-view.js rename : browser/devtools/shared/EventEmitter.jsm => browser/devtools/shared/event-emitter.js rename : browser/devtools/shared/InplaceEditor.jsm => browser/devtools/shared/inplace-editor.js rename : browser/devtools/shared/Undo.jsm => browser/devtools/shared/undo.js rename : browser/devtools/styleinspector/CssHtmlTree.jsm => browser/devtools/styleinspector/computed-view.js rename : browser/devtools/styleinspector/CssLogic.jsm => browser/devtools/styleinspector/css-logic.js rename : browser/devtools/styleinspector/CssRuleView.jsm => browser/devtools/styleinspector/rule-view.js rename : browser/devtools/styleinspector/StyleInspector.jsm => browser/devtools/styleinspector/style-inspector.js rename : browser/devtools/tilt/TiltGL.jsm => browser/devtools/tilt/tilt-gl.js rename : browser/devtools/tilt/TiltMath.jsm => browser/devtools/tilt/tilt-math.js rename : browser/devtools/tilt/TiltUtils.jsm => browser/devtools/tilt/tilt-utils.js rename : browser/devtools/tilt/TiltVisualizerStyle.jsm => browser/devtools/tilt/tilt-visualizer-style.js rename : browser/devtools/tilt/TiltVisualizer.jsm => browser/devtools/tilt/tilt-visualizer.js rename : browser/devtools/tilt/Tilt.jsm => browser/devtools/tilt/tilt.js
2013-04-11 20:59:08 +00:00
exports.InplaceEditor = InplaceEditor;
InplaceEditor.CONTENT_TYPES = CONTENT_TYPES;
InplaceEditor.prototype = {
get currentInputValue() {
let val = this.trimOutput ? this.input.value.trim() : this.input.value;
return val;
},
_createInput: function () {
this.input =
this.doc.createElementNS(HTML_NS, this.multiline ? "textarea" : "input");
this.input.inplaceEditor = this;
if (this.multiline) {
// Hide the textarea resize handle.
this.input.style.resize = "none";
this.input.style.overflow = "hidden";
}
this.input.classList.add("styleinspector-propertyeditor");
this.input.value = this.initial;
if (!this.preserveTextStyles) {
copyTextStyles(this.elt, this.input);
}
},
/**
* Get rid of the editor.
*/
_clear: function () {
if (!this.input) {
// Already cleared.
return;
}
this.input.removeEventListener("blur", this._onBlur);
this.input.removeEventListener("keypress", this._onKeyPress);
this.input.removeEventListener("keyup", this._onKeyup);
this.input.removeEventListener("input", this._onInput);
this.input.removeEventListener("dblclick", this._stopEventPropagation);
this.input.removeEventListener("click", this._stopEventPropagation);
this.input.removeEventListener("mousedown", this._stopEventPropagation);
this.input.removeEventListener("contextmenu", this._onContextMenu);
this.doc.defaultView.removeEventListener("blur", this._onWindowBlur);
this._stopAutosize();
this.elt.style.display = this.originalDisplay;
if (this.doc.activeElement == this.input) {
this.elt.focus();
}
this.input.remove();
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 () {
// 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";
if (this.multiline) {
style.whiteSpace = "pre-wrap";
style.wordWrap = "break-word";
if (this.maxWidth) {
style.maxWidth = this.maxWidth + "px";
// Use position fixed to measure dimensions without any influence from
// the container of the editor.
style.position = "fixed";
}
}
copyAllStyles(this.input, this._measurement);
this._updateSize();
},
/**
* Clean up the mess created by _autosize().
*/
_stopAutosize: function () {
if (!this._measurement) {
return;
}
this._measurement.remove();
delete this._measurement;
},
/**
* Size the editor to fit its current contents.
*/
_updateSize: function () {
// Replace spaces with non-breaking spaces. Otherwise setting
// the span's textContent will collapse spaces and the measurement
// will be wrong.
let content = this.input.value;
let unbreakableSpace = "\u00a0";
// Make sure the content is not empty.
if (content === "") {
content = unbreakableSpace;
}
// If content ends with a new line, add a blank space to force the autosize
// element to adapt its height.
if (content.lastIndexOf("\n") === content.length - 1) {
content = content + unbreakableSpace;
}
if (!this.multiline) {
content = content.replace(/ /g, unbreakableSpace);
}
this._measurement.textContent = content;
// Do not use offsetWidth: it will round floating width values.
let width = this._measurement.getBoundingClientRect().width + 2;
if (this.multiline) {
if (this.maxWidth) {
width = Math.min(this.maxWidth, width);
}
let height = this._measurement.getBoundingClientRect().height;
this.input.style.height = height + "px";
}
this.input.style.width = width + "px";
},
/**
* Get the width and height of a single character in the input to properly
* position the autocompletion popup.
*/
_getInputCharDimensions: function () {
// Just make the text content to be 'x' to get the width and height of any
// character in a monospace font.
this._measurement.textContent = "x";
let width = this._measurement.clientWidth;
let height = this._measurement.clientHeight;
return { width, height };
},
/**
* Increment property values in rule view.
*
* @param {Number} increment
* The amount to increase/decrease the property value.
* @return {Boolean} true if value has been incremented.
*/
_incrementValue: function (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.currentInputValue);
}
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 (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 preRawValue = range ? value.substr(0, range.start) : "";
let postRawValue = range ? value.substr(range.end) : "";
let info;
let incrementedValue = null, selection;
if (type === "num") {
if (rawValue == "0") {
info = {};
info.units = this._findCompatibleUnit(preRawValue, postRawValue);
}
let newValue = this._incrementRawValue(rawValue, increment, info);
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 {
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 null;
}
return {
value: preRawValue + incrementedValue + postRawValue,
start: range.start + selection[0],
end: range.start + selection[1]
};
},
/**
* Find a compatible unit to use for a CSS number value inserted between the
* provided beforeValue and afterValue. The compatible unit will be picked
* from a selection of default units corresponding to supported CSS value
* dimensions (distance, angle, duration).
*
* @param {String} beforeValue
* The string preceeding the number value in the current property
* value.
* @param {String} afterValue
* The string following the number value in the current property value.
* @return {String} a valid unit that can be used for this number value or
* empty string if no match could be found.
*/
_findCompatibleUnit: function (beforeValue, afterValue) {
if (!this.property || !this.property.name) {
return "";
}
// A DOM element is used to test the validity of various units. This is to
// avoid having to do an async call to the server to get this information.
let el = this.doc.createElement("div");
let units = ["px", "deg", "s"];
for (let unit of units) {
let value = beforeValue + "1" + unit + afterValue;
el.style.setProperty(this.property.name, "");
el.style.setProperty(this.property.name, value);
if (el.style.getPropertyValue(this.property.name) !== "") {
return unit;
}
}
return "";
},
/**
* Parses the property value and type.
*
* @param {String} value
* Property value.
* @param {Number} offset
* Starting index of value.
* @return {Object} object with properties 'value', 'start', 'end', and
* 'type'.
*/
_parseCSSValue: function (value, offset) {
/* eslint-disable max-len */
const reSplitCSS = /(url\("?[^"\)]+"?\)?)|(rgba?\([^)]*\)?)|(hsla?\([^)]*\)?)|(#[\dA-Fa-f]+)|(-?\d*\.?\d+(%|[a-z]{1,4})?)|"([^"]*)"?|'([^']*)'?|([^,\s\/!\(\)]+)|(!(.*)?)/;
/* eslint-enable */
let start = 0;
let m;
// retreive values from left to right until we find the one at our offset
while ((m = reSplitCSS.exec(value)) &&
(m.index + m[0].length < offset)) {
value = value.substr(m.index + m[0].length);
start += m.index + m[0].length;
offset -= m.index + m[0].length;
}
if (!m) {
return null;
}
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 (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
};
}
}
return null;
},
/**
* 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 (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);
if (info && "units" in info) {
units = info.units;
}
// 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 (rawValue, increment, offset, offsetEnd) {
// Return early if no part of the rawValue is selected.
if (offsetEnd > rawValue.length && offset >= rawValue.length) {
return null;
}
if (offset < 1 && offsetEnd <= 1) {
return null;
}
// 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;
}
// Normalize #ABCD -> #AABBCCDD.
if (rawValue.length === 4) {
rawValue = rawValue.charAt(0) + rawValue.charAt(0) +
rawValue.charAt(1) + rawValue.charAt(1) +
rawValue.charAt(2) + rawValue.charAt(2) +
rawValue.charAt(3) + rawValue.charAt(3);
offset *= 2;
offsetEnd *= 2;
}
if (rawValue.length !== 6 && rawValue.length !== 8) {
return null;
}
// If no selection, increment an adjacent color, preferably one to the left.
if (offset === offsetEnd) {
if (offset === 0) {
offsetEnd = 1;
} else {
offset = offsetEnd - 1;
}
}
// Make the selection cover entire parts.
offset -= offset % 2;
offsetEnd += offsetEnd % 2;
// Remap the increments from [0.1, 1, 10] to [1, 1, 16].
if (increment > -1 && increment < 1) {
increment = (increment < 0 ? -1 : 1);
}
if (Math.abs(increment) === 10) {
increment = (increment < 0 ? -16 : 16);
}
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 null;
}
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} reverse
* true to select previous item from the popup.
* @param {Boolean} noSelect
* true to not select the text after selecting the newly selectedItem
* from the popup.
*/
_cycleCSSSuggestion: function (reverse, noSelect) {
// selectedItem can be null when nothing is selected in an empty editor.
let {label, preLabel} = this.popup.selectedItem ||
{label: "", preLabel: ""};
if (reverse) {
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 (!noSelect) {
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 (event, direction) {
if (this._applied) {
return null;
}
this._applied = true;
if (this.done) {
let val = this.cancelled ? this.initial : this.currentInputValue;
return this.done(val, !this.cancelled, direction);
}
return null;
},
/**
* Hide the popup and cancel any pending popup opening.
*/
_onWindowBlur: function () {
if (this.popup && this.popup.isOpen) {
this.popup.hidePopup();
}
if (this._openPopupTimeout) {
this.doc.defaultView.clearTimeout(this._openPopupTimeout);
}
},
/**
* Event handler called when the inplace-editor's input loses focus.
*/
_onBlur: function (event) {
if (event && this.popup && this.popup.isOpen &&
this.popup.selectedIndex >= 0) {
this._acceptPopupSuggestion();
} else {
this._apply();
this._clear();
}
},
/**
* Event handler called by the autocomplete popup when receiving a click
* event.
*/
_onAutocompletePopupClick: function () {
this._acceptPopupSuggestion();
},
_acceptPopupSuggestion: function () {
let label, preLabel;
if (this._selectedIndex === undefined) {
Bug 1266456 - part9: use HTMLTooltip for autocomplete-popup;r=bgrins Modify the devtools autocomplete-popup to rely on a HTMLTooltip instance instead of a XUL panel. Other than the straightforward migration to HTML, the main difference with the new implementation is that the richlistbox has now been replace with a simple HTML list element. The former XUL widget used to be able to take the focus from the input it was linked to. This is no longer the case. Most autocomplete users were always keeping the focus in the input, except for the inspector-search, which was moving the focus back and forth between the input and the autocomplete's richlistbox. Now the focus is always in the input. A practical example to illustrate how this changes the UX: before when the user had the focus on the first element of the list, pressing "DOWN" would keep the element selected but visually move the focus in the input. Now the selection simply cycles to the next item. Even though this introduces a difference in behaviour compared to the previous implementation, it makes the inspector search UX consistent with the other autocomplete widgets used in devtools. Another difference is about the display for the inspector-search. The position of the autocomplete popup used to be above the input. This is now impossible to achieve because the search input is at the top of the toolbox and the HTML tooltip can not exceed the limits of the toolbox. For this #2 issue, either we manage to use XUL panel wrappers, in which case, the autocomplete will be displayed as it used to. Or we can invert the order in which items are inserted and explicitly ask for the autocomplete to be displayed below the input. I prefered not to change this here in order to make the code change easier to understand, but it should be addressed in a follow-up. MozReview-Commit-ID: jH9aXm9Jvz --HG-- extra : rebase_source : 57267be0d214ed807f3152838c4123400ab7b7e3
2016-07-07 14:32:42 +00:00
({label, preLabel} = this.popup.getItemAtIndex(this.popup.selectedIndex));
} else {
({label, preLabel} = this.popup.getItemAtIndex(this._selectedIndex));
}
let input = this.input;
Bug 1266456 - part9: use HTMLTooltip for autocomplete-popup;r=bgrins Modify the devtools autocomplete-popup to rely on a HTMLTooltip instance instead of a XUL panel. Other than the straightforward migration to HTML, the main difference with the new implementation is that the richlistbox has now been replace with a simple HTML list element. The former XUL widget used to be able to take the focus from the input it was linked to. This is no longer the case. Most autocomplete users were always keeping the focus in the input, except for the inspector-search, which was moving the focus back and forth between the input and the autocomplete's richlistbox. Now the focus is always in the input. A practical example to illustrate how this changes the UX: before when the user had the focus on the first element of the list, pressing "DOWN" would keep the element selected but visually move the focus in the input. Now the selection simply cycles to the next item. Even though this introduces a difference in behaviour compared to the previous implementation, it makes the inspector search UX consistent with the other autocomplete widgets used in devtools. Another difference is about the display for the inspector-search. The position of the autocomplete popup used to be above the input. This is now impossible to achieve because the search input is at the top of the toolbox and the HTML tooltip can not exceed the limits of the toolbox. For this #2 issue, either we manage to use XUL panel wrappers, in which case, the autocomplete will be displayed as it used to. Or we can invert the order in which items are inserted and explicitly ask for the autocomplete to be displayed below the input. I prefered not to change this here in order to make the code change easier to understand, but it should be addressed in a follow-up. MozReview-Commit-ID: jH9aXm9Jvz --HG-- extra : rebase_source : 57267be0d214ed807f3152838c4123400ab7b7e3
2016-07-07 14:32:42 +00:00
let pre = "";
// CSS_MIXED needs special treatment here to make it so that
// multiple presses of tab will cycle through completions, but
// without selecting the completed text. However, this same
// special treatment will do the wrong thing for other editing
// styles.
if (input.selectionStart < input.selectionEnd ||
this.contentType !== CONTENT_TYPES.CSS_MIXED) {
pre = input.value.slice(0, input.selectionStart);
} else {
pre = input.value.slice(0, input.selectionStart - label.length +
preLabel.length);
}
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 = () => {
Bug 1266456 - part9: use HTMLTooltip for autocomplete-popup;r=bgrins Modify the devtools autocomplete-popup to rely on a HTMLTooltip instance instead of a XUL panel. Other than the straightforward migration to HTML, the main difference with the new implementation is that the richlistbox has now been replace with a simple HTML list element. The former XUL widget used to be able to take the focus from the input it was linked to. This is no longer the case. Most autocomplete users were always keeping the focus in the input, except for the inspector-search, which was moving the focus back and forth between the input and the autocomplete's richlistbox. Now the focus is always in the input. A practical example to illustrate how this changes the UX: before when the user had the focus on the first element of the list, pressing "DOWN" would keep the element selected but visually move the focus in the input. Now the selection simply cycles to the next item. Even though this introduces a difference in behaviour compared to the previous implementation, it makes the inspector search UX consistent with the other autocomplete widgets used in devtools. Another difference is about the display for the inspector-search. The position of the autocomplete popup used to be above the input. This is now impossible to achieve because the search input is at the top of the toolbox and the HTML tooltip can not exceed the limits of the toolbox. For this #2 issue, either we manage to use XUL panel wrappers, in which case, the autocomplete will be displayed as it used to. Or we can invert the order in which items are inserted and explicitly ask for the autocomplete to be displayed below the input. I prefered not to change this here in order to make the code change easier to understand, but it should be addressed in a follow-up. MozReview-Commit-ID: jH9aXm9Jvz --HG-- extra : rebase_source : 57267be0d214ed807f3152838c4123400ab7b7e3
2016-07-07 14:32:42 +00:00
this.popup.off("popup-closed", onPopupHidden);
this.doc.defaultView.setTimeout(()=> {
input.focus();
this.emit("after-suggest");
}, 0);
};
Bug 1266456 - part9: use HTMLTooltip for autocomplete-popup;r=bgrins Modify the devtools autocomplete-popup to rely on a HTMLTooltip instance instead of a XUL panel. Other than the straightforward migration to HTML, the main difference with the new implementation is that the richlistbox has now been replace with a simple HTML list element. The former XUL widget used to be able to take the focus from the input it was linked to. This is no longer the case. Most autocomplete users were always keeping the focus in the input, except for the inspector-search, which was moving the focus back and forth between the input and the autocomplete's richlistbox. Now the focus is always in the input. A practical example to illustrate how this changes the UX: before when the user had the focus on the first element of the list, pressing "DOWN" would keep the element selected but visually move the focus in the input. Now the selection simply cycles to the next item. Even though this introduces a difference in behaviour compared to the previous implementation, it makes the inspector search UX consistent with the other autocomplete widgets used in devtools. Another difference is about the display for the inspector-search. The position of the autocomplete popup used to be above the input. This is now impossible to achieve because the search input is at the top of the toolbox and the HTML tooltip can not exceed the limits of the toolbox. For this #2 issue, either we manage to use XUL panel wrappers, in which case, the autocomplete will be displayed as it used to. Or we can invert the order in which items are inserted and explicitly ask for the autocomplete to be displayed below the input. I prefered not to change this here in order to make the code change easier to understand, but it should be addressed in a follow-up. MozReview-Commit-ID: jH9aXm9Jvz --HG-- extra : rebase_source : 57267be0d214ed807f3152838c4123400ab7b7e3
2016-07-07 14:32:42 +00:00
this.popup.on("popup-closed", onPopupHidden);
this._hideAutocompletePopup();
},
/**
* Handle the input field's keypress event.
*/
_onKeyPress: function (event) {
let prevent = false;
let key = event.keyCode;
let input = this.input;
let multilineNavigation = !this._isSingleLine() &&
isKeyIn(key, "UP", "DOWN", "LEFT", "RIGHT");
let isPlainText = this.contentType == CONTENT_TYPES.PLAIN_TEXT;
let isPopupOpen = this.popup && this.popup.isOpen;
let increment = 0;
if (!isPlainText && !multilineNavigation) {
increment = this._getIncrement(event);
}
if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) {
this._preventSuggestions = true;
}
let cycling = false;
if (increment && this._incrementValue(increment)) {
this._updateSize();
prevent = true;
cycling = true;
}
if (isPopupOpen && isKeyIn(key, "UP", "DOWN", "PAGE_UP", "PAGE_DOWN")) {
prevent = true;
cycling = true;
this._cycleCSSSuggestion(isKeyIn(key, "UP", "PAGE_UP"));
this._doValidation();
}
if (isKeyIn(key, "BACK_SPACE", "DELETE", "LEFT", "RIGHT", "HOME", "END")) {
if (isPopupOpen) {
this._hideAutocompletePopup();
}
} else if (!cycling && !multilineNavigation &&
!event.metaKey && !event.altKey && !event.ctrlKey) {
this._maybeSuggestCompletion(true);
}
if (this.multiline && event.shiftKey && isKeyIn(key, "RETURN")) {
prevent = false;
} else if (
this._advanceChars(event.charCode, input.value, input.selectionStart) ||
isKeyIn(key, "RETURN", "TAB")) {
prevent = true;
let direction;
if ((this.stopOnReturn && isKeyIn(key, "RETURN")) ||
(this.stopOnTab && !event.shiftKey && isKeyIn(key, "TAB")) ||
(this.stopOnShiftTab && event.shiftKey && isKeyIn(key, "TAB"))) {
direction = null;
} else if (event.shiftKey && isKeyIn(key, "TAB")) {
direction = FOCUS_BACKWARD;
} else {
direction = FOCUS_FORWARD;
}
// Now we don't want to suggest anything as we are moving out.
this._preventSuggestions = true;
// But we still want to show suggestions for css values. i.e. moving out
// of css property input box in forward direction
if (this.contentType == CONTENT_TYPES.CSS_PROPERTY &&
direction == FOCUS_FORWARD) {
this._preventSuggestions = false;
}
if (isKeyIn(key, "TAB") && this.contentType == CONTENT_TYPES.CSS_MIXED) {
if (this.popup && input.selectionStart < input.selectionEnd) {
event.preventDefault();
input.setSelectionRange(input.selectionEnd, input.selectionEnd);
this.emit("after-suggest");
return;
} else if (this.popup && this.popup.isOpen) {
event.preventDefault();
this._cycleCSSSuggestion(event.shiftKey, true);
return;
}
}
this._apply(event, direction);
// Close the popup if open
if (this.popup && this.popup.isOpen) {
this._hideAutocompletePopup();
}
if (direction !== null && focusManager.focusedElement === input) {
// If the focused element wasn't changed by the done callback,
// move the focus as requested.
let next = moveFocus(this.doc.defaultView, direction);
// If the next node to be focused has been tagged as an editable
// node, trigger editing using the configured event
if (next && next.ownerDocument === this.doc && next._editable) {
let e = this.doc.createEvent("Event");
e.initEvent(next._trigger, true, true);
next.dispatchEvent(e);
}
}
this._clear();
} else if (isKeyIn(key, "ESCAPE")) {
// Cancel and blur ourselves.
// Now we don't want to suggest anything as we are moving out.
this._preventSuggestions = true;
// Close the popup if open
if (this.popup && this.popup.isOpen) {
this._hideAutocompletePopup();
}
prevent = true;
this.cancelled = true;
this._apply();
this._clear();
event.stopPropagation();
} else if (isKeyIn(key, "SPACE")) {
// No need for leading spaces here. This is particularly
// noticable when adding a property: it's very natural to type
// <name>: (which advances to the next property) then spacebar.
prevent = !input.value;
}
if (prevent) {
event.preventDefault();
}
},
_onContextMenu: function (event) {
if (this.contextMenu) {
this.contextMenu(event);
}
},
/**
* Open the autocomplete popup, adding a custom click handler and classname.
*
* @param {Number} offset
* X-offset relative to the input starting edge.
* @param {Number} selectedIndex
* The index of the item that should be selected. Use -1 to have no
* item selected.
*/
_openAutocompletePopup: function (offset, selectedIndex) {
this.popup.on("popup-click", this._onAutocompletePopupClick);
this.popup.openPopup(this.input, offset, 0, selectedIndex);
},
/**
* Remove the custom classname and click handler and close the autocomplete
* popup.
*/
_hideAutocompletePopup: function () {
this.popup.off("popup-click", this._onAutocompletePopupClick);
this.popup.hidePopup();
},
/**
* Get the increment/decrement step to use for the provided key event.
*/
_getIncrement: function (event) {
const largeIncrement = 100;
const mediumIncrement = 10;
const smallIncrement = 0.1;
let increment = 0;
let key = event.keyCode;
if (isKeyIn(key, "UP", "PAGE_UP")) {
increment = 1 * this.defaultIncrement;
} else if (isKeyIn(key, "DOWN", "PAGE_DOWN")) {
increment = -1 * this.defaultIncrement;
}
if (event.shiftKey && !event.altKey) {
if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) {
increment *= largeIncrement;
} else {
increment *= mediumIncrement;
}
} else if (event.altKey && !event.shiftKey) {
increment *= smallIncrement;
}
return increment;
},
/**
* Handle the input field's keyup event.
*/
_onKeyup: function () {
this._applied = false;
},
/**
* Handle changes to the input text.
*/
_onInput: function () {
// Validate the entered value.
this._doValidation();
// Update size if we're autosizing.
if (this._measurement) {
this._updateSize();
}
// Call the user's change handler if available.
if (this.change) {
this.change(this.currentInputValue);
}
},
/**
* Stop propagation on the provided event
*/
_stopEventPropagation: function (e) {
e.stopPropagation();
},
/**
* 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} autoInsert
* Pass true to automatically insert the most relevant suggestion.
*/
_maybeSuggestCompletion: function (autoInsert) {
// 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._openPopupTimeout = 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 word (\w) character after the cursor, do
// not autocomplete.
if (input.selectionStart == input.selectionEnd &&
input.selectionStart < input.value.length) {
let nextChar = input.value.slice(input.selectionStart)[0];
// Check if the next character is a valid word character, no suggestion should be
// provided when preceeding a word.
if (/[\w-]/.test(nextChar)) {
// This emit is mainly to make the test flow simpler.
this.emit("after-suggest", "nothing to autocomplete");
return;
}
}
let list = [];
if (this.contentType == CONTENT_TYPES.CSS_PROPERTY) {
list = this._getCSSPropertyList();
} 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",
...this._getCSSValuesForPropertyName(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)) {
// Check if the style attribute is closed before the selection.
let styleValue = query.replace(/^\s*style\s*=\s*/, "");
// Look for a quote matching the opening quote (single or double).
if (/^("[^"]*"|'[^']*')/.test(styleValue)) {
// This emit is mainly to make the test flow simpler.
this.emit("after-suggest", "nothing to autocomplete");
return;
}
// Detecting if cursor is at property or value;
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;",
...this._getCSSValuesForPropertyName(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 = this._getCSSPropertyList();
startCheckQuery = match[2];
}
if (startCheckQuery == null) {
// This emit is mainly to make the test flow simpler.
this.emit("after-suggest", "nothing to autocomplete");
return;
}
}
}
if (!this.popup) {
// This emit is mainly to make the test flow simpler.
this.emit("after-suggest", "no popup");
return;
}
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;
}
}
// Sort items starting with [a-z0-9] first, to make sure vendor-prefixed
// values and "!important" are suggested only after standard values.
finalList.sort((item1, item2) => {
// Get the expected alphabetical comparison between the items.
let comparison = item1.label.localeCompare(item2.label);
if (/^\w/.test(item1.label) != /^\w/.test(item2.label)) {
// One starts with [a-z0-9], one does not: flip the comparison.
comparison = -1 * comparison;
}
return comparison;
});
let index = 0;
if (startCheckQuery) {
// Only select a "best" suggestion when the user started a query.
let cssValues = finalList.map(item => item.label);
index = findMostRelevantCssPropertyIndex(cssValues);
}
// Insert the most relevant item from the final list as the input value.
if (autoInsert && finalList[index]) {
let item = finalList[index].label;
input.value = query + item.slice(startCheckQuery.length) +
input.value.slice(query.length);
input.setSelectionRange(query.length, query.length + item.length -
startCheckQuery.length);
this._updateSize();
}
// Display the list of suggestions if there are more than one.
if (finalList.length > 1) {
// Calculate the popup horizontal offset.
let indent = this.input.selectionStart - startCheckQuery.length;
let offset = indent * this.inputCharDimensions.width;
offset = this._isSingleLine() ? offset : 0;
// Select the most relevantItem if autoInsert is allowed
let selectedIndex = autoInsert ? index : -1;
// Open the suggestions popup.
this.popup.setItems(finalList);
this._openAutocompletePopup(offset, selectedIndex);
} else {
this._hideAutocompletePopup();
}
// This emit is mainly for the purpose of making the test flow simpler.
this.emit("after-suggest");
this._doValidation();
}, 0);
},
/**
* Check if the current input is displaying more than one line of text.
*
* @return {Boolean} true if the input has a single line of text
*/
_isSingleLine: function () {
let inputRect = this.input.getBoundingClientRect();
return inputRect.height < 2 * this.inputCharDimensions.height;
},
/**
* Returns the list of CSS properties to use for the autocompletion. This
* method is overridden by tests in order to use mocked suggestion lists.
*
* @return {Array} array of CSS property names (Strings)
*/
_getCSSPropertyList: function () {
return this.cssProperties.getNames().sort();
},
/**
* Returns a list of CSS values valid for a provided property name to use for
* the autocompletion. This method is overridden by tests in order to use
* mocked suggestion lists.
*
* @param {String} propertyName
* @return {Array} array of CSS property values (Strings)
*/
_getCSSValuesForPropertyName: function (propertyName) {
return this.cssProperties.getValues(propertyName);
},
};
/**
* Copy text-related styles from one element to another.
*/
function copyTextStyles(from, to) {
let win = from.ownerDocument.defaultView;
let style = win.getComputedStyle(from);
let getCssText = name => style.getPropertyCSSValue(name).cssText;
to.style.fontFamily = getCssText("font-family");
to.style.fontSize = getCssText("font-size");
to.style.fontWeight = getCssText("font-weight");
to.style.fontStyle = getCssText("font-style");
}
/**
* Copy all styles which could have an impact on the element size.
*/
function copyAllStyles(from, to) {
let win = from.ownerDocument.defaultView;
let style = win.getComputedStyle(from);
let getCssText = name => style.getPropertyCSSValue(name).cssText;
copyTextStyles(from, to);
to.style.lineHeight = getCssText("line-height");
// If box-sizing is set to border-box, box model styles also need to be
// copied.
let boxSizing = getCssText("box-sizing");
if (boxSizing === "border-box") {
to.style.boxSizing = boxSizing;
copyBoxModelStyles(from, to);
}
}
/**
* Copy box model styles that can impact width and height measurements when box-
* sizing is set to "border-box" instead of "content-box".
*
* @param {DOMNode} from
* the element from which styles are copied
* @param {DOMNode} to
* the element on which copied styles are applied
*/
function copyBoxModelStyles(from, to) {
let win = from.ownerDocument.defaultView;
let style = win.getComputedStyle(from);
let getCssText = name => style.getPropertyCSSValue(name).cssText;
// Copy all paddings.
to.style.paddingTop = getCssText("padding-top");
to.style.paddingRight = getCssText("padding-right");
to.style.paddingBottom = getCssText("padding-bottom");
to.style.paddingLeft = getCssText("padding-left");
// Copy border styles.
to.style.borderTopStyle = getCssText("border-top-style");
to.style.borderRightStyle = getCssText("border-right-style");
to.style.borderBottomStyle = getCssText("border-bottom-style");
to.style.borderLeftStyle = getCssText("border-left-style");
// Copy border widths.
to.style.borderTopWidth = getCssText("border-top-width");
to.style.borderRightWidth = getCssText("border-right-width");
to.style.borderBottomWidth = getCssText("border-bottom-width");
to.style.borderLeftWidth = getCssText("border-left-width");
}
/**
* Trigger a focus change similar to pressing tab/shift-tab.
*/
function moveFocus(win, direction) {
return focusManager.moveFocus(win, null, direction, 0);
}