/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 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/. */ const Cc = Components.classes; const Cu = Components.utils; const Ci = Components.interfaces; // Page size for pageup/pagedown const PAGE_SIZE = 10; const PREVIEW_AREA = 700; const DEFAULT_MAX_CHILDREN = 100; this.EXPORTED_SYMBOLS = ["MarkupView"]; Cu.import("resource:///modules/devtools/LayoutHelpers.jsm"); Cu.import("resource:///modules/devtools/CssRuleView.jsm"); Cu.import("resource:///modules/devtools/Templater.jsm"); Cu.import("resource:///modules/devtools/Undo.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); /** * Vocabulary for the purposes of this file: * * MarkupContainer - the structure that holds an editor and its * immediate children in the markup panel. * 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. */ this.MarkupView = function MarkupView(aInspector, aFrame, aControllerWindow) { this._inspector = aInspector; this._frame = aFrame; this.doc = this._frame.contentDocument; this._elt = this.doc.querySelector("#root"); try { this.maxChildren = Services.prefs.getIntPref("devtools.markup.pagesize"); } catch(ex) { this.maxChildren = DEFAULT_MAX_CHILDREN; } this.undo = new UndoStack(); this.undo.installController(aControllerWindow); this._containers = new WeakMap(); this._observer = new this.doc.defaultView.MutationObserver(this._mutationObserver.bind(this)); this._boundOnNewSelection = this._onNewSelection.bind(this); this._inspector.selection.on("new-node", this._boundOnNewSelection); this._onNewSelection(); this._boundKeyDown = this._onKeyDown.bind(this); this._frame.contentWindow.addEventListener("keydown", this._boundKeyDown, false); this._boundFocus = this._onFocus.bind(this); this._frame.addEventListener("focus", this._boundFocus, false); this._initPreview(); } MarkupView.prototype = { _selectedContainer: null, template: function MT_template(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 MT_getContainer(aNode) { return this._containers.get(aNode); }, /** * Highlight the inspector selected node. */ _onNewSelection: function MT__onNewSelection() { if (this._inspector.selection.isNode()) { this.showNode(this._inspector.selection.node, true); this.markNodeAsSelected(this._inspector.selection.node); } else { this.unmarkSelectedNode(); } }, /** * Create a TreeWalker to find the next/previous * node for selection. */ _selectionWalker: function MT__seletionWalker(aStart) { let walker = this.doc.createTreeWalker( aStart || this._elt, Ci.nsIDOMNodeFilter.SHOW_ELEMENT, function(aElement) { if (aElement.container && aElement.container.visible) { return Ci.nsIDOMNodeFilter.FILTER_ACCEPT; } return Ci.nsIDOMNodeFilter.FILTER_SKIP; } ); walker.currentNode = this._selectedContainer.elt; return walker; }, /** * Key handling. */ _onKeyDown: function MT__KeyDown(aEvent) { let handled = true; // Ignore keystrokes that originated in editors. if (aEvent.target.tagName.toLowerCase() === "input" || aEvent.target.tagName.toLowerCase() === "textarea") { return; } switch(aEvent.keyCode) { case Ci.nsIDOMKeyEvent.DOM_VK_DELETE: case Ci.nsIDOMKeyEvent.DOM_VK_BACK_SPACE: this.deleteNode(this._selectedContainer.node); break; case Ci.nsIDOMKeyEvent.DOM_VK_HOME: this.navigate(this._containers.get(this._rootNode.firstChild)); break; case Ci.nsIDOMKeyEvent.DOM_VK_LEFT: this.collapseNode(this._selectedContainer.node); break; case Ci.nsIDOMKeyEvent.DOM_VK_RIGHT: this.expandNode(this._selectedContainer.node); 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; } default: handled = false; } if (handled) { aEvent.stopPropagation(); aEvent.preventDefault(); } }, /** * Delete a node from the DOM. * This is an undoable action. */ deleteNode: function MC__deleteNode(aNode) { let doc = nodeDocument(aNode); if (aNode === doc || aNode === doc.documentElement || aNode.nodeType == Ci.nsIDOMNode.DOCUMENT_TYPE_NODE) { return; } let parentNode = aNode.parentNode; let sibling = aNode.nextSibling; this.undo.do(function() { if (aNode.selected) { this.navigate(this._containers.get(parentNode)); } parentNode.removeChild(aNode); }.bind(this), function() { parentNode.insertBefore(aNode, sibling); }); }, /** * If an editable item is focused, select its container. */ _onFocus: function MC__onFocus(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 MT__navigate(aContainer, aIgnoreFocus) { if (!aContainer) { return; } let node = aContainer.node; this.showNode(node, false); this._inspector.selection.setNode(node, "treepanel"); // This event won't be fired if the node is the same. But the highlighter // need to lock the node if it wasn't. this._inspector.selection.emit("new-node"); if (!aIgnoreFocus) { aContainer.focus(); } }, /** * Make sure a node is included in the markup tool. * * @param DOMNode aNode * The node in the content document. * * @returns MarkupContainer The MarkupContainer object for this element. */ importNode: function MT_importNode(aNode, aExpand) { if (!aNode) { return null; } if (this._containers.has(aNode)) { return this._containers.get(aNode); } this._observer.observe(aNode, { attributes: true, childList: true, characterData: true, }); let walker = documentWalker(aNode); let parent = walker.parentNode(); if (parent) { var container = new MarkupContainer(this, aNode); } else { var container = new RootContainer(this, aNode); this._elt.appendChild(container.elt); this._rootNode = aNode; aNode.addEventListener("load", function MP_watch_contentLoaded(aEvent) { // Fake a childList mutation here. this._mutationObserver([{target: aEvent.target, type: "childList"}]); }.bind(this), true); } this._containers.set(aNode, container); // FIXME: set an expando to prevent the the wrapper from disappearing // See bug 819131 for details. aNode.__preserveHack = true; container.expanded = aExpand; container.childrenDirty = true; this._updateChildren(container); if (parent) { this.importNode(parent, true); } return container; }, /** * Mutation observer used for included nodes. */ _mutationObserver: function MT__mutationObserver(aMutations) { for (let mutation of aMutations) { let container = this._containers.get(mutation.target); if (!container) { // Container might not exist if this came from a load event for an iframe // we're not viewing. continue; } if (mutation.type === "attributes" || mutation.type === "characterData") { container.update(); } else if (mutation.type === "childList") { container.childrenDirty = true; this._updateChildren(container); } } this._inspector.emit("markupmutation"); }, /** * Make sure the given node's parents are expanded and the * node is scrolled on to screen. */ showNode: function MT_showNode(aNode, centered) { let container = this.importNode(aNode); this._updateChildren(container); let walker = documentWalker(aNode); let parent; while (parent = walker.parentNode()) { this._updateChildren(this.getContainer(parent)); this.expandNode(parent); } LayoutHelpers.scrollIntoViewIfNeeded(this._containers.get(aNode).editor.elt, centered); }, /** * Expand the container's children. */ _expandContainer: function MT__expandContainer(aContainer) { if (aContainer.hasChildren && !aContainer.expanded) { aContainer.expanded = true; this._updateChildren(aContainer); } }, /** * Expand the node's children. */ expandNode: function MT_expandNode(aNode) { let container = this._containers.get(aNode); this._expandContainer(container); }, /** * Expand the entire tree beneath a container. * * @param aContainer The container to expand. */ _expandAll: function MT_expandAll(aContainer) { this._expandContainer(aContainer); let child = aContainer.children.firstChild; while (child) { this._expandAll(child.container); child = child.nextSibling; } }, /** * Expand the entire tree beneath a node. * * @param aContainer The node to expand, or null * to start from the top. */ expandAll: function MT_expandAll(aNode) { aNode = aNode || this._rootNode; this._expandAll(this._containers.get(aNode)); }, /** * Collapse the node's children. */ collapseNode: function MT_collapseNode(aNode) { let container = this._containers.get(aNode); container.expanded = false; }, /** * Mark the given node selected. */ markNodeAsSelected: function MT_markNodeAsSelected(aNode) { let container = this._containers.get(aNode); if (this._selectedContainer === container) { return false; } if (this._selectedContainer) { this._selectedContainer.selected = false; } this._selectedContainer = container; if (aNode) { this._selectedContainer.selected = true; } this._ensureSelectionVisible(); return true; }, /** * Make sure that every ancestor of the selection are updated * and included in the list of visible children. */ _ensureSelectionVisible: function MT_ensureSelectionVisible() { let node = this._selectedContainer.node; let walker = documentWalker(node); while (node) { let container = this._containers.get(node); let parent = walker.parentNode(); if (!container.elt.parentNode) { let parentContainer = this._containers.get(parent); parentContainer.childrenDirty = true; this._updateChildren(parentContainer, node); } node = parent; } }, /** * Unmark selected node (no node selected). */ unmarkSelectedNode: function MT_unmarkSelectedNode() { if (this._selectedContainer) { this._selectedContainer.selected = false; this._selectedContainer = null; } }, /** * Called when the markup panel initiates a change on a node. */ nodeChanged: function MT_nodeChanged(aNode) { if (aNode === this._inspector.selection) { this._inspector.change("markupview"); } }, /** * Make sure all children of the given container's node are * imported and attached to the container in the right order. * @param aCentered If provided, this child will be included * in the visible subset, and will be roughly centered * in that list. */ _updateChildren: function MT__updateChildren(aContainer, aCentered) { if (!aContainer.childrenDirty) { return false; } // Get a tree walker pointing at the first child of the node. let treeWalker = documentWalker(aContainer.node); let child = treeWalker.firstChild(); aContainer.hasChildren = !!child; if (!aContainer.expanded) { return; } aContainer.childrenDirty = false; let children = this._getVisibleChildren(aContainer, aCentered); let fragment = this.doc.createDocumentFragment(); for (child of children.children) { let container = this.importNode(child, false); 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.children.length.toString()], 1), allButtonClick: function() { aContainer.maxChildren = -1; aContainer.childrenDirty = true; this._updateChildren(aContainer); }.bind(this) }; 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 true; }, /** * Return a list of the children to display for this container. */ _getVisibleChildren: function MV__getVisibleChildren(aContainer, aCentered) { let maxChildren = aContainer.maxChildren || this.maxChildren; if (maxChildren == -1) { maxChildren = Number.MAX_VALUE; } let firstChild = documentWalker(aContainer.node).firstChild(); let lastChild = documentWalker(aContainer.node).lastChild(); if (!firstChild) { // No children, we're done. return { hasFirst: true, hasLast: true, children: [] }; } // By default try to put the selected child in the middle of the list. let start = aCentered || firstChild; // Start by reading backward from the starting point.... let nodes = []; let backwardWalker = documentWalker(start); if (backwardWalker.previousSibling()) { let backwardCount = Math.floor(maxChildren / 2); let backwardNodes = this._readBackward(backwardWalker, backwardCount); nodes = backwardNodes; } // Then read forward by any slack left in the max children... let forwardWalker = documentWalker(start); let forwardCount = maxChildren - nodes.length; nodes = nodes.concat(this._readForward(forwardWalker, forwardCount)); // If there's any room left, it means we've run all the way to the end. // In that case, there might still be more items at the front. let remaining = maxChildren - nodes.length; if (remaining > 0 && nodes[0] != firstChild) { let firstNodes = this._readBackward(backwardWalker, remaining); // Then put it all back together. nodes = firstNodes.concat(nodes); } return { hasFirst: nodes[0] == firstChild, hasLast: nodes[nodes.length - 1] == lastChild, children: nodes }; }, _readForward: function MV__readForward(aWalker, aCount) { let ret = []; let node = aWalker.currentNode; do { ret.push(node); node = aWalker.nextSibling(); } while (node && --aCount); return ret; }, _readBackward: function MV__readBackward(aWalker, aCount) { let ret = []; let node = aWalker.currentNode; do { ret.push(node); node = aWalker.previousSibling(); } while(node && --aCount); ret.reverse(); return ret; }, /** * Tear down the markup panel. */ destroy: function MT_destroy() { this.undo.destroy(); delete this.undo; this._frame.removeEventListener("focus", this._boundFocus, false); delete this._boundFocus; this._frame.contentWindow.removeEventListener("scroll", this._boundUpdatePreview, true); this._frame.contentWindow.removeEventListener("resize", this._boundUpdatePreview, true); this._frame.contentWindow.removeEventListener("overflow", this._boundResizePreview, true); this._frame.contentWindow.removeEventListener("underflow", this._boundResizePreview, true); delete this._boundUpdatePreview; this._frame.contentWindow.removeEventListener("keydown", this._boundKeyDown, true); delete this._boundKeyDown; this._inspector.selection.off("new-node", this._boundOnNewSelection); delete this._boundOnNewSelection; delete this._elt; delete this._containers; this._observer.disconnect(); delete this._observer; }, /** * Initialize the preview panel. */ _initPreview: function MT_initPreview() { if (!Services.prefs.getBoolPref("devtools.inspector.markupPreview")) { return; } this._previewBar = this.doc.querySelector("#previewbar"); this._preview = this.doc.querySelector("#preview"); this._viewbox = this.doc.querySelector("#viewbox"); this._previewBar.classList.remove("disabled"); this._previewWidth = this._preview.getBoundingClientRect().width; this._boundResizePreview = this._resizePreview.bind(this); this._frame.contentWindow.addEventListener("resize", this._boundResizePreview, true); this._frame.contentWindow.addEventListener("overflow", this._boundResizePreview, true); this._frame.contentWindow.addEventListener("underflow", this._boundResizePreview, true); this._boundUpdatePreview = this._updatePreview.bind(this); this._frame.contentWindow.addEventListener("scroll", this._boundUpdatePreview, true); this._updatePreview(); }, /** * Move the preview viewbox. */ _updatePreview: function MT_updatePreview() { let win = this._frame.contentWindow; if (win.scrollMaxY == 0) { this._previewBar.classList.add("disabled"); return; } this._previewBar.classList.remove("disabled"); let ratio = this._previewWidth / PREVIEW_AREA; let width = ratio * win.innerWidth; let height = ratio * (win.scrollMaxY + win.innerHeight); let scrollTo if (height >= win.innerHeight) { scrollTo = -(height - win.innerHeight) * (win.scrollY / win.scrollMaxY); this._previewBar.setAttribute("style", "height:" + height + "px;transform:translateY(" + scrollTo + "px)"); } else { this._previewBar.setAttribute("style", "height:100%"); } let bgSize = ~~width + "px " + ~~height + "px"; this._preview.setAttribute("style", "background-size:" + bgSize); let height = ~~(win.innerHeight * ratio) + "px"; let top = ~~(win.scrollY * ratio) + "px"; this._viewbox.setAttribute("style", "height:" + height + ";transform: translateY(" + top + ")"); }, /** * Hide the preview while resizing, to avoid slowness. */ _resizePreview: function MT_resizePreview() { let win = this._frame.contentWindow; this._previewBar.classList.add("hide"); win.clearTimeout(this._resizePreviewTimeout); win.setTimeout(function() { this._updatePreview(); this._previewBar.classList.remove("hide"); }.bind(this), 1000); }, }; /** * The main structure for storing a document node in the markup * tree. Manages creation of the editor for the node and * a