/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ft=javascript 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/. */ "use strict"; const {Cc, Cu, Ci} = require("chrome"); // Page size for pageup/pagedown const PAGE_SIZE = 10; const DEFAULT_MAX_CHILDREN = 100; const COLLAPSE_DATA_URL_REGEX = /^data.+base64/; const COLLAPSE_DATA_URL_LENGTH = 60; const NEW_SELECTION_HIGHLIGHTER_TIMER = 1000; const DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE = 50; const DRAG_DROP_MIN_AUTOSCROLL_SPEED = 5; const DRAG_DROP_MAX_AUTOSCROLL_SPEED = 15; const DRAG_DROP_MIN_INITIAL_DISTANCE = 10; const AUTOCOMPLETE_POPUP_PANEL_ID = "markupview_autoCompletePopup"; const {UndoStack} = require("devtools/client/shared/undo"); const {editableField, InplaceEditor} = require("devtools/client/shared/inplace-editor"); const {HTMLEditor} = require("devtools/client/markupview/html-editor"); const promise = require("promise"); const {Tooltip} = require("devtools/client/shared/widgets/Tooltip"); const EventEmitter = require("devtools/shared/event-emitter"); const Heritage = require("sdk/core/heritage"); const {setTimeout, clearTimeout, setInterval, clearInterval} = require("sdk/timers"); const {parseAttribute} = require("devtools/client/shared/node-attribute-parser"); const ELLIPSIS = Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data; const {Task} = require("resource://gre/modules/Task.jsm"); const {scrollIntoViewIfNeeded} = require("devtools/shared/layout/utils"); Cu.import("resource://devtools/shared/gcli/Templater.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); loader.lazyGetter(this, "DOMParser", function() { return Cc["@mozilla.org/xmlextras/domparser;1"].createInstance(Ci.nsIDOMParser); }); loader.lazyGetter(this, "AutocompletePopup", () => { return require("devtools/client/shared/autocomplete-popup").AutocompletePopup; }); /** * Vocabulary for the purposes of this file: * * MarkupContainer - the structure that holds an editor and its * immediate children in the markup panel. * - MarkupElementContainer: markup container for element nodes * - MarkupTextContainer: markup container for text / comment nodes * - MarkupReadonlyContainer: markup container for other nodes * Node - A content node. * object.elt - A UI element in the markup panel. */ /** * The markup tree. Manages the mapping of nodes to MarkupContainers, * updating based on mutations, and the undo/redo bindings. * * @param Inspector aInspector * The inspector we're watching. * @param iframe aFrame * An iframe in which the caller has kindly loaded markup-view.xhtml. */ function MarkupView(aInspector, aFrame, aControllerWindow) { this._inspector = aInspector; this.walker = this._inspector.walker; this._frame = aFrame; this.win = this._frame.contentWindow; this.doc = this._frame.contentDocument; this._elt = this.doc.querySelector("#root"); this.htmlEditor = new HTMLEditor(this.doc); try { this.maxChildren = Services.prefs.getIntPref("devtools.markup.pagesize"); } catch (ex) { this.maxChildren = DEFAULT_MAX_CHILDREN; } this.collapseAttributeLength = Services.prefs.getIntPref("devtools.markup.collapseAttributeLength"); // Creating the popup to be used to show CSS suggestions. let options = { autoSelect: true, theme: "auto", // panelId option prevents the markupView autocomplete popup from // sharing XUL elements with other views, such as ruleView (see Bug 1191093) panelId: AUTOCOMPLETE_POPUP_PANEL_ID }; this.popup = new AutocompletePopup(this.doc.defaultView.parent.document, options); this.undo = new UndoStack(); this.undo.installController(aControllerWindow); this._containers = new Map(); // Binding functions that need to be called in scope. this._mutationObserver = this._mutationObserver.bind(this); this._onDisplayChange = this._onDisplayChange.bind(this); this._onMouseClick = this._onMouseClick.bind(this); this._onMouseUp = this._onMouseUp.bind(this); this._onNewSelection = this._onNewSelection.bind(this); this._onKeyDown = this._onKeyDown.bind(this); this._onCopy = this._onCopy.bind(this); this._onFocus = this._onFocus.bind(this); this._onMouseMove = this._onMouseMove.bind(this); this._onMouseLeave = this._onMouseLeave.bind(this); this._onToolboxPickerHover = this._onToolboxPickerHover.bind(this); // Listening to various events. this._elt.addEventListener("click", this._onMouseClick, false); this._elt.addEventListener("mousemove", this._onMouseMove, false); this._elt.addEventListener("mouseleave", this._onMouseLeave, false); this.doc.body.addEventListener("mouseup", this._onMouseUp); this.win.addEventListener("keydown", this._onKeyDown, false); this.win.addEventListener("copy", this._onCopy); this._frame.addEventListener("focus", this._onFocus, false); this.walker.on("mutations", this._mutationObserver); this.walker.on("display-change", this._onDisplayChange); this._inspector.selection.on("new-node-front", this._onNewSelection); this._inspector.toolbox.on("picker-node-hovered", this._onToolboxPickerHover); this._onNewSelection(); this._initTooltips(); EventEmitter.decorate(this); } exports.MarkupView = MarkupView; MarkupView.prototype = { /** * How long does a node flash when it mutates (in ms). */ CONTAINER_FLASHING_DURATION: 500, _selectedContainer: null, _initTooltips: function() { this.tooltip = new Tooltip(this._inspector.panelDoc); this._makeTooltipPersistent(false); }, _makeTooltipPersistent: function(state) { if (state) { this.tooltip.stopTogglingOnHover(); } else { this.tooltip.startTogglingOnHover(this._elt, this._isImagePreviewTarget.bind(this)); } }, _onToolboxPickerHover: function(event, nodeFront) { this.showNode(nodeFront).then(() => { this._showContainerAsHovered(nodeFront); }, e => console.error(e)); }, isDragging: false, _onMouseMove: function(event) { let target = event.target; // Auto-scroll if we're dragging. if (this.isDragging) { event.preventDefault(); this._autoScroll(event); return; } // Show the current container as hovered and highlight it. // This requires finding the current MarkupContainer (walking up the DOM). while (!target.container) { if (target.tagName.toLowerCase() === "body") { return; } target = target.parentNode; } let container = target.container; if (this._hoveredNode !== container.node) { if (container.node.nodeType !== Ci.nsIDOMNode.TEXT_NODE) { this._showBoxModel(container.node); } else { this._hideBoxModel(); } } this._showContainerAsHovered(container.node); }, /** * Executed on each mouse-move while a node is being dragged in the view. * Auto-scrolls the view to reveal nodes below the fold to drop the dragged * node in. */ _autoScroll: function(event) { let docEl = this.doc.documentElement; if (this._autoScrollInterval) { clearInterval(this._autoScrollInterval); } // Auto-scroll when the mouse approaches top/bottom edge. let fromBottom = docEl.clientHeight - event.pageY + this.win.scrollY; let fromTop = event.pageY - this.win.scrollY; if (fromBottom <= DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE) { // Map our distance from 0-50 to 5-15 range so the speed is kept in a // range not too fast, not too slow. let speed = map( fromBottom, 0, DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE, DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED); this._autoScrollInterval = setInterval(() => { docEl.scrollTop -= speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED; }, 0); } if (fromTop <= DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE) { let speed = map( fromTop, 0, DRAG_DROP_AUTOSCROLL_EDGE_DISTANCE, DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED); this._autoScrollInterval = setInterval(() => { docEl.scrollTop += speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED; }, 0); } }, _onMouseClick: function(event) { // From the target passed here, let's find the parent MarkupContainer // and ask it if the tooltip should be shown let parentNode = event.target; let container; while (parentNode !== this.doc.body) { if (parentNode.container) { container = parentNode.container; break; } parentNode = parentNode.parentNode; } if (container instanceof MarkupElementContainer) { // With the newly found container, delegate the tooltip content creation // and decision to show or not the tooltip container._buildEventTooltipContent(event.target, this.tooltip); } }, _onMouseUp: function() { this.indicateDropTarget(null); this.indicateDragTarget(null); if (this._autoScrollInterval) { clearInterval(this._autoScrollInterval); } }, cancelDragging: function() { if (!this.isDragging) { return; } for (let [, container] of this._containers) { if (container.isDragging) { container.cancelDragging(); break; } } this.indicateDropTarget(null); this.indicateDragTarget(null); if (this._autoScrollInterval) { clearInterval(this._autoScrollInterval); } }, _hoveredNode: null, /** * Show a NodeFront's container as being hovered * @param {NodeFront} nodeFront The node to show as hovered */ _showContainerAsHovered: function(nodeFront) { if (this._hoveredNode === nodeFront) { return; } if (this._hoveredNode) { this.getContainer(this._hoveredNode).hovered = false; } this.getContainer(nodeFront).hovered = true; this._hoveredNode = nodeFront; }, _onMouseLeave: function() { if (this._autoScrollInterval) { clearInterval(this._autoScrollInterval); } if (this.isDragging) { return; } this._hideBoxModel(true); if (this._hoveredNode) { this.getContainer(this._hoveredNode).hovered = false; } this._hoveredNode = null; }, /** * Show the box model highlighter on a given node front * @param {NodeFront} nodeFront The node to show the highlighter for * @return a promise that resolves when the highlighter for this nodeFront is * shown, taking into account that there could already be highlighter requests * queued up */ _showBoxModel: function(nodeFront) { return this._inspector.toolbox.highlighterUtils.highlightNodeFront(nodeFront); }, /** * Hide the box model highlighter on a given node front * @param {NodeFront} nodeFront The node to hide the highlighter for * @param {Boolean} forceHide See toolbox-highlighter-utils/unhighlight * @return a promise that resolves when the highlighter for this nodeFront is * hidden, taking into account that there could already be highlighter requests * queued up */ _hideBoxModel: function(forceHide) { return this._inspector.toolbox.highlighterUtils.unhighlight(forceHide); }, _briefBoxModelTimer: null, _clearBriefBoxModelTimer: function() { if (this._briefBoxModelTimer) { clearTimeout(this._briefBoxModelTimer); this._briefBoxModelPromise.resolve(); this._briefBoxModelPromise = null; this._briefBoxModelTimer = null; } }, _brieflyShowBoxModel: function(nodeFront) { this._clearBriefBoxModelTimer(); let onShown = this._showBoxModel(nodeFront); this._briefBoxModelPromise = promise.defer(); this._briefBoxModelTimer = setTimeout(() => { this._hideBoxModel() .then(this._briefBoxModelPromise.resolve, this._briefBoxModelPromise.resolve); }, NEW_SELECTION_HIGHLIGHTER_TIMER); return promise.all([onShown, this._briefBoxModelPromise.promise]); }, template: function(aName, aDest, aOptions={stack: "markup-view.xhtml"}) { let node = this.doc.getElementById("template-" + aName).cloneNode(true); node.removeAttribute("id"); template(node, aDest, aOptions); return node; }, /** * Get the MarkupContainer object for a given node, or undefined if * none exists. */ getContainer: function(aNode) { return this._containers.get(aNode); }, update: function() { let updateChildren = (node) => { this.getContainer(node).update(); for (let child of node.treeChildren()) { updateChildren(child); } }; // Start with the documentElement let documentElement; for (let node of this._rootNode.treeChildren()) { if (node.isDocumentElement === true) { documentElement = node; break; } } // Recursively update each node starting with documentElement. updateChildren(documentElement); }, /** * Executed when the mouse hovers over a target in the markup-view and is used * to decide whether this target should be used to display an image preview * tooltip. * Delegates the actual decision to the corresponding MarkupContainer instance * if one is found. * @return the promise returned by MarkupElementContainer._isImagePreviewTarget */ _isImagePreviewTarget: function(target) { // From the target passed here, let's find the parent MarkupContainer // and ask it if the tooltip should be shown if (this.isDragging) { return promise.reject(false); } let parent = target, container; while (parent !== this.doc.body) { if (parent.container) { container = parent.container; break; } parent = parent.parentNode; } if (container instanceof MarkupElementContainer) { // With the newly found container, delegate the tooltip content creation // and decision to show or not the tooltip return container.isImagePreviewTarget(target, this.tooltip); } }, /** * Given the known reason, should the current selection be briefly highlighted * In a few cases, we don't want to highlight the node: * - If the reason is null (used to reset the selection), * - if it's "inspector-open" (when the inspector opens up, let's not highlight * the default node) * - if it's "navigateaway" (since the page is being navigated away from) * - if it's "test" (this is a special case for mochitest. In tests, we often * need to select elements but don't necessarily want the highlighter to come * and go after a delay as this might break test scenarios) * We also do not want to start a brief highlight timeout if the node is already * being hovered over, since in that case it will already be highlighted. */ _shouldNewSelectionBeHighlighted: function() { let reason = this._inspector.selection.reason; let unwantedReasons = ["inspector-open", "navigateaway", "nodeselected", "test"]; let isHighlitNode = this._hoveredNode === this._inspector.selection.nodeFront; return !isHighlitNode && reason && unwantedReasons.indexOf(reason) === -1; }, /** * React to new-node-front selection events. * Highlights the node if needed, and make sure it is shown and selected in * the view. */ _onNewSelection: function() { let selection = this._inspector.selection; this.htmlEditor.hide(); if (this._hoveredNode && this._hoveredNode !== selection.nodeFront) { this.getContainer(this._hoveredNode).hovered = false; this._hoveredNode = null; } if (!selection.isNode()) { this.unmarkSelectedNode(); return; } let done = this._inspector.updating("markup-view"); let onShowBoxModel, onShow; // Highlight the element briefly if needed. if (this._shouldNewSelectionBeHighlighted()) { onShowBoxModel = this._brieflyShowBoxModel(selection.nodeFront); } onShow = this.showNode(selection.nodeFront).then(() => { // We could be destroyed by now. if (this._destroyer) { return promise.reject("markupview destroyed"); } // Mark the node as selected. this.markNodeAsSelected(selection.nodeFront); // Make sure the new selection receives focus so the keyboard can be used. this.maybeFocusNewSelection(); }).catch(e => { if (!this._destroyer) { console.error(e); } else { console.warn("Could not mark node as selected, the markup-view was " + "destroyed while showing the node."); } }); promise.all([onShowBoxModel, onShow]).then(done); }, /** * Focus the current node selection's MarkupContainer if the selection * happened because the user picked an element using the element picker or * browser context menu. */ maybeFocusNewSelection: function() { let {reason, nodeFront} = this._inspector.selection; if (reason !== "browser-context-menu" && reason !== "picker-node-picked") { return; } this.getContainer(nodeFront).focus(); }, /** * Create a TreeWalker to find the next/previous * node for selection. */ _selectionWalker: function(aStart) { let walker = this.doc.createTreeWalker( aStart || this._elt, Ci.nsIDOMNodeFilter.SHOW_ELEMENT, function(aElement) { if (aElement.container && aElement.container.elt === aElement && aElement.container.visible) { return Ci.nsIDOMNodeFilter.FILTER_ACCEPT; } return Ci.nsIDOMNodeFilter.FILTER_SKIP; } ); walker.currentNode = this._selectedContainer.elt; return walker; }, _onCopy: function (evt) { // Ignore copy events from editors if (this._isInputOrTextarea(evt.target)) { return; } let selection = this._inspector.selection; if (selection.isNode()) { this._inspector.copyOuterHTML(); } evt.stopPropagation(); evt.preventDefault(); }, /** * Key handling. */ _onKeyDown: function(aEvent) { let handled = true; // Ignore keystrokes that originated in editors. if (this._isInputOrTextarea(aEvent.target)) { return; } switch(aEvent.keyCode) { case Ci.nsIDOMKeyEvent.DOM_VK_H: if (aEvent.metaKey || aEvent.shiftKey) { handled = false; } else { let node = this._selectedContainer.node; if (node.hidden) { this.walker.unhideNode(node); } else { this.walker.hideNode(node); } } break; case Ci.nsIDOMKeyEvent.DOM_VK_DELETE: this.deleteNodeOrAttribute(); break; case Ci.nsIDOMKeyEvent.DOM_VK_BACK_SPACE: this.deleteNodeOrAttribute(true); break; case Ci.nsIDOMKeyEvent.DOM_VK_HOME: let rootContainer = this.getContainer(this._rootNode); this.navigate(rootContainer.children.firstChild.container); break; case Ci.nsIDOMKeyEvent.DOM_VK_LEFT: if (this._selectedContainer.expanded) { this.collapseNode(this._selectedContainer.node); } else { let parent = this._selectionWalker().parentNode(); if (parent) { this.navigate(parent.container); } } break; case Ci.nsIDOMKeyEvent.DOM_VK_RIGHT: if (!this._selectedContainer.expanded && this._selectedContainer.hasChildren) { this._expandContainer(this._selectedContainer); } else { let next = this._selectionWalker().nextNode(); if (next) { this.navigate(next.container); } } break; case Ci.nsIDOMKeyEvent.DOM_VK_UP: let prev = this._selectionWalker().previousNode(); if (prev) { this.navigate(prev.container); } break; case Ci.nsIDOMKeyEvent.DOM_VK_DOWN: let next = this._selectionWalker().nextNode(); if (next) { this.navigate(next.container); } break; case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP: { let walker = this._selectionWalker(); let selection = this._selectedContainer; for (let i = 0; i < PAGE_SIZE; i++) { let prev = walker.previousNode(); if (!prev) { break; } selection = prev.container; } this.navigate(selection); break; } case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN: { let walker = this._selectionWalker(); let selection = this._selectedContainer; for (let i = 0; i < PAGE_SIZE; i++) { let next = walker.nextNode(); if (!next) { break; } selection = next.container; } this.navigate(selection); break; } case Ci.nsIDOMKeyEvent.DOM_VK_F2: { this.beginEditingOuterHTML(this._selectedContainer.node); break; } case Ci.nsIDOMKeyEvent.DOM_VK_S: { let selection = this._selectedContainer.node; this._inspector.scrollNodeIntoView(selection); break; } case Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE: { if (this.isDragging) { this.cancelDragging(); break; } } default: handled = false; } if (handled) { aEvent.stopPropagation(); aEvent.preventDefault(); } }, /** * Check if a node is an input or textarea */ _isInputOrTextarea : function (element) { let name = element.tagName.toLowerCase(); return name === "input" || name === "textarea"; }, /** * If there's an attribute on the current node that's currently focused, then * delete this attribute, otherwise delete the node itself. * @param {boolean} moveBackward If set to true and if we're deleting the * node, focus the previous sibling after deletion, otherwise the next one. */ deleteNodeOrAttribute: function(moveBackward) { let focusedAttribute = this.doc.activeElement ? this.doc.activeElement.closest(".attreditor") : null; if (focusedAttribute) { // The focused attribute might not be in the current selected container. let container = focusedAttribute.closest("li.child").container; container.removeAttribute(focusedAttribute.dataset.attr); } else { this.deleteNode(this._selectedContainer.node, moveBackward); } }, /** * Delete a node from the DOM. * This is an undoable action. * * @param {NodeFront} aNode The node to remove. * @param {boolean} moveBackward If set to true, focus the previous sibling, * otherwise the next one. */ deleteNode: function(aNode, moveBackward) { if (aNode.isDocumentElement || aNode.nodeType == Ci.nsIDOMNode.DOCUMENT_TYPE_NODE || aNode.isAnonymous) { return; } let container = this.getContainer(aNode); // Retain the node so we can undo this... this.walker.retainNode(aNode).then(() => { let parent = aNode.parentNode(); let nextSibling = null; this.undo.do(() => { this.walker.removeNode(aNode).then(siblings => { nextSibling = siblings.nextSibling; let focusNode = moveBackward ? siblings.previousSibling : nextSibling; // If we can't move as the user wants, we move to the other direction. // If there is no sibling elements anymore, move to the parent node. if (!focusNode) { focusNode = nextSibling || siblings.previousSibling || parent; } if (container.selected) { this.navigate(this.getContainer(focusNode)); } }); }, () => { this.walker.insertBefore(aNode, parent, nextSibling); }); }).then(null, console.error); }, /** * If an editable item is focused, select its container. */ _onFocus: function(aEvent) { let parent = aEvent.target; while (!parent.container) { parent = parent.parentNode; } if (parent) { this.navigate(parent.container, true); } }, /** * Handle a user-requested navigation to a given MarkupContainer, * updating the inspector's currently-selected node. * * @param MarkupContainer aContainer * The container we're navigating to. * @param aIgnoreFocus aIgnoreFocus * If falsy, keyboard focus will be moved to the container too. */ navigate: function(aContainer, aIgnoreFocus) { if (!aContainer) { return; } let node = aContainer.node; this.markNodeAsSelected(node, "treepanel"); if (!aIgnoreFocus) { aContainer.focus(); } }, /** * Make sure a node is included in the markup tool. * * @param NodeFront aNode * The node in the content document. * @param boolean aFlashNode * Whether the newly imported node should be flashed * @returns MarkupContainer The MarkupContainer object for this element. */ importNode: function(aNode, aFlashNode) { if (!aNode) { return null; } if (this._containers.has(aNode)) { return this.getContainer(aNode); } let container; let {nodeType, isPseudoElement} = aNode; if (aNode === this.walker.rootNode) { container = new RootContainer(this, aNode); this._elt.appendChild(container.elt); this._rootNode = aNode; } else if (nodeType == Ci.nsIDOMNode.ELEMENT_NODE && !isPseudoElement) { container = new MarkupElementContainer(this, aNode, this._inspector); } else if (nodeType == Ci.nsIDOMNode.COMMENT_NODE || nodeType == Ci.nsIDOMNode.TEXT_NODE) { container = new MarkupTextContainer(this, aNode, this._inspector); } else { container = new MarkupReadOnlyContainer(this, aNode, this._inspector); } if (aFlashNode) { container.flashMutation(); } this._containers.set(aNode, container); container.childrenDirty = true; this._updateChildren(container); this._inspector.emit("container-created", container); return container; }, /** * Mutation observer used for included nodes. */ _mutationObserver: function(aMutations) { for (let mutation of aMutations) { let type = mutation.type; let target = mutation.target; if (mutation.type === "documentUnload") { // Treat this as a childList change of the child (maybe the protocol // should do this). type = "childList"; target = mutation.targetParent; if (!target) { continue; } } let container = this.getContainer(target); if (!container) { // Container might not exist if this came from a load event for a node // we're not viewing. continue; } if (type === "attributes" || type === "characterData") { container.update(); } else if (type === "childList" || type === "nativeAnonymousChildList") { container.childrenDirty = true; // Update the children to take care of changes in the markup view DOM. this._updateChildren(container, {flash: true}); } else if (type === "pseudoClassLock") { container.update(); } } this._waitForChildren().then(() => { if (this._destroyer) { console.warn("Could not fully update after markup mutations, " + "the markup-view was destroyed while waiting for children."); return; } this._flashMutatedNodes(aMutations); this._inspector.emit("markupmutation", aMutations); // Since the htmlEditor is absolutely positioned, a mutation may change // the location in which it should be shown. this.htmlEditor.refresh(); }); }, /** * React to display-change events from the walker * @param {Array} nodes An array of nodeFronts */ _onDisplayChange: function(nodes) { for (let node of nodes) { let container = this.getContainer(node); if (container) { container.isDisplayed = node.isDisplayed; } } }, /** * Given a list of mutations returned by the mutation observer, flash the * corresponding containers to attract attention. */ _flashMutatedNodes: function(aMutations) { let addedOrEditedContainers = new Set(); let removedContainers = new Set(); for (let {type, target, added, removed, newValue} of aMutations) { let container = this.getContainer(target); if (container) { if (type === "characterData") { addedOrEditedContainers.add(container); } else if (type === "attributes" && newValue === null) { // Removed attributes should flash the entire node. // New or changed attributes will flash the attribute itself // in ElementEditor.flashAttribute. addedOrEditedContainers.add(container); } else if (type === "childList") { // If there has been removals, flash the parent if (removed.length) { removedContainers.add(container); } // If there has been additions, flash the nodes if their associated // container exist (so if their parent is expanded in the inspector). added.forEach(added => { let addedContainer = this.getContainer(added); if (addedContainer) { addedOrEditedContainers.add(addedContainer); // The node may be added as a result of an append, in which case // it will have been removed from another container first, but in // these cases we don't want to flash both the removal and the // addition removedContainers.delete(container); } }); } } } for (let container of removedContainers) { container.flashMutation(); } for (let container of addedOrEditedContainers) { container.flashMutation(); } }, /** * Make sure the given node's parents are expanded and the * node is scrolled on to screen. */ showNode: function(aNode, centered=true) { let parent = aNode; this.importNode(aNode); while ((parent = parent.parentNode())) { this.importNode(parent); this.expandNode(parent); } return this._waitForChildren().then(() => { if (this._destroyer) { return promise.reject("markupview destroyed"); } return this._ensureVisible(aNode); }).then(() => { scrollIntoViewIfNeeded(this.getContainer(aNode).editor.elt, centered); }, e => { // Only report this rejection as an error if the panel hasn't been // destroyed in the meantime. if (!this._destroyer) { console.error(e); } else { console.warn("Could not show the node, the markup-view was destroyed " + "while waiting for children"); } }); }, /** * Expand the container's children. */ _expandContainer: function(aContainer) { return this._updateChildren(aContainer, {expand: true}).then(() => { if (this._destroyer) { console.warn("Could not expand the node, the markup-view was destroyed"); return; } aContainer.setExpanded(true); }); }, /** * Expand the node's children. */ expandNode: function(aNode) { let container = this.getContainer(aNode); this._expandContainer(container); }, /** * Expand the entire tree beneath a container. * * @param aContainer The container to expand. */ _expandAll: function(aContainer) { return this._expandContainer(aContainer).then(() => { let child = aContainer.children.firstChild; let promises = []; while (child) { promises.push(this._expandAll(child.container)); child = child.nextSibling; } return promise.all(promises); }).then(null, console.error); }, /** * Expand the entire tree beneath a node. * * @param aContainer The node to expand, or null * to start from the top. */ expandAll: function(aNode) { aNode = aNode || this._rootNode; return this._expandAll(this.getContainer(aNode)); }, /** * Collapse the node's children. */ collapseNode: function(aNode) { let container = this.getContainer(aNode); container.setExpanded(false); }, /** * Returns either the innerHTML or the outerHTML for a remote node. * @param aNode The NodeFront to get the outerHTML / innerHTML for. * @param isOuter A boolean that, if true, makes the function return the * outerHTML, otherwise the innerHTML. * @returns A promise that will be resolved with the outerHTML / innerHTML. */ _getNodeHTML: function(aNode, isOuter) { let walkerPromise = null; if (isOuter) { walkerPromise = this.walker.outerHTML(aNode); } else { walkerPromise = this.walker.innerHTML(aNode); } return walkerPromise.then(longstr => { return longstr.string().then(html => { longstr.release().then(null, console.error); return html; }); }); }, /** * Retrieve the outerHTML for a remote node. * @param aNode The NodeFront to get the outerHTML for. * @returns A promise that will be resolved with the outerHTML. */ getNodeOuterHTML: function(aNode) { return this._getNodeHTML(aNode, true); }, /** * Retrieve the innerHTML for a remote node. * @param aNode The NodeFront to get the innerHTML for. * @returns A promise that will be resolved with the innerHTML. */ getNodeInnerHTML: function(aNode) { return this._getNodeHTML(aNode); }, /** * Listen to mutations, expect a given node to be removed and try and select * the node that sits at the same place instead. * This is useful when changing the outerHTML or the tag name so that the * newly inserted node gets selected instead of the one that just got removed. */ reselectOnRemoved: function(removedNode, reason) { // Only allow one removed node reselection at a time, so that when there are // more than 1 request in parallel, the last one wins. this.cancelReselectOnRemoved(); // Get the removedNode index in its parent node to reselect the right node. let isHTMLTag = removedNode.tagName.toLowerCase() === "html"; let oldContainer = this.getContainer(removedNode); let parentContainer = this.getContainer(removedNode.parentNode()); let childIndex = parentContainer.getChildContainers().indexOf(oldContainer); let onMutations = this._removedNodeObserver = (e, mutations) => { let isNodeRemovalMutation = false; for (let mutation of mutations) { let containsRemovedNode = mutation.removed && mutation.removed.some(n => n === removedNode); if (mutation.type === "childList" && (containsRemovedNode || isHTMLTag)) { isNodeRemovalMutation = true; break; } } if (!isNodeRemovalMutation) { return; } this._inspector.off("markupmutation", onMutations); this._removedNodeObserver = null; // Don't select the new node if the user has already changed the current // selection. if (this._inspector.selection.nodeFront === parentContainer.node || (this._inspector.selection.nodeFront === removedNode && isHTMLTag)) { let childContainers = parentContainer.getChildContainers(); if (childContainers && childContainers[childIndex]) { this.markNodeAsSelected(childContainers[childIndex].node, reason); if (childContainers[childIndex].hasChildren) { this.expandNode(childContainers[childIndex].node); } this.emit("reselectedonremoved"); } } }; // Start listening for mutations until we find a childList change that has // removedNode removed. this._inspector.on("markupmutation", onMutations); }, /** * Make sure to stop listening for node removal markupmutations and not * reselect the corresponding node when that happens. * Useful when the outerHTML/tagname edition failed. */ cancelReselectOnRemoved: function() { if (this._removedNodeObserver) { this._inspector.off("markupmutation", this._removedNodeObserver); this._removedNodeObserver = null; this.emit("canceledreselectonremoved"); } }, /** * Replace the outerHTML of any node displayed in the inspector with * some other HTML code * @param {NodeFront} node node which outerHTML will be replaced. * @param {string} newValue The new outerHTML to set on the node. * @param {string} oldValue The old outerHTML that will be used if the * user undoes the update. * @returns A promise that will resolve when the outer HTML has been updated. */ updateNodeOuterHTML: function(node, newValue, oldValue) { let container = this.getContainer(node); if (!container) { return promise.reject(); } // Changing the outerHTML removes the node which outerHTML was changed. // Listen to this removal to reselect the right node afterwards. this.reselectOnRemoved(node, "outerhtml"); return this.walker.setOuterHTML(node, newValue).then(null, () => { this.cancelReselectOnRemoved(); }); }, /** * Replace the innerHTML of any node displayed in the inspector with * some other HTML code * @param {Node} node node which innerHTML will be replaced. * @param {string} newValue The new innerHTML to set on the node. * @param {string} oldValue The old innerHTML that will be used if the user * undoes the update. * @returns A promise that will resolve when the inner HTML has been updated. */ updateNodeInnerHTML: function(node, newValue, oldValue) { let container = this.getContainer(node); if (!container) { return promise.reject(); } let def = promise.defer(); container.undo.do(() => { this.walker.setInnerHTML(node, newValue).then(def.resolve, def.reject); }, () => { this.walker.setInnerHTML(node, oldValue); }); return def.promise; }, /** * Insert adjacent HTML to any node displayed in the inspector. * * @param {NodeFront} node The reference node. * @param {string} position The position as specified for Element.insertAdjacentHTML * (i.e. "beforeBegin", "afterBegin", "beforeEnd", "afterEnd"). * @param {string} newValue The adjacent HTML. * @returns A promise that will resolve when the adjacent HTML has * been inserted. */ insertAdjacentHTMLToNode: function(node, position, value) { let container = this.getContainer(node); if (!container) { return promise.reject(); } let def = promise.defer(); let injectedNodes = []; container.undo.do(() => { this.walker.insertAdjacentHTML(node, position, value).then(nodeArray => { injectedNodes = nodeArray.nodes; return nodeArray; }).then(def.resolve, def.reject); }, () => { this.walker.removeNodes(injectedNodes); }); return def.promise; }, /** * Open an editor in the UI to allow editing of a node's outerHTML. * @param aNode The NodeFront to edit. */ beginEditingOuterHTML: function(aNode) { this.getNodeOuterHTML(aNode).then(oldValue => { let container = this.getContainer(aNode); if (!container) { return; } this.htmlEditor.show(container.tagLine, oldValue); this.htmlEditor.once("popuphidden", (e, aCommit, aValue) => { // Need to focus the element instead of the frame / window // in order to give keyboard focus back to doc (from editor). this.doc.documentElement.focus(); if (aCommit) { this.updateNodeOuterHTML(aNode, aValue, oldValue); } }); }); }, /** * Mark the given node expanded. * @param {NodeFront} aNode The NodeFront to mark as expanded. * @param {Boolean} aExpanded Whether the expand or collapse. * @param {Boolean} aExpandDescendants Whether to expand all descendants too */ setNodeExpanded: function(aNode, aExpanded, aExpandDescendants) { if (aExpanded) { if (aExpandDescendants) { this.expandAll(aNode); } else { this.expandNode(aNode); } } else { this.collapseNode(aNode); } }, /** * Mark the given node selected, and update the inspector.selection * object's NodeFront to keep consistent state between UI and selection. * @param {NodeFront} aNode The NodeFront to mark as selected. * @param {String} reason The reason for marking the node as selected. * @return {Boolean} False if the node is already marked as selected, true * otherwise. */ markNodeAsSelected: function(node, reason) { let container = this.getContainer(node); if (this._selectedContainer === container) { return false; } // Un-select the previous container. if (this._selectedContainer) { this._selectedContainer.selected = false; } // Select the new container. this._selectedContainer = container; if (node) { this._selectedContainer.selected = true; } // Change the current selection if needed. if (this._inspector.selection.nodeFront !== node) { this._inspector.selection.setNodeFront(node, reason || "nodeselected"); } return true; }, /** * Make sure that every ancestor of the selection are updated * and included in the list of visible children. */ _ensureVisible: function(node) { while (node) { let container = this.getContainer(node); let parent = node.parentNode(); if (!container.elt.parentNode) { let parentContainer = this.getContainer(parent); if (parentContainer) { parentContainer.childrenDirty = true; this._updateChildren(parentContainer, {expand: true}); } } node = parent; } return this._waitForChildren(); }, /** * Unmark selected node (no node selected). */ unmarkSelectedNode: function() { if (this._selectedContainer) { this._selectedContainer.selected = false; this._selectedContainer = null; } }, /** * Check if the current selection is a descendent of the container. * if so, make sure it's among the visible set for the container, * and set the dirty flag if needed. * @returns The node that should be made visible, if any. */ _checkSelectionVisible: function(aContainer) { let centered = null; let node = this._inspector.selection.nodeFront; while (node) { if (node.parentNode() === aContainer.node) { centered = node; break; } node = node.parentNode(); } return centered; }, /** * Make sure all children of the given container's node are * imported and attached to the container in the right order. * * Children need to be updated only in the following circumstances: * a) We just imported this node and have never seen its children. * container.childrenDirty will be set by importNode in this case. * b) We received a childList mutation on the node. * container.childrenDirty will be set in that case too. * c) We have changed the selection, and the path to that selection * wasn't loaded in a previous children request (because we only * grab a subset). * container.childrenDirty should be set in that case too! * * @param MarkupContainer aContainer * The markup container whose children need updating * @param Object options * Options are {expand:boolean,flash:boolean} * @return a promise that will be resolved when the children are ready * (which may be immediately). */ _updateChildren: function(aContainer, options) { let expand = options && options.expand; let flash = options && options.flash; aContainer.hasChildren = aContainer.node.hasChildren; if (!this._queuedChildUpdates) { this._queuedChildUpdates = new Map(); } if (this._queuedChildUpdates.has(aContainer)) { return this._queuedChildUpdates.get(aContainer); } if (!aContainer.childrenDirty) { return promise.resolve(aContainer); } if (aContainer.singleTextChild && aContainer.singleTextChild != aContainer.node.singleTextChild) { // This container was doing double duty as a container for a single // text child, back that out. this._containers.delete(aContainer.singleTextChild); aContainer.clearSingleTextChild(); if (aContainer.hasChildren && aContainer.selected) { aContainer.setExpanded(true); } } if (aContainer.node.singleTextChild) { aContainer.setExpanded(false); // this container will do double duty as the container for the single // text child. while (aContainer.children.firstChild) { aContainer.children.removeChild(aContainer.children.firstChild); } aContainer.setSingleTextChild(aContainer.node.singleTextChild); this._containers.set(aContainer.node.singleTextChild, aContainer); aContainer.childrenDirty = false; return promise.resolve(aContainer); } if (!aContainer.hasChildren) { while (aContainer.children.firstChild) { aContainer.children.removeChild(aContainer.children.firstChild); } aContainer.childrenDirty = false; aContainer.setExpanded(false); return promise.resolve(aContainer); } // If we're not expanded (or asked to update anyway), we're done for // now. Note that this will leave the childrenDirty flag set, so when // expanded we'll refresh the child list. if (!(aContainer.expanded || expand)) { return promise.resolve(aContainer); } // We're going to issue a children request, make sure it includes the // centered node. let centered = this._checkSelectionVisible(aContainer); // Children aren't updated yet, but clear the childrenDirty flag anyway. // If the dirty flag is re-set while we're fetching we'll need to fetch // again. aContainer.childrenDirty = false; let updatePromise = this._getVisibleChildren(aContainer, centered).then(children => { if (!this._containers) { return promise.reject("markup view destroyed"); } this._queuedChildUpdates.delete(aContainer); // If children are dirty, we got a change notification for this node // while the request was in progress, we need to do it again. if (aContainer.childrenDirty) { return this._updateChildren(aContainer, {expand: centered}); } let fragment = this.doc.createDocumentFragment(); for (let child of children.nodes) { let container = this.importNode(child, flash); fragment.appendChild(container.elt); } while (aContainer.children.firstChild) { aContainer.children.removeChild(aContainer.children.firstChild); } if (!(children.hasFirst && children.hasLast)) { let data = { showing: this.strings.GetStringFromName("markupView.more.showing"), showAll: this.strings.formatStringFromName( "markupView.more.showAll", [aContainer.node.numChildren.toString()], 1), allButtonClick: () => { aContainer.maxChildren = -1; aContainer.childrenDirty = true; this._updateChildren(aContainer); } }; if (!children.hasFirst) { let span = this.template("more-nodes", data); fragment.insertBefore(span, fragment.firstChild); } if (!children.hasLast) { let span = this.template("more-nodes", data); fragment.appendChild(span); } } aContainer.children.appendChild(fragment); return aContainer; }).then(null, console.error); this._queuedChildUpdates.set(aContainer, updatePromise); return updatePromise; }, _waitForChildren: function() { if (!this._queuedChildUpdates) { return promise.resolve(undefined); } return promise.all([...this._queuedChildUpdates.values()]); }, /** * Return a list of the children to display for this container. */ _getVisibleChildren: function(aContainer, aCentered) { let maxChildren = aContainer.maxChildren || this.maxChildren; if (maxChildren == -1) { maxChildren = undefined; } return this.walker.children(aContainer.node, { maxNodes: maxChildren, center: aCentered }); }, /** * Tear down the markup panel. */ destroy: function() { if (this._destroyer) { return this._destroyer; } this._destroyer = promise.resolve(); this._clearBriefBoxModelTimer(); this._hoveredNode = null; this.htmlEditor.destroy(); this.htmlEditor = null; this.undo.destroy(); this.undo = null; this.popup.destroy(); this.popup = null; this._elt.removeEventListener("click", this._onMouseClick, false); this._elt.removeEventListener("mousemove", this._onMouseMove, false); this._elt.removeEventListener("mouseleave", this._onMouseLeave, false); this.doc.body.removeEventListener("mouseup", this._onMouseUp); this.win.removeEventListener("keydown", this._onKeyDown, false); this.win.removeEventListener("copy", this._onCopy); this._frame.removeEventListener("focus", this._onFocus, false); this.walker.off("mutations", this._mutationObserver); this.walker.off("display-change", this._onDisplayChange); this._inspector.selection.off("new-node-front", this._onNewSelection); this._inspector.toolbox.off("picker-node-hovered", this._onToolboxPickerHover); this._elt = null; for (let [, container] of this._containers) { container.destroy(); } this._containers = null; this.tooltip.destroy(); this.tooltip = null; this.win = null; this.doc = null; this._lastDropTarget = null; this._lastDragTarget = null; return this._destroyer; }, /** * Find the closest element with class tag-line. These are used to indicate * drag and drop targets. * @param {DOMNode} el * @return {DOMNode} */ findClosestDragDropTarget: function(el) { return el.classList.contains("tag-line") ? el : el.querySelector(".tag-line") || el.closest(".tag-line"); }, /** * Takes an element as it's only argument and marks the element * as the drop target */ indicateDropTarget: function(el) { if (this._lastDropTarget) { this._lastDropTarget.classList.remove("drop-target"); } if (!el) { return; } let target = this.findClosestDragDropTarget(el); if (target) { target.classList.add("drop-target"); this._lastDropTarget = target; } }, /** * Takes an element to mark it as indicator of dragging target's initial place */ indicateDragTarget: function(el) { if (this._lastDragTarget) { this._lastDragTarget.classList.remove("drag-target"); } if (!el) { return; } let target = this.findClosestDragDropTarget(el); if (target) { target.classList.add("drag-target"); this._lastDragTarget = target; } }, /** * Used to get the nodes required to modify the markup after dragging the * element (parent/nextSibling). */ get dropTargetNodes() { let target = this._lastDropTarget; if (!target) { return null; } let parent, nextSibling; if (this._lastDropTarget.previousElementSibling && this._lastDropTarget.previousElementSibling.nodeName.toLowerCase() === "ul") { parent = target.parentNode.container.node; nextSibling = null; } else { parent = target.parentNode.container.node.parentNode(); nextSibling = target.parentNode.container.node; } if (nextSibling && nextSibling.isBeforePseudoElement) { nextSibling = target.parentNode.parentNode.children[1].container.node; } if (nextSibling && nextSibling.isAfterPseudoElement) { parent = target.parentNode.container.node.parentNode(); nextSibling = null; } if (parent.nodeType !== Ci.nsIDOMNode.ELEMENT_NODE) { return null; } return {parent, nextSibling}; } }; /** * The main structure for storing a document node in the markup * tree. Manages creation of the editor for the node and * a