diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/client/inspector/rules/views/class-list-previewer.js | 310 |
1 files changed, 310 insertions, 0 deletions
diff --git a/devtools/client/inspector/rules/views/class-list-previewer.js b/devtools/client/inspector/rules/views/class-list-previewer.js new file mode 100644 index 0000000000..e4e99bedde --- /dev/null +++ b/devtools/client/inspector/rules/views/class-list-previewer.js @@ -0,0 +1,310 @@ +/* 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/. */ + +"use strict"; + +const ClassList = require("resource://devtools/client/inspector/rules/models/class-list.js"); + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const L10N = new LocalizationHelper( + "devtools/client/locales/inspector.properties" +); +const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); +const { debounce } = require("resource://devtools/shared/debounce.js"); + +/** + * This UI widget shows a textfield and a series of checkboxes in the rule-view. It is + * used to toggle classes on the current node selection, and add new classes. + */ +class ClassListPreviewer { + /* + * @param {Inspector} inspector + * The current inspector instance. + * @param {DomNode} containerEl + * The element in the rule-view where the widget should go. + */ + constructor(inspector, containerEl) { + this.inspector = inspector; + this.containerEl = containerEl; + this.model = new ClassList(inspector); + + this.onNewSelection = this.onNewSelection.bind(this); + this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onAddElementInputModified = debounce( + this.onAddElementInputModified, + 75, + this + ); + this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this); + this.onNodeFrontWillUnset = this.onNodeFrontWillUnset.bind(this); + this.onAutocompleteClassHovered = debounce( + this.onAutocompleteClassHovered, + 75, + this + ); + this.onAutocompleteClosed = this.onAutocompleteClosed.bind(this); + + // Create the add class text field. + this.addEl = this.doc.createElement("input"); + this.addEl.classList.add("devtools-textinput"); + this.addEl.classList.add("add-class"); + this.addEl.setAttribute( + "placeholder", + L10N.getStr("inspector.classPanel.newClass.placeholder") + ); + this.addEl.addEventListener("keydown", this.onKeyDown); + this.addEl.addEventListener("input", this.onAddElementInputModified); + this.containerEl.appendChild(this.addEl); + + // Create the class checkboxes container. + this.classesEl = this.doc.createElement("div"); + this.classesEl.classList.add("classes"); + this.containerEl.appendChild(this.classesEl); + + // Create the autocomplete popup + this.autocompletePopup = new AutocompletePopup(this.inspector.toolbox.doc, { + listId: "inspector_classListPreviewer_autocompletePopupListBox", + position: "bottom", + autoSelect: true, + useXulWrapper: true, + input: this.addEl, + onClick: (e, item) => { + if (item) { + this.addEl.value = item.label; + this.autocompletePopup.hidePopup(); + this.autocompletePopup.clearItems(); + this.model.previewClass(item.label); + } + }, + onSelect: item => { + if (item) { + this.onAutocompleteClassHovered(item?.label); + } + }, + }); + + // Start listening for interesting events. + this.inspector.selection.on("new-node-front", this.onNewSelection); + this.inspector.selection.on( + "node-front-will-unset", + this.onNodeFrontWillUnset + ); + this.containerEl.addEventListener("input", this.onCheckBoxChanged); + this.model.on("current-node-class-changed", this.onCurrentNodeClassChanged); + this.autocompletePopup.on("popup-closed", this.onAutocompleteClosed); + + this.onNewSelection(); + } + + destroy() { + this.inspector.selection.off("new-node-front", this.onNewSelection); + this.inspector.selection.off( + "node-front-will-unset", + this.onNodeFrontWillUnset + ); + this.autocompletePopup.off("popup-closed", this.onAutocompleteClosed); + this.addEl.removeEventListener("keydown", this.onKeyDown); + this.addEl.removeEventListener("input", this.onAddElementInputModified); + this.containerEl.removeEventListener("input", this.onCheckBoxChanged); + + this.autocompletePopup.destroy(); + + this.containerEl.innerHTML = ""; + + this.model.destroy(); + this.containerEl = null; + this.inspector = null; + this.addEl = null; + this.classesEl = null; + } + + get doc() { + return this.containerEl.ownerDocument; + } + + /** + * Render the content of the panel. You typically don't need to call this as the panel + * renders itself on inspector selection changes. + */ + render() { + this.classesEl.innerHTML = ""; + + for (const { name, isApplied } of this.model.currentClasses) { + const checkBox = this.renderCheckBox(name, isApplied); + this.classesEl.appendChild(checkBox); + } + + if (!this.model.currentClasses.length) { + this.classesEl.appendChild(this.renderNoClassesMessage()); + } + } + + /** + * Render a single checkbox for a given classname. + * + * @param {String} name + * The name of this class. + * @param {Boolean} isApplied + * Is this class currently applied on the DOM node. + * @return {DOMNode} The DOM element for this checkbox. + */ + renderCheckBox(name, isApplied) { + const box = this.doc.createElement("input"); + box.setAttribute("type", "checkbox"); + if (isApplied) { + box.setAttribute("checked", "checked"); + } + box.dataset.name = name; + + const labelWrapper = this.doc.createElement("label"); + labelWrapper.setAttribute("title", name); + labelWrapper.appendChild(box); + + // A child element is required to do the ellipsis. + const label = this.doc.createElement("span"); + label.textContent = name; + labelWrapper.appendChild(label); + + return labelWrapper; + } + + /** + * Render the message displayed in the panel when the current element has no classes. + * + * @return {DOMNode} The DOM element for the message. + */ + renderNoClassesMessage() { + const msg = this.doc.createElement("p"); + msg.classList.add("no-classes"); + msg.textContent = L10N.getStr("inspector.classPanel.noClasses"); + return msg; + } + + /** + * Focus the add-class text field. + */ + focusAddClassField() { + if (this.addEl) { + this.addEl.focus(); + } + } + + onCheckBoxChanged({ target }) { + if (!target.dataset.name) { + return; + } + + this.model.setClassState(target.dataset.name, target.checked).catch(e => { + // Only log the error if the panel wasn't destroyed in the meantime. + if (this.containerEl) { + console.error(e); + } + }); + } + + onKeyDown(event) { + // If the popup is already open, all the keyboard interaction are handled + // directly by the popup component. + if (this.autocompletePopup.isOpen) { + return; + } + + // Open the autocomplete popup on Ctrl+Space / ArrowDown (when the input isn't empty) + if ( + (this.addEl.value && event.key === " " && event.ctrlKey) || + event.key === "ArrowDown" + ) { + this.onAddElementInputModified(); + return; + } + + if (this.addEl.value !== "" && event.key === "Enter") { + this.addClassName(this.addEl.value); + } + } + + async onAddElementInputModified() { + const newValue = this.addEl.value; + + // if the input is empty, let's close the popup, if it was open. + if (newValue === "") { + if (this.autocompletePopup.isOpen) { + this.autocompletePopup.hidePopup(); + this.autocompletePopup.clearItems(); + } else { + this.model.previewClass(""); + } + return; + } + + // Otherwise, we need to update the popup items to match the new input. + let items = []; + try { + const classNames = await this.model.getClassNames(newValue); + if (!this.autocompletePopup.isOpen) { + this._previewClassesBeforeAutocompletion = + this.model.previewClasses.map(previewClass => previewClass.className); + } + items = classNames.map(className => { + return { + preLabel: className.substring(0, newValue.length), + label: className, + }; + }); + } catch (e) { + // If there was an error while retrieving the classNames, we'll simply NOT show the + // popup, which is okay. + console.warn("Error when calling getClassNames", e); + } + + if (!items.length || (items.length == 1 && items[0].label === newValue)) { + this.autocompletePopup.clearItems(); + await this.autocompletePopup.hidePopup(); + this.model.previewClass(newValue); + } else { + this.autocompletePopup.setItems(items); + this.autocompletePopup.openPopup(); + } + } + + async addClassName(className) { + try { + await this.model.addClassName(className); + this.render(); + this.addEl.value = ""; + } catch (e) { + // Only log the error if the panel wasn't destroyed in the meantime. + if (this.containerEl) { + console.error(e); + } + } + } + + onNewSelection() { + this.render(); + } + + onCurrentNodeClassChanged() { + this.render(); + } + + onNodeFrontWillUnset() { + this.model.eraseClassPreview(); + this.addEl.value = ""; + } + + onAutocompleteClassHovered(autocompleteItemLabel = "") { + if (this.autocompletePopup.isOpen) { + this.model.previewClass(autocompleteItemLabel); + } + } + + onAutocompleteClosed() { + const inputValue = this.addEl.value; + this.model.previewClass(inputValue); + } +} + +module.exports = ClassListPreviewer; |