/* 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/. */ /* globals LayoutHelpers, DOMUtils, CssLogic, setIgnoreLayoutChanges */ "use strict"; const {Cu, Cc, Ci} = require("chrome"); const protocol = require("devtools/server/protocol"); const {Arg, Option, method, RetVal} = protocol; const events = require("sdk/event/core"); const Heritage = require("sdk/core/heritage"); const EventEmitter = require("devtools/toolkit/event-emitter"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); loader.lazyRequireGetter(this, "CssLogic", "devtools/styleinspector/css-logic", true); loader.lazyRequireGetter(this, "setIgnoreLayoutChanges", "devtools/server/actors/layout", true); loader.lazyGetter(this, "DOMUtils", function() { return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); }); loader.lazyImporter(this, "LayoutHelpers", "resource://gre/modules/devtools/LayoutHelpers.jsm"); // FIXME: add ":visited" and ":link" after bug 713106 is fixed const PSEUDO_CLASSES = [":hover", ":active", ":focus"]; // Note that the order of items in this array is important because it is used // for drawing the BoxModelHighlighter's path elements correctly. const BOX_MODEL_REGIONS = ["margin", "border", "padding", "content"]; const BOX_MODEL_SIDES = ["top", "right", "bottom", "left"]; const SVG_NS = "http://www.w3.org/2000/svg"; const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; const STYLESHEET_URI = "resource://gre/modules/devtools/server/actors/" + "highlighter.css"; const HIGHLIGHTER_PICKED_TIMER = 1000; // How high is the nodeinfobar (px). const NODE_INFOBAR_HEIGHT = 34; // What's the size of the nodeinfobar arrow (px). const NODE_INFOBAR_ARROW_SIZE = 9; // Width of boxmodelhighlighter guides const GUIDE_STROKE_WIDTH = 1; // The minimum distance a line should be before it has an arrow marker-end const ARROW_LINE_MIN_DISTANCE = 10; // How many maximum nodes can be highlighted at the same time by the // SelectorHighlighter const MAX_HIGHLIGHTED_ELEMENTS = 100; // SimpleOutlineHighlighter's stylesheet const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-devtools-highlighted"; const SIMPLE_OUTLINE_SHEET = ".__fx-devtools-hide-shortcut__ {" + " visibility: hidden !important" + "}" + HIGHLIGHTED_PSEUDO_CLASS + " {" + " outline: 2px dashed #F06!important;" + " outline-offset: -2px!important;" + "}"; const GEOMETRY_LABEL_SIZE = 6; // Maximum size, in pixel, for the horizontal ruler and vertical ruler // used by RulersHighlighter const RULERS_MAX_X_AXIS = 10000; const RULERS_MAX_Y_AXIS = 15000; // Number of steps after we add a graduation, marker and text in // RulersHighliter; currently the unit is in pixel. const RULERS_GRADUATION_STEP = 5; const RULERS_MARKER_STEP = 50; const RULERS_TEXT_STEP = 100; /** * The registration mechanism for highlighters provide a quick way to * have modular highlighters, instead of a hard coded list. * It allow us to split highlighers in sub modules, and add them dynamically * using add-on (useful for 3rd party developers, or prototyping) * * Note that currently, highlighters added using add-ons, can only work on * Firefox desktop, or Fennec if the same add-on is installed in both. */ const highlighterTypes = new Map(); /** * Returns `true` if a highlighter for the given `typeName` is registered, * `false` otherwise. */ const isTypeRegistered = (typeName) => highlighterTypes.has(typeName); exports.isTypeRegistered = isTypeRegistered; /** * Registers a given constructor as highlighter, for the `typeName` given. * If no `typeName` is provided, is looking for a `typeName` property in * the prototype's constructor. */ const register = (constructor, typeName=constructor.prototype.typeName) => { if (!typeName) { throw Error("No type's name found, or provided."); } if (highlighterTypes.has(typeName)) { throw Error(`${typeName} is already registered.`); } highlighterTypes.set(typeName, constructor); }; exports.register = register; /** * The Highlighter is the server-side entry points for any tool that wishes to * highlight elements in some way in the content document. * * A little bit of vocabulary: * - HighlighterActor classes are the actors that can be used from * the client. They do very little else than instantiate a given * Highlighter and use it to highlight elements. * - Highlighter classes aren't actors, they're just JS classes that * know how to create and attach the actual highlighter elements on top of the * content * * The most used highlighter actor is the HighlighterActor which can be * conveniently retrieved via the InspectorActor's 'getHighlighter' method. * The InspectorActor will always return the same instance of * HighlighterActor if asked several times and this instance is used in the * toolbox to highlighter elements's box-model from the markup-view, * layout-view, console, debugger, ... as well as select elements with the * pointer (pick). * * Other types of highlighter actors exist and can be accessed via the * InspectorActor's 'getHighlighterByType' method. */ /** * The HighlighterActor class */ let HighlighterActor = exports.HighlighterActor = protocol.ActorClass({ typeName: "highlighter", initialize: function(inspector, autohide) { protocol.Actor.prototype.initialize.call(this, null); this._autohide = autohide; this._inspector = inspector; this._walker = this._inspector.walker; this._tabActor = this._inspector.tabActor; this._highlighterEnv = new HighlighterEnvironment(); this._highlighterEnv.initFromTabActor(this._tabActor); this._highlighterReady = this._highlighterReady.bind(this); this._highlighterHidden = this._highlighterHidden.bind(this); this._onNavigate = this._onNavigate.bind(this); this._layoutHelpers = new LayoutHelpers(this._tabActor.window); this._createHighlighter(); // Listen to navigation events to switch from the BoxModelHighlighter to the // SimpleOutlineHighlighter, and back, if the top level window changes. events.on(this._tabActor, "navigate", this._onNavigate); }, get conn() { return this._inspector && this._inspector.conn; }, _createHighlighter: function() { this._isPreviousWindowXUL = isXUL(this._tabActor.window); if (!this._isPreviousWindowXUL) { this._highlighter = new BoxModelHighlighter(this._highlighterEnv, this._inspector); this._highlighter.on("ready", this._highlighterReady); this._highlighter.on("hide", this._highlighterHidden); } else { this._highlighter = new SimpleOutlineHighlighter(this._highlighterEnv); } }, _destroyHighlighter: function() { if (this._highlighter) { if (!this._isPreviousWindowXUL) { this._highlighter.off("ready", this._highlighterReady); this._highlighter.off("hide", this._highlighterHidden); } this._highlighter.destroy(); this._highlighter = null; } }, _onNavigate: function({isTopLevel}) { // Skip navigation events for non top-level windows, or if the document // doesn't exist anymore. if (!isTopLevel || !this._tabActor.window.document.documentElement) { return; } // Only rebuild the highlighter if the window type changed. if (isXUL(this._tabActor.window) !== this._isPreviousWindowXUL) { this._destroyHighlighter(); this._createHighlighter(); } }, destroy: function() { protocol.Actor.prototype.destroy.call(this); this._destroyHighlighter(); events.off(this._tabActor, "navigate", this._onNavigate); this._highlighterEnv.destroy(); this._highlighterEnv = null; this._autohide = null; this._inspector = null; this._walker = null; this._tabActor = null; this._layoutHelpers = null; }, /** * Display the box model highlighting on a given NodeActor. * There is only one instance of the box model highlighter, so calling this * method several times won't display several highlighters, it will just move * the highlighter instance to these nodes. * * @param NodeActor The node to be highlighted * @param Options See the request part for existing options. Note that not * all options may be supported by all types of highlighters. */ showBoxModel: method(function(node, options={}) { if (node && isNodeValid(node.rawNode)) { this._highlighter.show(node.rawNode, options); } else { this._highlighter.hide(); } }, { request: { node: Arg(0, "domnode"), region: Option(1), hideInfoBar: Option(1), hideGuides: Option(1), showOnly: Option(1), onlyRegionArea: Option(1) } }), /** * Hide the box model highlighting if it was shown before */ hideBoxModel: method(function() { this._highlighter.hide(); }, { request: {} }), /** * Pick a node on click, and highlight hovered nodes in the process. * * This method doesn't respond anything interesting, however, it starts * mousemove, and click listeners on the content document to fire * events and let connected clients know when nodes are hovered over or * clicked. * * Once a node is picked, events will cease, and listeners will be removed. */ _isPicking: false, _hoveredNode: null, _currentNode: null, pick: method(function() { if (this._isPicking) { return null; } this._isPicking = true; this._preventContentEvent = event => { event.stopPropagation(); event.preventDefault(); }; this._onPick = event => { this._preventContentEvent(event); this._stopPickerListeners(); this._isPicking = false; if (this._autohide) { this._tabActor.window.setTimeout(() => { this._highlighter.hide(); }, HIGHLIGHTER_PICKED_TIMER); } if (!this._currentNode) { this._currentNode = this._findAndAttachElement(event); } events.emit(this._walker, "picker-node-picked", this._currentNode); }; this._onHovered = event => { this._preventContentEvent(event); this._currentNode = this._findAndAttachElement(event); if (this._hoveredNode !== this._currentNode.node) { this._highlighter.show(this._currentNode.node.rawNode); events.emit(this._walker, "picker-node-hovered", this._currentNode); this._hoveredNode = this._currentNode.node; } }; this._onKey = event => { if (!this._currentNode || !this._isPicking) { return; } this._preventContentEvent(event); let currentNode = this._currentNode.node.rawNode; /** * KEY: Action/scope * LEFT_KEY: wider or parent * RIGHT_KEY: narrower or child * ENTER/CARRIAGE_RETURN: Picks currentNode * ESC: Cancels picker, picks currentNode */ switch (event.keyCode) { // Wider. case Ci.nsIDOMKeyEvent.DOM_VK_LEFT: if (!currentNode.parentElement) { return; } currentNode = currentNode.parentElement; break; // Narrower. case Ci.nsIDOMKeyEvent.DOM_VK_RIGHT: if (!currentNode.children.length) { return; } // Set firstElementChild by default let child = currentNode.firstElementChild; // If currentNode is parent of hoveredNode, then // previously selected childNode is set let hoveredNode = this._hoveredNode.rawNode; for (let sibling of currentNode.children) { if (sibling.contains(hoveredNode) || sibling === hoveredNode) { child = sibling; } } currentNode = child; break; // Select the element. case Ci.nsIDOMKeyEvent.DOM_VK_RETURN: this._onPick(event); return; // Cancel pick mode. case Ci.nsIDOMKeyEvent.DOM_VK_ESCAPE: this.cancelPick(); events.emit(this._walker, "picker-node-canceled"); return; default: return; } // Store currently attached element this._currentNode = this._walker.attachElement(currentNode); this._highlighter.show(this._currentNode.node.rawNode); events.emit(this._walker, "picker-node-hovered", this._currentNode); }; this._tabActor.window.focus(); this._startPickerListeners(); return null; }), _findAndAttachElement: function(event) { // originalTarget allows access to the "real" element before any retargeting // is applied, such as in the case of XBL anonymous elements. See also // https://developer.mozilla.org/docs/XBL/XBL_1.0_Reference/Anonymous_Content#Event_Flow_and_Targeting let node = event.originalTarget || event.target; return this._walker.attachElement(node); }, _startPickerListeners: function() { let target = this._highlighterEnv.pageListenerTarget; target.addEventListener("mousemove", this._onHovered, true); target.addEventListener("click", this._onPick, true); target.addEventListener("mousedown", this._preventContentEvent, true); target.addEventListener("mouseup", this._preventContentEvent, true); target.addEventListener("dblclick", this._preventContentEvent, true); target.addEventListener("keydown", this._onKey, true); target.addEventListener("keyup", this._preventContentEvent, true); }, _stopPickerListeners: function() { let target = this._highlighterEnv.pageListenerTarget; target.removeEventListener("mousemove", this._onHovered, true); target.removeEventListener("click", this._onPick, true); target.removeEventListener("mousedown", this._preventContentEvent, true); target.removeEventListener("mouseup", this._preventContentEvent, true); target.removeEventListener("dblclick", this._preventContentEvent, true); target.removeEventListener("keydown", this._onKey, true); target.removeEventListener("keyup", this._preventContentEvent, true); }, _highlighterReady: function() { events.emit(this._inspector.walker, "highlighter-ready"); }, _highlighterHidden: function() { events.emit(this._inspector.walker, "highlighter-hide"); }, cancelPick: method(function() { if (this._isPicking) { this._highlighter.hide(); this._stopPickerListeners(); this._isPicking = false; this._hoveredNode = null; } }) }); let HighlighterFront = protocol.FrontClass(HighlighterActor, {}); /** * A generic highlighter actor class that instantiate a highlighter given its * type name and allows to show/hide it. */ let CustomHighlighterActor = exports.CustomHighlighterActor = protocol.ActorClass({ typeName: "customhighlighter", /** * Create a highlighter instance given its typename * The typename must be one of HIGHLIGHTER_CLASSES and the class must * implement constructor(tabActor), show(node), hide(), destroy() */ initialize: function(inspector, typeName) { protocol.Actor.prototype.initialize.call(this, null); this._inspector = inspector; let constructor = highlighterTypes.get(typeName); if (!constructor) { let list = [...highlighterTypes.keys()]; throw new Error(`${typeName} isn't a valid highlighter class (${list})`); return; } // The assumption is that all custom highlighters need the canvasframe // container to append their elements, so if this is a XUL window, bail out. if (!isXUL(this._inspector.tabActor.window)) { this._highlighterEnv = new HighlighterEnvironment(); this._highlighterEnv.initFromTabActor(inspector.tabActor); this._highlighter = new constructor(this._highlighterEnv); } else { throw new Error("Custom " + typeName + "highlighter cannot be created in a XUL window"); return; } }, get conn() { return this._inspector && this._inspector.conn; }, destroy: function() { protocol.Actor.prototype.destroy.call(this); this.finalize(); this._inspector = null; }, /** * Show the highlighter. * This calls through to the highlighter instance's |show(node, options)| * method. * * Most custom highlighters are made to highlight DOM nodes, hence the first * NodeActor argument (NodeActor as in * toolkit/devtools/server/actor/inspector). * Note however that some highlighters use this argument merely as a context * node: the RectHighlighter for instance uses it to calculate the absolute * position of the provided rect. The SelectHighlighter uses it as a base node * to run the provided CSS selector on. * * @param {NodeActor} The node to be highlighted * @param {Object} Options for the custom highlighter * @return {Boolean} True, if the highlighter has been successfully shown * (FF41+) */ show: method(function(node, options) { if (!node || !isNodeValid(node.rawNode) || !this._highlighter) { return false; } return this._highlighter.show(node.rawNode, options); }, { request: { node: Arg(0, "domnode"), options: Arg(1, "nullable:json") }, response: { value: RetVal("nullable:boolean") } }), /** * Hide the highlighter if it was shown before */ hide: method(function() { if (this._highlighter) { this._highlighter.hide(); } }, { request: {} }), /** * Kill this actor. This method is called automatically just before the actor * is destroyed. */ finalize: method(function() { if (this._highlighterEnv) { this._highlighterEnv.destroy(); this._highlighterEnv = null; } if (this._highlighter) { this._highlighter.destroy(); this._highlighter = null; } }, { oneway: true }) }); let CustomHighlighterFront = protocol.FrontClass(CustomHighlighterActor, {}); /** * Every highlighters should insert their markup content into the document's * canvasFrame anonymous content container (see dom/webidl/Document.webidl). * * Since this container gets cleared when the document navigates, highlighters * should use this helper to have their markup content automatically re-inserted * in the new document. * * Since the markup content is inserted in the canvasFrame using * insertAnonymousContent, this means that it can be modified using the API * described in AnonymousContent.webidl. * To retrieve the AnonymousContent instance, use the content getter. * * @param {HighlighterEnv} highlighterEnv * The environemnt which windows will be used to insert the node. * @param {Function} nodeBuilder * A function that, when executed, returns a DOM node to be inserted into * the canvasFrame. */ function CanvasFrameAnonymousContentHelper(highlighterEnv, nodeBuilder) { this.highlighterEnv = highlighterEnv; this.nodeBuilder = nodeBuilder; this.anonymousContentDocument = this.highlighterEnv.document; // XXX the next line is a wallpaper for bug 1123362. this.anonymousContentGlobal = Cu.getGlobalForObject( this.anonymousContentDocument); this._insert(); this._onNavigate = this._onNavigate.bind(this); this.highlighterEnv.on("navigate", this._onNavigate); this.listeners = new Map(); } exports.CanvasFrameAnonymousContentHelper = CanvasFrameAnonymousContentHelper; CanvasFrameAnonymousContentHelper.prototype = { destroy: function() { try { let doc = this.anonymousContentDocument; doc.removeAnonymousContent(this._content); } catch (e) { // If the current window isn't the one the content was inserted into, this // will fail, but that's fine. } this.highlighterEnv.off("navigate", this._onNavigate); this.highlighterEnv = this.nodeBuilder = this._content = null; this.anonymousContentDocument = null; this.anonymousContentGlobal = null; this._removeAllListeners(); }, _insert: function() { // Insert the content node only if the page isn't in a XUL window, and if // the document still exists. if (!this.highlighterEnv.document.documentElement || isXUL(this.highlighterEnv.window)) { return; } let doc = this.highlighterEnv.document; // On B2G, for example, when connecting to keyboard just after startup, // we connect to a hidden document, which doesn't accept // insertAnonymousContent call yet. if (doc.hidden) { // In such scenario, just wait for the document to be visible // before injecting anonymous content. let onVisibilityChange = () => { doc.removeEventListener("visibilitychange", onVisibilityChange); this._insert(); }; doc.addEventListener("visibilitychange", onVisibilityChange); return; } // For now highlighter.css is injected in content as a ua sheet because //