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: "use strict"; michael@0: michael@0: const {Cu, Cc, Ci} = require("chrome"); michael@0: const Services = require("Services"); michael@0: const protocol = require("devtools/server/protocol"); michael@0: const {Arg, Option, method} = protocol; michael@0: const events = require("sdk/event/core"); michael@0: michael@0: const EventEmitter = require("devtools/toolkit/event-emitter"); michael@0: const GUIDE_STROKE_WIDTH = 1; michael@0: michael@0: // Make sure the domnode type is known here michael@0: require("devtools/server/actors/inspector"); michael@0: michael@0: Cu.import("resource://gre/modules/devtools/LayoutHelpers.jsm"); michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: michael@0: // FIXME: add ":visited" and ":link" after bug 713106 is fixed michael@0: const PSEUDO_CLASSES = [":hover", ":active", ":focus"]; michael@0: const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-devtools-highlighted"; michael@0: let HELPER_SHEET = ".__fx-devtools-hide-shortcut__ { visibility: hidden !important } "; michael@0: HELPER_SHEET += ":-moz-devtools-highlighted { outline: 2px dashed #F06!important; outline-offset: -2px!important } "; michael@0: const XHTML_NS = "http://www.w3.org/1999/xhtml"; michael@0: const SVG_NS = "http://www.w3.org/2000/svg"; michael@0: const HIGHLIGHTER_PICKED_TIMER = 1000; michael@0: const INFO_BAR_OFFSET = 5; michael@0: michael@0: /** michael@0: * The HighlighterActor is the server-side entry points for any tool that wishes michael@0: * to highlight elements in the content document. michael@0: * michael@0: * The highlighter can be retrieved via the inspector's getHighlighter method. michael@0: */ michael@0: michael@0: /** michael@0: * The HighlighterActor class michael@0: */ michael@0: let HighlighterActor = protocol.ActorClass({ michael@0: typeName: "highlighter", michael@0: michael@0: initialize: function(inspector, autohide) { michael@0: protocol.Actor.prototype.initialize.call(this, null); michael@0: michael@0: this._autohide = autohide; michael@0: this._inspector = inspector; michael@0: this._walker = this._inspector.walker; michael@0: this._tabActor = this._inspector.tabActor; michael@0: michael@0: this._highlighterReady = this._highlighterReady.bind(this); michael@0: this._highlighterHidden = this._highlighterHidden.bind(this); michael@0: michael@0: if (this._supportsBoxModelHighlighter()) { michael@0: this._boxModelHighlighter = michael@0: new BoxModelHighlighter(this._tabActor, this._inspector); michael@0: michael@0: this._boxModelHighlighter.on("ready", this._highlighterReady); michael@0: this._boxModelHighlighter.on("hide", this._highlighterHidden); michael@0: } else { michael@0: this._boxModelHighlighter = new SimpleOutlineHighlighter(this._tabActor); michael@0: } michael@0: }, michael@0: michael@0: get conn() this._inspector && this._inspector.conn, michael@0: michael@0: /** michael@0: * Can the host support the box model highlighter which requires a parent michael@0: * XUL node to attach itself. michael@0: */ michael@0: _supportsBoxModelHighlighter: function() { michael@0: // Note that s on Fennec also have a XUL parentNode but the box michael@0: // model highlighter doesn't display correctly on Fennec (bug 993190) michael@0: return this._tabActor.browser && michael@0: !!this._tabActor.browser.parentNode && michael@0: Services.appinfo.ID !== "{aa3c5121-dab2-40e2-81ca-7ea25febc110}"; michael@0: }, michael@0: michael@0: destroy: function() { michael@0: protocol.Actor.prototype.destroy.call(this); michael@0: if (this._boxModelHighlighter) { michael@0: this._boxModelHighlighter.off("ready", this._highlighterReady); michael@0: this._boxModelHighlighter.off("hide", this._highlighterHidden); michael@0: this._boxModelHighlighter.destroy(); michael@0: this._boxModelHighlighter = null; michael@0: } michael@0: this._autohide = null; michael@0: this._inspector = null; michael@0: this._walker = null; michael@0: this._tabActor = null; michael@0: }, michael@0: michael@0: /** michael@0: * Display the box model highlighting on a given NodeActor. michael@0: * There is only one instance of the box model highlighter, so calling this michael@0: * method several times won't display several highlighters, it will just move michael@0: * the highlighter instance to these nodes. michael@0: * michael@0: * @param NodeActor The node to be highlighted michael@0: * @param Options See the request part for existing options. Note that not michael@0: * all options may be supported by all types of highlighters. michael@0: */ michael@0: showBoxModel: method(function(node, options={}) { michael@0: if (node && this._isNodeValidForHighlighting(node.rawNode)) { michael@0: this._boxModelHighlighter.show(node.rawNode, options); michael@0: } else { michael@0: this._boxModelHighlighter.hide(); michael@0: } michael@0: }, { michael@0: request: { michael@0: node: Arg(0, "domnode"), michael@0: region: Option(1) michael@0: } michael@0: }), michael@0: michael@0: _isNodeValidForHighlighting: function(node) { michael@0: // Is it null or dead? michael@0: let isNotDead = node && !Cu.isDeadWrapper(node); michael@0: michael@0: // Is it connected to the document? michael@0: let isConnected = false; michael@0: try { michael@0: let doc = node.ownerDocument; michael@0: isConnected = (doc && doc.defaultView && doc.documentElement.contains(node)); michael@0: } catch (e) { michael@0: // "can't access dead object" error michael@0: } michael@0: michael@0: // Is it an element node michael@0: let isElementNode = node.nodeType === Ci.nsIDOMNode.ELEMENT_NODE; michael@0: michael@0: return isNotDead && isConnected && isElementNode; michael@0: }, michael@0: michael@0: /** michael@0: * Hide the box model highlighting if it was shown before michael@0: */ michael@0: hideBoxModel: method(function() { michael@0: this._boxModelHighlighter.hide(); michael@0: }, { michael@0: request: {} michael@0: }), michael@0: michael@0: /** michael@0: * Pick a node on click, and highlight hovered nodes in the process. michael@0: * michael@0: * This method doesn't respond anything interesting, however, it starts michael@0: * mousemove, and click listeners on the content document to fire michael@0: * events and let connected clients know when nodes are hovered over or michael@0: * clicked. michael@0: * michael@0: * Once a node is picked, events will cease, and listeners will be removed. michael@0: */ michael@0: _isPicking: false, michael@0: _hoveredNode: null, michael@0: michael@0: pick: method(function() { michael@0: if (this._isPicking) { michael@0: return null; michael@0: } michael@0: this._isPicking = true; michael@0: michael@0: this._preventContentEvent = event => { michael@0: event.stopPropagation(); michael@0: event.preventDefault(); michael@0: }; michael@0: michael@0: this._onPick = event => { michael@0: this._preventContentEvent(event); michael@0: this._stopPickerListeners(); michael@0: this._isPicking = false; michael@0: if (this._autohide) { michael@0: this._tabActor.window.setTimeout(() => { michael@0: this._boxModelHighlighter.hide(); michael@0: }, HIGHLIGHTER_PICKED_TIMER); michael@0: } michael@0: events.emit(this._walker, "picker-node-picked", this._findAndAttachElement(event)); michael@0: }; michael@0: michael@0: this._onHovered = event => { michael@0: this._preventContentEvent(event); michael@0: let res = this._findAndAttachElement(event); michael@0: if (this._hoveredNode !== res.node) { michael@0: this._boxModelHighlighter.show(res.node.rawNode); michael@0: events.emit(this._walker, "picker-node-hovered", res); michael@0: this._hoveredNode = res.node; michael@0: } michael@0: }; michael@0: michael@0: this._tabActor.window.focus(); michael@0: this._startPickerListeners(); michael@0: michael@0: return null; michael@0: }), michael@0: michael@0: _findAndAttachElement: function(event) { michael@0: let doc = event.target.ownerDocument; michael@0: michael@0: let x = event.clientX; michael@0: let y = event.clientY; michael@0: michael@0: let node = doc.elementFromPoint(x, y); michael@0: return this._walker.attachElement(node); michael@0: }, michael@0: michael@0: /** michael@0: * Get the right target for listening to mouse events while in pick mode. michael@0: * - On a firefox desktop content page: tabActor is a BrowserTabActor from michael@0: * which the browser property will give us a target we can use to listen to michael@0: * events, even in nested iframes. michael@0: * - On B2G: tabActor is a ContentActor which doesn't have a browser but michael@0: * since it overrides BrowserTabActor, it does get a browser property michael@0: * anyway, which points to its window object. michael@0: * - When using the Browser Toolbox (to inspect firefox desktop): tabActor is michael@0: * the RootActor, in which case, the window property can be used to listen michael@0: * to events michael@0: */ michael@0: _getPickerListenerTarget: function() { michael@0: let actor = this._tabActor; michael@0: return actor.isRootActor ? actor.window : actor.chromeEventHandler; michael@0: }, michael@0: michael@0: _startPickerListeners: function() { michael@0: let target = this._getPickerListenerTarget(); michael@0: target.addEventListener("mousemove", this._onHovered, true); michael@0: target.addEventListener("click", this._onPick, true); michael@0: target.addEventListener("mousedown", this._preventContentEvent, true); michael@0: target.addEventListener("mouseup", this._preventContentEvent, true); michael@0: target.addEventListener("dblclick", this._preventContentEvent, true); michael@0: }, michael@0: michael@0: _stopPickerListeners: function() { michael@0: let target = this._getPickerListenerTarget(); michael@0: target.removeEventListener("mousemove", this._onHovered, true); michael@0: target.removeEventListener("click", this._onPick, true); michael@0: target.removeEventListener("mousedown", this._preventContentEvent, true); michael@0: target.removeEventListener("mouseup", this._preventContentEvent, true); michael@0: target.removeEventListener("dblclick", this._preventContentEvent, true); michael@0: }, michael@0: michael@0: _highlighterReady: function() { michael@0: events.emit(this._inspector.walker, "highlighter-ready"); michael@0: }, michael@0: michael@0: _highlighterHidden: function() { michael@0: events.emit(this._inspector.walker, "highlighter-hide"); michael@0: }, michael@0: michael@0: cancelPick: method(function() { michael@0: if (this._isPicking) { michael@0: this._boxModelHighlighter.hide(); michael@0: this._stopPickerListeners(); michael@0: this._isPicking = false; michael@0: this._hoveredNode = null; michael@0: } michael@0: }) michael@0: }); michael@0: michael@0: exports.HighlighterActor = HighlighterActor; michael@0: michael@0: /** michael@0: * The HighlighterFront class michael@0: */ michael@0: let HighlighterFront = protocol.FrontClass(HighlighterActor, {}); michael@0: michael@0: /** michael@0: * The BoxModelHighlighter is the class that actually draws the the box model michael@0: * regions on top of a node. michael@0: * It is used by the HighlighterActor. michael@0: * michael@0: * Usage example: michael@0: * michael@0: * let h = new BoxModelHighlighter(browser); michael@0: * h.show(node); michael@0: * h.hide(); michael@0: * h.destroy(); michael@0: * michael@0: * Structure: michael@0: * michael@0: * michael@0: * michael@0: * michael@0: * michael@0: * michael@0: * michael@0: * Node name michael@0: * Node id michael@0: * .someClass michael@0: * :hover michael@0: * michael@0: * michael@0: * michael@0: * michael@0: * michael@0: * michael@0: */ michael@0: function BoxModelHighlighter(tabActor, inspector) { michael@0: this.browser = tabActor.browser; michael@0: this.win = tabActor.window; michael@0: this.chromeDoc = this.browser.ownerDocument; michael@0: this.chromeWin = this.chromeDoc.defaultView; michael@0: this._inspector = inspector; michael@0: michael@0: this.layoutHelpers = new LayoutHelpers(this.win); michael@0: this.chromeLayoutHelper = new LayoutHelpers(this.chromeWin); michael@0: michael@0: this.transitionDisabler = null; michael@0: this.pageEventsMuter = null; michael@0: this._update = this._update.bind(this); michael@0: this.handleEvent = this.handleEvent.bind(this); michael@0: this.currentNode = null; michael@0: michael@0: EventEmitter.decorate(this); michael@0: this._initMarkup(); michael@0: } michael@0: michael@0: BoxModelHighlighter.prototype = { michael@0: get zoom() { michael@0: return this.win.QueryInterface(Ci.nsIInterfaceRequestor) michael@0: .getInterface(Ci.nsIDOMWindowUtils).fullZoom; michael@0: }, michael@0: michael@0: _initMarkup: function() { michael@0: let stack = this.browser.parentNode; michael@0: michael@0: this._highlighterContainer = this.chromeDoc.createElement("stack"); michael@0: this._highlighterContainer.className = "highlighter-container"; michael@0: michael@0: this._svgRoot = this._createSVGNode("root", "svg", this._highlighterContainer); michael@0: michael@0: // Set the SVG canvas height to 0 to stop content jumping around on small michael@0: // screens. michael@0: this._svgRoot.setAttribute("height", "0"); michael@0: michael@0: this._boxModelContainer = this._createSVGNode("container", "g", this._svgRoot); michael@0: michael@0: this._boxModelNodes = { michael@0: margin: this._createSVGNode("margin", "polygon", this._boxModelContainer), michael@0: border: this._createSVGNode("border", "polygon", this._boxModelContainer), michael@0: padding: this._createSVGNode("padding", "polygon", this._boxModelContainer), michael@0: content: this._createSVGNode("content", "polygon", this._boxModelContainer) michael@0: }; michael@0: michael@0: this._guideNodes = { michael@0: top: this._createSVGNode("guide-top", "line", this._svgRoot), michael@0: right: this._createSVGNode("guide-right", "line", this._svgRoot), michael@0: bottom: this._createSVGNode("guide-bottom", "line", this._svgRoot), michael@0: left: this._createSVGNode("guide-left", "line", this._svgRoot) michael@0: }; michael@0: michael@0: this._guideNodes.top.setAttribute("stroke-width", GUIDE_STROKE_WIDTH); michael@0: this._guideNodes.right.setAttribute("stroke-width", GUIDE_STROKE_WIDTH); michael@0: this._guideNodes.bottom.setAttribute("stroke-width", GUIDE_STROKE_WIDTH); michael@0: this._guideNodes.left.setAttribute("stroke-width", GUIDE_STROKE_WIDTH); michael@0: michael@0: this._highlighterContainer.appendChild(this._svgRoot); michael@0: michael@0: let infobarContainer = this.chromeDoc.createElement("box"); michael@0: infobarContainer.className = "highlighter-nodeinfobar-container"; michael@0: this._highlighterContainer.appendChild(infobarContainer); michael@0: michael@0: // Insert the highlighter right after the browser michael@0: stack.insertBefore(this._highlighterContainer, stack.childNodes[1]); michael@0: michael@0: // Building the infobar michael@0: let infobarPositioner = this.chromeDoc.createElement("box"); michael@0: infobarPositioner.className = "highlighter-nodeinfobar-positioner"; michael@0: infobarPositioner.setAttribute("position", "top"); michael@0: infobarPositioner.setAttribute("disabled", "true"); michael@0: michael@0: let nodeInfobar = this.chromeDoc.createElement("hbox"); michael@0: nodeInfobar.className = "highlighter-nodeinfobar"; michael@0: michael@0: let arrowBoxTop = this.chromeDoc.createElement("box"); michael@0: arrowBoxTop.className = "highlighter-nodeinfobar-arrow highlighter-nodeinfobar-arrow-top"; michael@0: michael@0: let arrowBoxBottom = this.chromeDoc.createElement("box"); michael@0: arrowBoxBottom.className = "highlighter-nodeinfobar-arrow highlighter-nodeinfobar-arrow-bottom"; michael@0: michael@0: let tagNameLabel = this.chromeDoc.createElementNS(XHTML_NS, "span"); michael@0: tagNameLabel.className = "highlighter-nodeinfobar-tagname"; michael@0: michael@0: let idLabel = this.chromeDoc.createElementNS(XHTML_NS, "span"); michael@0: idLabel.className = "highlighter-nodeinfobar-id"; michael@0: michael@0: let classesBox = this.chromeDoc.createElementNS(XHTML_NS, "span"); michael@0: classesBox.className = "highlighter-nodeinfobar-classes"; michael@0: michael@0: let pseudoClassesBox = this.chromeDoc.createElementNS(XHTML_NS, "span"); michael@0: pseudoClassesBox.className = "highlighter-nodeinfobar-pseudo-classes"; michael@0: michael@0: // Add some content to force a better boundingClientRect michael@0: pseudoClassesBox.textContent = " "; michael@0: michael@0: // michael@0: let texthbox = this.chromeDoc.createElement("hbox"); michael@0: texthbox.className = "highlighter-nodeinfobar-text"; michael@0: texthbox.setAttribute("align", "center"); michael@0: texthbox.setAttribute("flex", "1"); michael@0: michael@0: texthbox.appendChild(tagNameLabel); michael@0: texthbox.appendChild(idLabel); michael@0: texthbox.appendChild(classesBox); michael@0: texthbox.appendChild(pseudoClassesBox); michael@0: michael@0: nodeInfobar.appendChild(texthbox); michael@0: michael@0: infobarPositioner.appendChild(arrowBoxTop); michael@0: infobarPositioner.appendChild(nodeInfobar); michael@0: infobarPositioner.appendChild(arrowBoxBottom); michael@0: michael@0: infobarContainer.appendChild(infobarPositioner); michael@0: michael@0: let barHeight = infobarPositioner.getBoundingClientRect().height; michael@0: michael@0: this.nodeInfo = { michael@0: tagNameLabel: tagNameLabel, michael@0: idLabel: idLabel, michael@0: classesBox: classesBox, michael@0: pseudoClassesBox: pseudoClassesBox, michael@0: positioner: infobarPositioner, michael@0: barHeight: barHeight, michael@0: }; michael@0: }, michael@0: michael@0: _createSVGNode: function(classPostfix, nodeType, parent) { michael@0: let node = this.chromeDoc.createElementNS(SVG_NS, nodeType); michael@0: node.setAttribute("class", "box-model-" + classPostfix); michael@0: michael@0: parent.appendChild(node); michael@0: michael@0: return node; michael@0: }, michael@0: michael@0: /** michael@0: * Destroy the nodes. Remove listeners. michael@0: */ michael@0: destroy: function() { michael@0: this.hide(); michael@0: michael@0: this.chromeWin.clearTimeout(this.transitionDisabler); michael@0: this.chromeWin.clearTimeout(this.pageEventsMuter); michael@0: michael@0: this.nodeInfo = null; michael@0: michael@0: this._highlighterContainer.remove(); michael@0: this._highlighterContainer = null; michael@0: michael@0: this.rect = null; michael@0: this.win = null; michael@0: this.browser = null; michael@0: this.chromeDoc = null; michael@0: this.chromeWin = null; michael@0: this.currentNode = null; michael@0: }, michael@0: michael@0: /** michael@0: * Show the highlighter on a given node michael@0: * michael@0: * @param {DOMNode} node michael@0: * @param {Object} options michael@0: * Object used for passing options michael@0: */ michael@0: show: function(node, options={}) { michael@0: this.currentNode = node; michael@0: michael@0: this._showInfobar(); michael@0: this._detachPageListeners(); michael@0: this._attachPageListeners(); michael@0: this._update(); michael@0: this._trackMutations(); michael@0: }, michael@0: michael@0: _trackMutations: function() { michael@0: if (this.currentNode) { michael@0: let win = this.currentNode.ownerDocument.defaultView; michael@0: this.currentNodeObserver = new win.MutationObserver(() => { michael@0: this._update(); michael@0: }); michael@0: this.currentNodeObserver.observe(this.currentNode, {attributes: true}); michael@0: } michael@0: }, michael@0: michael@0: _untrackMutations: function() { michael@0: if (this.currentNode) { michael@0: if (this.currentNodeObserver) { michael@0: // The following may fail with a "can't access dead object" exception michael@0: // when the actor is being destroyed michael@0: try { michael@0: this.currentNodeObserver.disconnect(); michael@0: } catch (e) {} michael@0: this.currentNodeObserver = null; michael@0: } michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Update the highlighter on the current highlighted node (the one that was michael@0: * passed as an argument to show(node)). michael@0: * Should be called whenever node size or attributes change michael@0: * @param {Object} options michael@0: * Object used for passing options. Valid options are: michael@0: * - box: "content", "padding", "border" or "margin." This specifies michael@0: * the box that the guides should outline. Default is content. michael@0: */ michael@0: _update: function(options={}) { michael@0: if (this.currentNode) { michael@0: if (this._highlightBoxModel(options)) { michael@0: this._showInfobar(); michael@0: } else { michael@0: // Nothing to highlight (0px rectangle like a