michael@0: /* -*- Mode: Javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ michael@0: /* vim: set ft=javascript ts=2 et sw=2 tw=80: */ michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: const {Cc, Cu, Ci} = require("chrome"); michael@0: michael@0: // Page size for pageup/pagedown michael@0: const PAGE_SIZE = 10; michael@0: const PREVIEW_AREA = 700; michael@0: const DEFAULT_MAX_CHILDREN = 100; michael@0: const COLLAPSE_ATTRIBUTE_LENGTH = 120; michael@0: const COLLAPSE_DATA_URL_REGEX = /^data.+base64/; michael@0: const COLLAPSE_DATA_URL_LENGTH = 60; michael@0: const CONTAINER_FLASHING_DURATION = 500; michael@0: const NEW_SELECTION_HIGHLIGHTER_TIMER = 1000; michael@0: michael@0: const {UndoStack} = require("devtools/shared/undo"); michael@0: const {editableField, InplaceEditor} = require("devtools/shared/inplace-editor"); michael@0: const {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {}); michael@0: const {HTMLEditor} = require("devtools/markupview/html-editor"); michael@0: const promise = require("devtools/toolkit/deprecated-sync-thenables"); michael@0: const {Tooltip} = require("devtools/shared/widgets/Tooltip"); michael@0: const EventEmitter = require("devtools/toolkit/event-emitter"); michael@0: michael@0: Cu.import("resource://gre/modules/devtools/LayoutHelpers.jsm"); michael@0: Cu.import("resource://gre/modules/devtools/Templater.jsm"); michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: michael@0: loader.lazyGetter(this, "DOMParser", function() { michael@0: return Cc["@mozilla.org/xmlextras/domparser;1"].createInstance(Ci.nsIDOMParser); michael@0: }); michael@0: loader.lazyGetter(this, "AutocompletePopup", () => { michael@0: return require("devtools/shared/autocomplete-popup").AutocompletePopup michael@0: }); michael@0: michael@0: /** michael@0: * Vocabulary for the purposes of this file: michael@0: * michael@0: * MarkupContainer - the structure that holds an editor and its michael@0: * immediate children in the markup panel. michael@0: * Node - A content node. michael@0: * object.elt - A UI element in the markup panel. michael@0: */ michael@0: michael@0: /** michael@0: * The markup tree. Manages the mapping of nodes to MarkupContainers, michael@0: * updating based on mutations, and the undo/redo bindings. michael@0: * michael@0: * @param Inspector aInspector michael@0: * The inspector we're watching. michael@0: * @param iframe aFrame michael@0: * An iframe in which the caller has kindly loaded markup-view.xhtml. michael@0: */ michael@0: function MarkupView(aInspector, aFrame, aControllerWindow) { michael@0: this._inspector = aInspector; michael@0: this.walker = this._inspector.walker; michael@0: this._frame = aFrame; michael@0: this.doc = this._frame.contentDocument; michael@0: this._elt = this.doc.querySelector("#root"); michael@0: this.htmlEditor = new HTMLEditor(this.doc); michael@0: michael@0: this.layoutHelpers = new LayoutHelpers(this.doc.defaultView); michael@0: michael@0: try { michael@0: this.maxChildren = Services.prefs.getIntPref("devtools.markup.pagesize"); michael@0: } catch(ex) { michael@0: this.maxChildren = DEFAULT_MAX_CHILDREN; michael@0: } michael@0: michael@0: // Creating the popup to be used to show CSS suggestions. michael@0: let options = { michael@0: autoSelect: true, michael@0: theme: "auto" michael@0: }; michael@0: this.popup = new AutocompletePopup(this.doc.defaultView.parent.document, options); michael@0: michael@0: this.undo = new UndoStack(); michael@0: this.undo.installController(aControllerWindow); michael@0: michael@0: this._containers = new Map(); michael@0: michael@0: this._boundMutationObserver = this._mutationObserver.bind(this); michael@0: this.walker.on("mutations", this._boundMutationObserver); michael@0: michael@0: this._boundOnNewSelection = this._onNewSelection.bind(this); michael@0: this._inspector.selection.on("new-node-front", this._boundOnNewSelection); michael@0: this._onNewSelection(); michael@0: michael@0: this._boundKeyDown = this._onKeyDown.bind(this); michael@0: this._frame.contentWindow.addEventListener("keydown", this._boundKeyDown, false); michael@0: michael@0: this._boundFocus = this._onFocus.bind(this); michael@0: this._frame.addEventListener("focus", this._boundFocus, false); michael@0: michael@0: this._initPreview(); michael@0: this._initTooltips(); michael@0: this._initHighlighter(); michael@0: michael@0: EventEmitter.decorate(this); michael@0: } michael@0: michael@0: exports.MarkupView = MarkupView; michael@0: michael@0: MarkupView.prototype = { michael@0: _selectedContainer: null, michael@0: michael@0: _initTooltips: function() { michael@0: this.tooltip = new Tooltip(this._inspector.panelDoc); michael@0: this.tooltip.startTogglingOnHover(this._elt, michael@0: this._isImagePreviewTarget.bind(this)); michael@0: }, michael@0: michael@0: _initHighlighter: function() { michael@0: // Show the box model on markup-view mousemove michael@0: this._onMouseMove = this._onMouseMove.bind(this); michael@0: this._elt.addEventListener("mousemove", this._onMouseMove, false); michael@0: this._onMouseLeave = this._onMouseLeave.bind(this); michael@0: this._elt.addEventListener("mouseleave", this._onMouseLeave, false); michael@0: michael@0: // Show markup-containers as hovered on toolbox "picker-node-hovered" event michael@0: // which happens when the "pick" button is pressed michael@0: this._onToolboxPickerHover = (event, nodeFront) => { michael@0: this.showNode(nodeFront, true).then(() => { michael@0: this._showContainerAsHovered(nodeFront); michael@0: }); michael@0: } michael@0: this._inspector.toolbox.on("picker-node-hovered", this._onToolboxPickerHover); michael@0: }, michael@0: michael@0: _onMouseMove: function(event) { michael@0: let target = event.target; michael@0: michael@0: // Search target for a markupContainer reference, if not found, walk up michael@0: while (!target.container) { michael@0: if (target.tagName.toLowerCase() === "body") { michael@0: return; michael@0: } michael@0: target = target.parentNode; michael@0: } michael@0: michael@0: let container = target.container; michael@0: if (this._hoveredNode !== container.node) { michael@0: if (container.node.nodeType !== Ci.nsIDOMNode.TEXT_NODE) { michael@0: this._showBoxModel(container.node); michael@0: } else { michael@0: this._hideBoxModel(); michael@0: } michael@0: } michael@0: this._showContainerAsHovered(container.node); michael@0: }, michael@0: michael@0: _hoveredNode: null, michael@0: _showContainerAsHovered: function(nodeFront) { michael@0: if (this._hoveredNode !== nodeFront) { michael@0: if (this._hoveredNode) { michael@0: this._containers.get(this._hoveredNode).hovered = false; michael@0: } michael@0: this._containers.get(nodeFront).hovered = true; michael@0: michael@0: this._hoveredNode = nodeFront; michael@0: } michael@0: }, michael@0: michael@0: _onMouseLeave: function() { michael@0: this._hideBoxModel(true); michael@0: if (this._hoveredNode) { michael@0: this._containers.get(this._hoveredNode).hovered = false; michael@0: } michael@0: this._hoveredNode = null; michael@0: }, michael@0: michael@0: _showBoxModel: function(nodeFront, options={}) { michael@0: this._inspector.toolbox.highlighterUtils.highlightNodeFront(nodeFront, options); michael@0: }, michael@0: michael@0: _hideBoxModel: function(forceHide) { michael@0: return this._inspector.toolbox.highlighterUtils.unhighlight(forceHide); michael@0: }, michael@0: michael@0: _briefBoxModelTimer: null, michael@0: _brieflyShowBoxModel: function(nodeFront, options) { michael@0: let win = this._frame.contentWindow; michael@0: michael@0: if (this._briefBoxModelTimer) { michael@0: win.clearTimeout(this._briefBoxModelTimer); michael@0: this._briefBoxModelTimer = null; michael@0: } michael@0: michael@0: this._showBoxModel(nodeFront, options); michael@0: michael@0: this._briefBoxModelTimer = this._frame.contentWindow.setTimeout(() => { michael@0: this._hideBoxModel(); michael@0: }, NEW_SELECTION_HIGHLIGHTER_TIMER); michael@0: }, michael@0: michael@0: template: function(aName, aDest, aOptions={stack: "markup-view.xhtml"}) { michael@0: let node = this.doc.getElementById("template-" + aName).cloneNode(true); michael@0: node.removeAttribute("id"); michael@0: template(node, aDest, aOptions); michael@0: return node; michael@0: }, michael@0: michael@0: /** michael@0: * Get the MarkupContainer object for a given node, or undefined if michael@0: * none exists. michael@0: */ michael@0: getContainer: function(aNode) { michael@0: return this._containers.get(aNode); michael@0: }, michael@0: michael@0: update: function() { michael@0: let updateChildren = function(node) { michael@0: this.getContainer(node).update(); michael@0: for (let child of node.treeChildren()) { michael@0: updateChildren(child); michael@0: } michael@0: }.bind(this); michael@0: michael@0: // Start with the documentElement michael@0: let documentElement; michael@0: for (let node of this._rootNode.treeChildren()) { michael@0: if (node.isDocumentElement === true) { michael@0: documentElement = node; michael@0: break; michael@0: } michael@0: } michael@0: michael@0: // Recursively update each node starting with documentElement. michael@0: updateChildren(documentElement); michael@0: }, michael@0: michael@0: /** michael@0: * Executed when the mouse hovers over a target in the markup-view and is used michael@0: * to decide whether this target should be used to display an image preview michael@0: * tooltip. michael@0: * Delegates the actual decision to the corresponding MarkupContainer instance michael@0: * if one is found. michael@0: * @return the promise returned by MarkupContainer._isImagePreviewTarget michael@0: */ michael@0: _isImagePreviewTarget: function(target) { michael@0: // From the target passed here, let's find the parent MarkupContainer michael@0: // and ask it if the tooltip should be shown michael@0: let parent = target, container; michael@0: while (parent !== this.doc.body) { michael@0: if (parent.container) { michael@0: container = parent.container; michael@0: break; michael@0: } michael@0: parent = parent.parentNode; michael@0: } michael@0: michael@0: if (container) { michael@0: // With the newly found container, delegate the tooltip content creation michael@0: // and decision to show or not the tooltip michael@0: return container._isImagePreviewTarget(target, this.tooltip); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Given the known reason, should the current selection be briefly highlighted michael@0: * In a few cases, we don't want to highlight the node: michael@0: * - If the reason is null (used to reset the selection), michael@0: * - if it's "inspector-open" (when the inspector opens up, let's not highlight michael@0: * the default node) michael@0: * - if it's "navigateaway" (since the page is being navigated away from) michael@0: * - if it's "test" (this is a special case for mochitest. In tests, we often michael@0: * need to select elements but don't necessarily want the highlighter to come michael@0: * and go after a delay as this might break test scenarios) michael@0: * We also do not want to start a brief highlight timeout if the node is already michael@0: * being hovered over, since in that case it will already be highlighted. michael@0: */ michael@0: _shouldNewSelectionBeHighlighted: function() { michael@0: let reason = this._inspector.selection.reason; michael@0: let unwantedReasons = ["inspector-open", "navigateaway", "test"]; michael@0: let isHighlitNode = this._hoveredNode === this._inspector.selection.nodeFront; michael@0: return !isHighlitNode && reason && unwantedReasons.indexOf(reason) === -1; michael@0: }, michael@0: michael@0: /** michael@0: * Highlight the inspector selected node. michael@0: */ michael@0: _onNewSelection: function() { michael@0: let selection = this._inspector.selection; michael@0: michael@0: this.htmlEditor.hide(); michael@0: let done = this._inspector.updating("markup-view"); michael@0: if (selection.isNode()) { michael@0: if (this._shouldNewSelectionBeHighlighted()) { michael@0: this._brieflyShowBoxModel(selection.nodeFront, {}); michael@0: } michael@0: michael@0: this.showNode(selection.nodeFront, true).then(() => { michael@0: if (selection.reason !== "treepanel") { michael@0: this.markNodeAsSelected(selection.nodeFront); michael@0: } michael@0: done(); michael@0: }, (e) => { michael@0: console.error(e); michael@0: done(); michael@0: }); michael@0: } else { michael@0: this.unmarkSelectedNode(); michael@0: done(); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Create a TreeWalker to find the next/previous michael@0: * node for selection. michael@0: */ michael@0: _selectionWalker: function(aStart) { michael@0: let walker = this.doc.createTreeWalker( michael@0: aStart || this._elt, michael@0: Ci.nsIDOMNodeFilter.SHOW_ELEMENT, michael@0: function(aElement) { michael@0: if (aElement.container && michael@0: aElement.container.elt === aElement && michael@0: aElement.container.visible) { michael@0: return Ci.nsIDOMNodeFilter.FILTER_ACCEPT; michael@0: } michael@0: return Ci.nsIDOMNodeFilter.FILTER_SKIP; michael@0: } michael@0: ); michael@0: walker.currentNode = this._selectedContainer.elt; michael@0: return walker; michael@0: }, michael@0: michael@0: /** michael@0: * Key handling. michael@0: */ michael@0: _onKeyDown: function(aEvent) { michael@0: let handled = true; michael@0: michael@0: // Ignore keystrokes that originated in editors. michael@0: if (aEvent.target.tagName.toLowerCase() === "input" || michael@0: aEvent.target.tagName.toLowerCase() === "textarea") { michael@0: return; michael@0: } michael@0: michael@0: switch(aEvent.keyCode) { michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_H: michael@0: let node = this._selectedContainer.node; michael@0: if (node.hidden) { michael@0: this.walker.unhideNode(node).then(() => this.nodeChanged(node)); michael@0: } else { michael@0: this.walker.hideNode(node).then(() => this.nodeChanged(node)); michael@0: } michael@0: break; michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_DELETE: michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_BACK_SPACE: michael@0: this.deleteNode(this._selectedContainer.node); michael@0: break; michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_HOME: michael@0: let rootContainer = this._containers.get(this._rootNode); michael@0: this.navigate(rootContainer.children.firstChild.container); michael@0: break; michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_LEFT: michael@0: if (this._selectedContainer.expanded) { michael@0: this.collapseNode(this._selectedContainer.node); michael@0: } else { michael@0: let parent = this._selectionWalker().parentNode(); michael@0: if (parent) { michael@0: this.navigate(parent.container); michael@0: } michael@0: } michael@0: break; michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_RIGHT: michael@0: if (!this._selectedContainer.expanded && michael@0: this._selectedContainer.hasChildren) { michael@0: this._expandContainer(this._selectedContainer); michael@0: } else { michael@0: let next = this._selectionWalker().nextNode(); michael@0: if (next) { michael@0: this.navigate(next.container); michael@0: } michael@0: } michael@0: break; michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_UP: michael@0: let prev = this._selectionWalker().previousNode(); michael@0: if (prev) { michael@0: this.navigate(prev.container); michael@0: } michael@0: break; michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_DOWN: michael@0: let next = this._selectionWalker().nextNode(); michael@0: if (next) { michael@0: this.navigate(next.container); michael@0: } michael@0: break; michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP: { michael@0: let walker = this._selectionWalker(); michael@0: let selection = this._selectedContainer; michael@0: for (let i = 0; i < PAGE_SIZE; i++) { michael@0: let prev = walker.previousNode(); michael@0: if (!prev) { michael@0: break; michael@0: } michael@0: selection = prev.container; michael@0: } michael@0: this.navigate(selection); michael@0: break; michael@0: } michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN: { michael@0: let walker = this._selectionWalker(); michael@0: let selection = this._selectedContainer; michael@0: for (let i = 0; i < PAGE_SIZE; i++) { michael@0: let next = walker.nextNode(); michael@0: if (!next) { michael@0: break; michael@0: } michael@0: selection = next.container; michael@0: } michael@0: this.navigate(selection); michael@0: break; michael@0: } michael@0: case Ci.nsIDOMKeyEvent.DOM_VK_F2: { michael@0: this.beginEditingOuterHTML(this._selectedContainer.node); michael@0: break; michael@0: } michael@0: default: michael@0: handled = false; michael@0: } michael@0: if (handled) { michael@0: aEvent.stopPropagation(); michael@0: aEvent.preventDefault(); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Delete a node from the DOM. michael@0: * This is an undoable action. michael@0: */ michael@0: deleteNode: function(aNode) { michael@0: if (aNode.isDocumentElement || michael@0: aNode.nodeType == Ci.nsIDOMNode.DOCUMENT_TYPE_NODE) { michael@0: return; michael@0: } michael@0: michael@0: let container = this._containers.get(aNode); michael@0: michael@0: // Retain the node so we can undo this... michael@0: this.walker.retainNode(aNode).then(() => { michael@0: let parent = aNode.parentNode(); michael@0: let sibling = null; michael@0: this.undo.do(() => { michael@0: if (container.selected) { michael@0: this.navigate(this._containers.get(parent)); michael@0: } michael@0: this.walker.removeNode(aNode).then(nextSibling => { michael@0: sibling = nextSibling; michael@0: }); michael@0: }, () => { michael@0: this.walker.insertBefore(aNode, parent, sibling); michael@0: }); michael@0: }).then(null, console.error); michael@0: }, michael@0: michael@0: /** michael@0: * If an editable item is focused, select its container. michael@0: */ michael@0: _onFocus: function(aEvent) { michael@0: let parent = aEvent.target; michael@0: while (!parent.container) { michael@0: parent = parent.parentNode; michael@0: } michael@0: if (parent) { michael@0: this.navigate(parent.container, true); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Handle a user-requested navigation to a given MarkupContainer, michael@0: * updating the inspector's currently-selected node. michael@0: * michael@0: * @param MarkupContainer aContainer michael@0: * The container we're navigating to. michael@0: * @param aIgnoreFocus aIgnoreFocus michael@0: * If falsy, keyboard focus will be moved to the container too. michael@0: */ michael@0: navigate: function(aContainer, aIgnoreFocus) { michael@0: if (!aContainer) { michael@0: return; michael@0: } michael@0: michael@0: let node = aContainer.node; michael@0: this.markNodeAsSelected(node, "treepanel"); michael@0: michael@0: if (!aIgnoreFocus) { michael@0: aContainer.focus(); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Make sure a node is included in the markup tool. michael@0: * michael@0: * @param DOMNode aNode michael@0: * The node in the content document. michael@0: * @param boolean aFlashNode michael@0: * Whether the newly imported node should be flashed michael@0: * @returns MarkupContainer The MarkupContainer object for this element. michael@0: */ michael@0: importNode: function(aNode, aFlashNode) { michael@0: if (!aNode) { michael@0: return null; michael@0: } michael@0: michael@0: if (this._containers.has(aNode)) { michael@0: return this._containers.get(aNode); michael@0: } michael@0: michael@0: if (aNode === this.walker.rootNode) { michael@0: var container = new RootContainer(this, aNode); michael@0: this._elt.appendChild(container.elt); michael@0: this._rootNode = aNode; michael@0: } else { michael@0: var container = new MarkupContainer(this, aNode, this._inspector); michael@0: if (aFlashNode) { michael@0: container.flashMutation(); michael@0: } michael@0: } michael@0: michael@0: this._containers.set(aNode, container); michael@0: container.childrenDirty = true; michael@0: michael@0: this._updateChildren(container); michael@0: michael@0: return container; michael@0: }, michael@0: michael@0: /** michael@0: * Mutation observer used for included nodes. michael@0: */ michael@0: _mutationObserver: function(aMutations) { michael@0: let requiresLayoutChange = false; michael@0: let reselectParent; michael@0: let reselectChildIndex; michael@0: michael@0: for (let mutation of aMutations) { michael@0: let type = mutation.type; michael@0: let target = mutation.target; michael@0: michael@0: if (mutation.type === "documentUnload") { michael@0: // Treat this as a childList change of the child (maybe the protocol michael@0: // should do this). michael@0: type = "childList"; michael@0: target = mutation.targetParent; michael@0: if (!target) { michael@0: continue; michael@0: } michael@0: } michael@0: michael@0: let container = this._containers.get(target); michael@0: if (!container) { michael@0: // Container might not exist if this came from a load event for a node michael@0: // we're not viewing. michael@0: continue; michael@0: } michael@0: if (type === "attributes" || type === "characterData") { michael@0: container.update(); michael@0: michael@0: // Auto refresh style properties on selected node when they change. michael@0: if (type === "attributes" && container.selected) { michael@0: requiresLayoutChange = true; michael@0: } michael@0: } else if (type === "childList") { michael@0: let isFromOuterHTML = mutation.removed.some((n) => { michael@0: return n === this._outerHTMLNode; michael@0: }); michael@0: michael@0: // Keep track of which node should be reselected after mutations. michael@0: if (isFromOuterHTML) { michael@0: reselectParent = target; michael@0: reselectChildIndex = this._outerHTMLChildIndex; michael@0: michael@0: delete this._outerHTMLNode; michael@0: delete this._outerHTMLChildIndex; michael@0: } michael@0: michael@0: container.childrenDirty = true; michael@0: // Update the children to take care of changes in the markup view DOM. michael@0: this._updateChildren(container, {flash: !isFromOuterHTML}); michael@0: } michael@0: } michael@0: michael@0: if (requiresLayoutChange) { michael@0: this._inspector.immediateLayoutChange(); michael@0: } michael@0: this._waitForChildren().then((nodes) => { michael@0: this._flashMutatedNodes(aMutations); michael@0: this._inspector.emit("markupmutation", aMutations); michael@0: michael@0: // Since the htmlEditor is absolutely positioned, a mutation may change michael@0: // the location in which it should be shown. michael@0: this.htmlEditor.refresh(); michael@0: michael@0: // If a node has had its outerHTML set, the parent node will be selected. michael@0: // Reselect the original node immediately. michael@0: if (this._inspector.selection.nodeFront === reselectParent) { michael@0: this.walker.children(reselectParent).then((o) => { michael@0: let node = o.nodes[reselectChildIndex]; michael@0: let container = this._containers.get(node); michael@0: if (node && container) { michael@0: this.markNodeAsSelected(node, "outerhtml"); michael@0: if (container.hasChildren) { michael@0: this.expandNode(node); michael@0: } michael@0: } michael@0: }); michael@0: michael@0: } michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Given a list of mutations returned by the mutation observer, flash the michael@0: * corresponding containers to attract attention. michael@0: */ michael@0: _flashMutatedNodes: function(aMutations) { michael@0: let addedOrEditedContainers = new Set(); michael@0: let removedContainers = new Set(); michael@0: michael@0: for (let {type, target, added, removed} of aMutations) { michael@0: let container = this._containers.get(target); michael@0: michael@0: if (container) { michael@0: if (type === "attributes" || type === "characterData") { michael@0: addedOrEditedContainers.add(container); michael@0: } else if (type === "childList") { michael@0: // If there has been removals, flash the parent michael@0: if (removed.length) { michael@0: removedContainers.add(container); michael@0: } michael@0: michael@0: // If there has been additions, flash the nodes michael@0: added.forEach(added => { michael@0: let addedContainer = this._containers.get(added); michael@0: addedOrEditedContainers.add(addedContainer); michael@0: michael@0: // The node may be added as a result of an append, in which case it michael@0: // it will have been removed from another container first, but in michael@0: // these cases we don't want to flash both the removal and the michael@0: // addition michael@0: removedContainers.delete(container); michael@0: }); michael@0: } michael@0: } michael@0: } michael@0: michael@0: for (let container of removedContainers) { michael@0: container.flashMutation(); michael@0: } michael@0: for (let container of addedOrEditedContainers) { michael@0: container.flashMutation(); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Make sure the given node's parents are expanded and the michael@0: * node is scrolled on to screen. michael@0: */ michael@0: showNode: function(aNode, centered) { michael@0: let parent = aNode; michael@0: michael@0: this.importNode(aNode); michael@0: michael@0: while ((parent = parent.parentNode())) { michael@0: this.importNode(parent); michael@0: this.expandNode(parent); michael@0: } michael@0: michael@0: return this._waitForChildren().then(() => { michael@0: return this._ensureVisible(aNode); michael@0: }).then(() => { michael@0: // Why is this not working? michael@0: this.layoutHelpers.scrollIntoViewIfNeeded(this._containers.get(aNode).editor.elt, centered); michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Expand the container's children. michael@0: */ michael@0: _expandContainer: function(aContainer) { michael@0: return this._updateChildren(aContainer, {expand: true}).then(() => { michael@0: aContainer.expanded = true; michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Expand the node's children. michael@0: */ michael@0: expandNode: function(aNode) { michael@0: let container = this._containers.get(aNode); michael@0: this._expandContainer(container); michael@0: }, michael@0: michael@0: /** michael@0: * Expand the entire tree beneath a container. michael@0: * michael@0: * @param aContainer The container to expand. michael@0: */ michael@0: _expandAll: function(aContainer) { michael@0: return this._expandContainer(aContainer).then(() => { michael@0: let child = aContainer.children.firstChild; michael@0: let promises = []; michael@0: while (child) { michael@0: promises.push(this._expandAll(child.container)); michael@0: child = child.nextSibling; michael@0: } michael@0: return promise.all(promises); michael@0: }).then(null, console.error); michael@0: }, michael@0: michael@0: /** michael@0: * Expand the entire tree beneath a node. michael@0: * michael@0: * @param aContainer The node to expand, or null michael@0: * to start from the top. michael@0: */ michael@0: expandAll: function(aNode) { michael@0: aNode = aNode || this._rootNode; michael@0: return this._expandAll(this._containers.get(aNode)); michael@0: }, michael@0: michael@0: /** michael@0: * Collapse the node's children. michael@0: */ michael@0: collapseNode: function(aNode) { michael@0: let container = this._containers.get(aNode); michael@0: container.expanded = false; michael@0: }, michael@0: michael@0: /** michael@0: * Retrieve the outerHTML for a remote node. michael@0: * @param aNode The NodeFront to get the outerHTML for. michael@0: * @returns A promise that will be resolved with the outerHTML. michael@0: */ michael@0: getNodeOuterHTML: function(aNode) { michael@0: let def = promise.defer(); michael@0: this.walker.outerHTML(aNode).then(longstr => { michael@0: longstr.string().then(outerHTML => { michael@0: longstr.release().then(null, console.error); michael@0: def.resolve(outerHTML); michael@0: }); michael@0: }); michael@0: return def.promise; michael@0: }, michael@0: michael@0: /** michael@0: * Retrieve the index of a child within its parent's children list. michael@0: * @param aNode The NodeFront to find the index of. michael@0: * @returns A promise that will be resolved with the integer index. michael@0: * If the child cannot be found, returns -1 michael@0: */ michael@0: getNodeChildIndex: function(aNode) { michael@0: let def = promise.defer(); michael@0: let parentNode = aNode.parentNode(); michael@0: michael@0: // Node may have been removed from the DOM, instead of throwing an error, michael@0: // return -1 indicating that it isn't inside of its parent children list. michael@0: if (!parentNode) { michael@0: def.resolve(-1); michael@0: } else { michael@0: this.walker.children(parentNode).then(children => { michael@0: def.resolve(children.nodes.indexOf(aNode)); michael@0: }); michael@0: } michael@0: michael@0: return def.promise; michael@0: }, michael@0: michael@0: /** michael@0: * Retrieve the index of a child within its parent's children collection. michael@0: * @param aNode The NodeFront to find the index of. michael@0: * @param newValue The new outerHTML to set on the node. michael@0: * @param oldValue The old outerHTML that will be reverted to find the index of. michael@0: * @returns A promise that will be resolved with the integer index. michael@0: * If the child cannot be found, returns -1 michael@0: */ michael@0: updateNodeOuterHTML: function(aNode, newValue, oldValue) { michael@0: let container = this._containers.get(aNode); michael@0: if (!container) { michael@0: return; michael@0: } michael@0: michael@0: this.getNodeChildIndex(aNode).then((i) => { michael@0: this._outerHTMLChildIndex = i; michael@0: this._outerHTMLNode = aNode; michael@0: michael@0: container.undo.do(() => { michael@0: this.walker.setOuterHTML(aNode, newValue); michael@0: }, () => { michael@0: this.walker.setOuterHTML(aNode, oldValue); michael@0: }); michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Open an editor in the UI to allow editing of a node's outerHTML. michael@0: * @param aNode The NodeFront to edit. michael@0: */ michael@0: beginEditingOuterHTML: function(aNode) { michael@0: this.getNodeOuterHTML(aNode).then((oldValue)=> { michael@0: let container = this._containers.get(aNode); michael@0: if (!container) { michael@0: return; michael@0: } michael@0: this.htmlEditor.show(container.tagLine, oldValue); michael@0: this.htmlEditor.once("popuphidden", (e, aCommit, aValue) => { michael@0: // Need to focus the element instead of the frame / window michael@0: // in order to give keyboard focus back to doc (from editor). michael@0: this._frame.contentDocument.documentElement.focus(); michael@0: michael@0: if (aCommit) { michael@0: this.updateNodeOuterHTML(aNode, aValue, oldValue); michael@0: } michael@0: }); michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Mark the given node expanded. michael@0: * @param {NodeFront} aNode The NodeFront to mark as expanded. michael@0: * @param {Boolean} aExpanded Whether the expand or collapse. michael@0: * @param {Boolean} aExpandDescendants Whether to expand all descendants too michael@0: */ michael@0: setNodeExpanded: function(aNode, aExpanded, aExpandDescendants) { michael@0: if (aExpanded) { michael@0: if (aExpandDescendants) { michael@0: this.expandAll(aNode); michael@0: } else { michael@0: this.expandNode(aNode); michael@0: } michael@0: } else { michael@0: this.collapseNode(aNode); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Mark the given node selected, and update the inspector.selection michael@0: * object's NodeFront to keep consistent state between UI and selection. michael@0: * @param aNode The NodeFront to mark as selected. michael@0: */ michael@0: markNodeAsSelected: function(aNode, reason) { michael@0: let container = this._containers.get(aNode); michael@0: if (this._selectedContainer === container) { michael@0: return false; michael@0: } michael@0: if (this._selectedContainer) { michael@0: this._selectedContainer.selected = false; michael@0: } michael@0: this._selectedContainer = container; michael@0: if (aNode) { michael@0: this._selectedContainer.selected = true; michael@0: } michael@0: michael@0: this._inspector.selection.setNodeFront(aNode, reason || "nodeselected"); michael@0: return true; michael@0: }, michael@0: michael@0: /** michael@0: * Make sure that every ancestor of the selection are updated michael@0: * and included in the list of visible children. michael@0: */ michael@0: _ensureVisible: function(node) { michael@0: while (node) { michael@0: let container = this._containers.get(node); michael@0: let parent = node.parentNode(); michael@0: if (!container.elt.parentNode) { michael@0: let parentContainer = this._containers.get(parent); michael@0: if (parentContainer) { michael@0: parentContainer.childrenDirty = true; michael@0: this._updateChildren(parentContainer, {expand: node}); michael@0: } michael@0: } michael@0: michael@0: node = parent; michael@0: } michael@0: return this._waitForChildren(); michael@0: }, michael@0: michael@0: /** michael@0: * Unmark selected node (no node selected). michael@0: */ michael@0: unmarkSelectedNode: function() { michael@0: if (this._selectedContainer) { michael@0: this._selectedContainer.selected = false; michael@0: this._selectedContainer = null; michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Called when the markup panel initiates a change on a node. michael@0: */ michael@0: nodeChanged: function(aNode) { michael@0: if (aNode === this._inspector.selection.nodeFront) { michael@0: this._inspector.change("markupview"); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Check if the current selection is a descendent of the container. michael@0: * if so, make sure it's among the visible set for the container, michael@0: * and set the dirty flag if needed. michael@0: * @returns The node that should be made visible, if any. michael@0: */ michael@0: _checkSelectionVisible: function(aContainer) { michael@0: let centered = null; michael@0: let node = this._inspector.selection.nodeFront; michael@0: while (node) { michael@0: if (node.parentNode() === aContainer.node) { michael@0: centered = node; michael@0: break; michael@0: } michael@0: node = node.parentNode(); michael@0: } michael@0: michael@0: return centered; michael@0: }, michael@0: michael@0: /** michael@0: * Make sure all children of the given container's node are michael@0: * imported and attached to the container in the right order. michael@0: * michael@0: * Children need to be updated only in the following circumstances: michael@0: * a) We just imported this node and have never seen its children. michael@0: * container.childrenDirty will be set by importNode in this case. michael@0: * b) We received a childList mutation on the node. michael@0: * container.childrenDirty will be set in that case too. michael@0: * c) We have changed the selection, and the path to that selection michael@0: * wasn't loaded in a previous children request (because we only michael@0: * grab a subset). michael@0: * container.childrenDirty should be set in that case too! michael@0: * michael@0: * @param MarkupContainer aContainer michael@0: * The markup container whose children need updating michael@0: * @param Object options michael@0: * Options are {expand:boolean,flash:boolean} michael@0: * @return a promise that will be resolved when the children are ready michael@0: * (which may be immediately). michael@0: */ michael@0: _updateChildren: function(aContainer, options) { michael@0: let expand = options && options.expand; michael@0: let flash = options && options.flash; michael@0: michael@0: aContainer.hasChildren = aContainer.node.hasChildren; michael@0: michael@0: if (!this._queuedChildUpdates) { michael@0: this._queuedChildUpdates = new Map(); michael@0: } michael@0: michael@0: if (this._queuedChildUpdates.has(aContainer)) { michael@0: return this._queuedChildUpdates.get(aContainer); michael@0: } michael@0: michael@0: if (!aContainer.childrenDirty) { michael@0: return promise.resolve(aContainer); michael@0: } michael@0: michael@0: if (!aContainer.hasChildren) { michael@0: while (aContainer.children.firstChild) { michael@0: aContainer.children.removeChild(aContainer.children.firstChild); michael@0: } michael@0: aContainer.childrenDirty = false; michael@0: return promise.resolve(aContainer); michael@0: } michael@0: michael@0: // If we're not expanded (or asked to update anyway), we're done for michael@0: // now. Note that this will leave the childrenDirty flag set, so when michael@0: // expanded we'll refresh the child list. michael@0: if (!(aContainer.expanded || expand)) { michael@0: return promise.resolve(aContainer); michael@0: } michael@0: michael@0: // We're going to issue a children request, make sure it includes the michael@0: // centered node. michael@0: let centered = this._checkSelectionVisible(aContainer); michael@0: michael@0: // Children aren't updated yet, but clear the childrenDirty flag anyway. michael@0: // If the dirty flag is re-set while we're fetching we'll need to fetch michael@0: // again. michael@0: aContainer.childrenDirty = false; michael@0: let updatePromise = this._getVisibleChildren(aContainer, centered).then(children => { michael@0: if (!this._containers) { michael@0: return promise.reject("markup view destroyed"); michael@0: } michael@0: this._queuedChildUpdates.delete(aContainer); michael@0: michael@0: // If children are dirty, we got a change notification for this node michael@0: // while the request was in progress, we need to do it again. michael@0: if (aContainer.childrenDirty) { michael@0: return this._updateChildren(aContainer, {expand: centered}); michael@0: } michael@0: michael@0: let fragment = this.doc.createDocumentFragment(); michael@0: michael@0: for (let child of children.nodes) { michael@0: let container = this.importNode(child, flash); michael@0: fragment.appendChild(container.elt); michael@0: } michael@0: michael@0: while (aContainer.children.firstChild) { michael@0: aContainer.children.removeChild(aContainer.children.firstChild); michael@0: } michael@0: michael@0: if (!(children.hasFirst && children.hasLast)) { michael@0: let data = { michael@0: showing: this.strings.GetStringFromName("markupView.more.showing"), michael@0: showAll: this.strings.formatStringFromName( michael@0: "markupView.more.showAll", michael@0: [aContainer.node.numChildren.toString()], 1), michael@0: allButtonClick: () => { michael@0: aContainer.maxChildren = -1; michael@0: aContainer.childrenDirty = true; michael@0: this._updateChildren(aContainer); michael@0: } michael@0: }; michael@0: michael@0: if (!children.hasFirst) { michael@0: let span = this.template("more-nodes", data); michael@0: fragment.insertBefore(span, fragment.firstChild); michael@0: } michael@0: if (!children.hasLast) { michael@0: let span = this.template("more-nodes", data); michael@0: fragment.appendChild(span); michael@0: } michael@0: } michael@0: michael@0: aContainer.children.appendChild(fragment); michael@0: return aContainer; michael@0: }).then(null, console.error); michael@0: this._queuedChildUpdates.set(aContainer, updatePromise); michael@0: return updatePromise; michael@0: }, michael@0: michael@0: _waitForChildren: function() { michael@0: if (!this._queuedChildUpdates) { michael@0: return promise.resolve(undefined); michael@0: } michael@0: return promise.all([updatePromise for (updatePromise of this._queuedChildUpdates.values())]); michael@0: }, michael@0: michael@0: /** michael@0: * Return a list of the children to display for this container. michael@0: */ michael@0: _getVisibleChildren: function(aContainer, aCentered) { michael@0: let maxChildren = aContainer.maxChildren || this.maxChildren; michael@0: if (maxChildren == -1) { michael@0: maxChildren = undefined; michael@0: } michael@0: michael@0: return this.walker.children(aContainer.node, { michael@0: maxNodes: maxChildren, michael@0: center: aCentered michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * Tear down the markup panel. michael@0: */ michael@0: destroy: function() { michael@0: if (this._destroyer) { michael@0: return this._destroyer; michael@0: } michael@0: michael@0: // Note that if the toolbox is closed, this will work fine, but will fail michael@0: // in case the browser is closed and will trigger a noSuchActor message. michael@0: this._destroyer = this._hideBoxModel(); michael@0: michael@0: this._hoveredNode = null; michael@0: this._inspector.toolbox.off("picker-node-hovered", this._onToolboxPickerHover); michael@0: michael@0: this.htmlEditor.destroy(); michael@0: this.htmlEditor = null; michael@0: michael@0: this.undo.destroy(); michael@0: this.undo = null; michael@0: michael@0: this.popup.destroy(); michael@0: this.popup = null; michael@0: michael@0: this._frame.removeEventListener("focus", this._boundFocus, false); michael@0: this._boundFocus = null; michael@0: michael@0: if (this._boundUpdatePreview) { michael@0: this._frame.contentWindow.removeEventListener("scroll", michael@0: this._boundUpdatePreview, true); michael@0: this._boundUpdatePreview = null; michael@0: } michael@0: michael@0: if (this._boundResizePreview) { michael@0: this._frame.contentWindow.removeEventListener("resize", michael@0: this._boundResizePreview, true); michael@0: this._frame.contentWindow.removeEventListener("overflow", michael@0: this._boundResizePreview, true); michael@0: this._frame.contentWindow.removeEventListener("underflow", michael@0: this._boundResizePreview, true); michael@0: this._boundResizePreview = null; michael@0: } michael@0: michael@0: this._frame.contentWindow.removeEventListener("keydown", michael@0: this._boundKeyDown, false); michael@0: this._boundKeyDown = null; michael@0: michael@0: this._inspector.selection.off("new-node-front", this._boundOnNewSelection); michael@0: this._boundOnNewSelection = null; michael@0: michael@0: this.walker.off("mutations", this._boundMutationObserver) michael@0: this._boundMutationObserver = null; michael@0: michael@0: this._elt.removeEventListener("mousemove", this._onMouseMove, false); michael@0: this._elt.removeEventListener("mouseleave", this._onMouseLeave, false); michael@0: this._elt = null; michael@0: michael@0: for (let [key, container] of this._containers) { michael@0: container.destroy(); michael@0: } michael@0: this._containers = null; michael@0: michael@0: this.tooltip.destroy(); michael@0: this.tooltip = null; michael@0: michael@0: return this._destroyer; michael@0: }, michael@0: michael@0: /** michael@0: * Initialize the preview panel. michael@0: */ michael@0: _initPreview: function() { michael@0: this._previewEnabled = Services.prefs.getBoolPref("devtools.inspector.markupPreview"); michael@0: if (!this._previewEnabled) { michael@0: return; michael@0: } michael@0: michael@0: this._previewBar = this.doc.querySelector("#previewbar"); michael@0: this._preview = this.doc.querySelector("#preview"); michael@0: this._viewbox = this.doc.querySelector("#viewbox"); michael@0: michael@0: this._previewBar.classList.remove("disabled"); michael@0: michael@0: this._previewWidth = this._preview.getBoundingClientRect().width; michael@0: michael@0: this._boundResizePreview = this._resizePreview.bind(this); michael@0: this._frame.contentWindow.addEventListener("resize", michael@0: this._boundResizePreview, true); michael@0: this._frame.contentWindow.addEventListener("overflow", michael@0: this._boundResizePreview, true); michael@0: this._frame.contentWindow.addEventListener("underflow", michael@0: this._boundResizePreview, true); michael@0: michael@0: this._boundUpdatePreview = this._updatePreview.bind(this); michael@0: this._frame.contentWindow.addEventListener("scroll", michael@0: this._boundUpdatePreview, true); michael@0: this._updatePreview(); michael@0: }, michael@0: michael@0: /** michael@0: * Move the preview viewbox. michael@0: */ michael@0: _updatePreview: function() { michael@0: if (!this._previewEnabled) { michael@0: return; michael@0: } michael@0: let win = this._frame.contentWindow; michael@0: michael@0: if (win.scrollMaxY == 0) { michael@0: this._previewBar.classList.add("disabled"); michael@0: return; michael@0: } michael@0: michael@0: this._previewBar.classList.remove("disabled"); michael@0: michael@0: let ratio = this._previewWidth / PREVIEW_AREA; michael@0: let width = ratio * win.innerWidth; michael@0: michael@0: let height = ratio * (win.scrollMaxY + win.innerHeight); michael@0: let scrollTo michael@0: if (height >= win.innerHeight) { michael@0: scrollTo = -(height - win.innerHeight) * (win.scrollY / win.scrollMaxY); michael@0: this._previewBar.setAttribute("style", "height:" + height + michael@0: "px;transform:translateY(" + scrollTo + "px)"); michael@0: } else { michael@0: this._previewBar.setAttribute("style", "height:100%"); michael@0: } michael@0: michael@0: let bgSize = ~~width + "px " + ~~height + "px"; michael@0: this._preview.setAttribute("style", "background-size:" + bgSize); michael@0: michael@0: let height = ~~(win.innerHeight * ratio) + "px"; michael@0: let top = ~~(win.scrollY * ratio) + "px"; michael@0: this._viewbox.setAttribute("style", "height:" + height + michael@0: ";transform: translateY(" + top + ")"); michael@0: }, michael@0: michael@0: /** michael@0: * Hide the preview while resizing, to avoid slowness. michael@0: */ michael@0: _resizePreview: function() { michael@0: if (!this._previewEnabled) { michael@0: return; michael@0: } michael@0: let win = this._frame.contentWindow; michael@0: this._previewBar.classList.add("hide"); michael@0: win.clearTimeout(this._resizePreviewTimeout); michael@0: michael@0: win.setTimeout(function() { michael@0: this._updatePreview(); michael@0: this._previewBar.classList.remove("hide"); michael@0: }.bind(this), 1000); michael@0: } michael@0: }; michael@0: michael@0: michael@0: /** michael@0: * The main structure for storing a document node in the markup michael@0: * tree. Manages creation of the editor for the node and michael@0: * a