summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/highlighters/utils/markup.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/highlighters/utils/markup.js')
-rw-r--r--devtools/server/actors/highlighters/utils/markup.js609
1 files changed, 609 insertions, 0 deletions
diff --git a/devtools/server/actors/highlighters/utils/markup.js b/devtools/server/actors/highlighters/utils/markup.js
new file mode 100644
index 000000000..8750014bc
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/markup.js
@@ -0,0 +1,609 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Cc, Ci, Cu } = require("chrome");
+const { getCurrentZoom,
+ getRootBindingParent } = require("devtools/shared/layout/utils");
+const { on, emit } = require("sdk/event/core");
+
+const lazyContainer = {};
+
+loader.lazyRequireGetter(lazyContainer, "CssLogic",
+ "devtools/server/css-logic", true);
+exports.getComputedStyle = (node) =>
+ lazyContainer.CssLogic.getComputedStyle(node);
+
+exports.getBindingElementAndPseudo = (node) =>
+ lazyContainer.CssLogic.getBindingElementAndPseudo(node);
+
+loader.lazyGetter(lazyContainer, "DOMUtils", () =>
+ Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils));
+exports.hasPseudoClassLock = (...args) =>
+ lazyContainer.DOMUtils.hasPseudoClassLock(...args);
+
+exports.addPseudoClassLock = (...args) =>
+ lazyContainer.DOMUtils.addPseudoClassLock(...args);
+
+exports.removePseudoClassLock = (...args) =>
+ lazyContainer.DOMUtils.removePseudoClassLock(...args);
+
+exports.getCSSStyleRules = (...args) =>
+ lazyContainer.DOMUtils.getCSSStyleRules(...args);
+
+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://devtools/server/actors/" +
+ "highlighters.css";
+// How high is the infobar (px).
+const INFOBAR_HEIGHT = 34;
+// What's the size of the infobar arrow (px).
+const INFOBAR_ARROW_SIZE = 9;
+
+const _tokens = Symbol("classList/tokens");
+
+/**
+ * Shims the element's `classList` for anonymous content elements; used
+ * internally by `CanvasFrameAnonymousContentHelper.getElement()` method.
+ */
+function ClassList(className) {
+ let trimmed = (className || "").trim();
+ this[_tokens] = trimmed ? trimmed.split(/\s+/) : [];
+}
+
+ClassList.prototype = {
+ item(index) {
+ return this[_tokens][index];
+ },
+ contains(token) {
+ return this[_tokens].includes(token);
+ },
+ add(token) {
+ if (!this.contains(token)) {
+ this[_tokens].push(token);
+ }
+ emit(this, "update");
+ },
+ remove(token) {
+ let index = this[_tokens].indexOf(token);
+
+ if (index > -1) {
+ this[_tokens].splice(index, 1);
+ }
+ emit(this, "update");
+ },
+ toggle(token) {
+ if (this.contains(token)) {
+ this.remove(token);
+ } else {
+ this.add(token);
+ }
+ },
+ get length() {
+ return this[_tokens].length;
+ },
+ [Symbol.iterator]: function* () {
+ for (let i = 0; i < this.tokens.length; i++) {
+ yield this[_tokens][i];
+ }
+ },
+ toString() {
+ return this[_tokens].join(" ");
+ }
+};
+
+/**
+ * Is this content window a XUL window?
+ * @param {Window} window
+ * @return {Boolean}
+ */
+function isXUL(window) {
+ return window.document.documentElement.namespaceURI === XUL_NS;
+}
+exports.isXUL = isXUL;
+
+/**
+ * Inject a helper stylesheet in the window.
+ */
+var installedHelperSheets = new WeakMap();
+
+function installHelperSheet(win, source, type = "agent") {
+ if (installedHelperSheets.has(win.document)) {
+ return;
+ }
+ let {Style} = require("sdk/stylesheet/style");
+ let {attach} = require("sdk/content/mod");
+ let style = Style({source, type});
+ attach(style, win);
+ installedHelperSheets.set(win.document, style);
+}
+exports.installHelperSheet = installHelperSheet;
+
+/**
+ * Returns true if a DOM node is "valid", where "valid" means that the node isn't a dead
+ * object wrapper, is still attached to a document, and is of a given type.
+ * @param {DOMNode} node
+ * @param {Number} nodeType Optional, defaults to ELEMENT_NODE
+ * @return {Boolean}
+ */
+function isNodeValid(node, nodeType = Ci.nsIDOMNode.ELEMENT_NODE) {
+ // Is it still alive?
+ if (!node || Cu.isDeadWrapper(node)) {
+ return false;
+ }
+
+ // Is it of the right type?
+ if (node.nodeType !== nodeType) {
+ return false;
+ }
+
+ // Is its document accessible?
+ let doc = node.ownerDocument;
+ if (!doc || !doc.defaultView) {
+ return false;
+ }
+
+ // Is the node connected to the document? Using getBindingParent adds
+ // support for anonymous elements generated by a node in the document.
+ let bindingParent = getRootBindingParent(node);
+ if (!doc.documentElement.contains(bindingParent)) {
+ return false;
+ }
+
+ return true;
+}
+exports.isNodeValid = isNodeValid;
+
+/**
+ * Helper function that creates SVG DOM nodes.
+ * @param {Window} This window's document will be used to create the element
+ * @param {Object} Options for the node include:
+ * - nodeType: the type of node, defaults to "box".
+ * - attributes: a {name:value} object to be used as attributes for the node.
+ * - prefix: a string that will be used to prefix the values of the id and class
+ * attributes.
+ * - parent: if provided, the newly created element will be appended to this
+ * node.
+ */
+function createSVGNode(win, options) {
+ if (!options.nodeType) {
+ options.nodeType = "box";
+ }
+ options.namespace = SVG_NS;
+ return createNode(win, options);
+}
+exports.createSVGNode = createSVGNode;
+
+/**
+ * Helper function that creates DOM nodes.
+ * @param {Window} This window's document will be used to create the element
+ * @param {Object} Options for the node include:
+ * - nodeType: the type of node, defaults to "div".
+ * - namespace: if passed, doc.createElementNS will be used instead of
+ * doc.creatElement.
+ * - attributes: a {name:value} object to be used as attributes for the node.
+ * - prefix: a string that will be used to prefix the values of the id and class
+ * attributes.
+ * - parent: if provided, the newly created element will be appended to this
+ * node.
+ */
+function createNode(win, options) {
+ let type = options.nodeType || "div";
+
+ let node;
+ if (options.namespace) {
+ node = win.document.createElementNS(options.namespace, type);
+ } else {
+ node = win.document.createElement(type);
+ }
+
+ for (let name in options.attributes || {}) {
+ let value = options.attributes[name];
+ if (options.prefix && (name === "class" || name === "id")) {
+ value = options.prefix + value;
+ }
+ node.setAttribute(name, value);
+ }
+
+ if (options.parent) {
+ options.parent.appendChild(node);
+ }
+
+ return node;
+}
+exports.createNode = createNode;
+
+/**
+ * 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);
+
+ // Only try to create the highlighter when the document is loaded,
+ // otherwise, wait for the navigate event to fire.
+ let doc = this.highlighterEnv.document;
+ if (doc.documentElement && doc.readyState != "uninitialized") {
+ this._insert();
+ }
+
+ this._onNavigate = this._onNavigate.bind(this);
+ this.highlighterEnv.on("navigate", this._onNavigate);
+
+ this.listeners = new Map();
+}
+
+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 () {
+ let doc = this.highlighterEnv.document;
+ // Insert the content node only if the document:
+ // * is loaded (navigate event will fire once it is),
+ // * still exists,
+ // * isn't in XUL.
+ if (doc.readyState == "uninitialized" ||
+ !doc.documentElement ||
+ isXUL(this.highlighterEnv.window)) {
+ return;
+ }
+
+ // For now highlighters.css is injected in content as a ua sheet because
+ // <style scoped> doesn't work inside anonymous content (see bug 1086532).
+ // If it did, highlighters.css would be injected as an anonymous content
+ // node using CanvasFrameAnonymousContentHelper instead.
+ installHelperSheet(this.highlighterEnv.window,
+ "@import url('" + STYLESHEET_URI + "');");
+ let node = this.nodeBuilder();
+
+ // It was stated that hidden documents don't accept
+ // `insertAnonymousContent` calls yet. That doesn't seems the case anymore,
+ // at least on desktop. Therefore, removing the code that was dealing with
+ // that scenario, fixes when we're adding anonymous content in a tab that
+ // is not the active one (see bug 1260043 and bug 1260044)
+ this._content = doc.insertAnonymousContent(node);
+ },
+
+ _onNavigate: function (e, {isTopLevel}) {
+ if (isTopLevel) {
+ this._removeAllListeners();
+ this._insert();
+ this.anonymousContentDocument = this.highlighterEnv.document;
+ }
+ },
+
+ getTextContentForElement: function (id) {
+ if (!this.content) {
+ return null;
+ }
+ return this.content.getTextContentForElement(id);
+ },
+
+ setTextContentForElement: function (id, text) {
+ if (this.content) {
+ this.content.setTextContentForElement(id, text);
+ }
+ },
+
+ setAttributeForElement: function (id, name, value) {
+ if (this.content) {
+ this.content.setAttributeForElement(id, name, value);
+ }
+ },
+
+ getAttributeForElement: function (id, name) {
+ if (!this.content) {
+ return null;
+ }
+ return this.content.getAttributeForElement(id, name);
+ },
+
+ removeAttributeForElement: function (id, name) {
+ if (this.content) {
+ this.content.removeAttributeForElement(id, name);
+ }
+ },
+
+ hasAttributeForElement: function (id, name) {
+ return typeof this.getAttributeForElement(id, name) === "string";
+ },
+
+ getCanvasContext: function (id, type = "2d") {
+ return this.content ? this.content.getCanvasContext(id, type) : null;
+ },
+
+ /**
+ * Add an event listener to one of the elements inserted in the canvasFrame
+ * native anonymous container.
+ * Like other methods in this helper, this requires the ID of the element to
+ * be passed in.
+ *
+ * Note that if the content page navigates, the event listeners won't be
+ * added again.
+ *
+ * Also note that unlike traditional DOM events, the events handled by
+ * listeners added here will propagate through the document only through
+ * bubbling phase, so the useCapture parameter isn't supported.
+ * It is possible however to call e.stopPropagation() to stop the bubbling.
+ *
+ * IMPORTANT: the chrome-only canvasFrame insertion API takes great care of
+ * not leaking references to inserted elements to chrome JS code. That's
+ * because otherwise, chrome JS code could freely modify native anon elements
+ * inside the canvasFrame and probably change things that are assumed not to
+ * change by the C++ code managing this frame.
+ * See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API
+ * Unfortunately, the inserted nodes are still available via
+ * event.originalTarget, and that's what the event handler here uses to check
+ * that the event actually occured on the right element, but that also means
+ * consumers of this code would be able to access the inserted elements.
+ * Therefore, the originalTarget property will be nullified before the event
+ * is passed to your handler.
+ *
+ * IMPL DETAIL: A single event listener is added per event types only, at
+ * browser level and if the event originalTarget is found to have the provided
+ * ID, the callback is executed (and then IDs of parent nodes of the
+ * originalTarget are checked too).
+ *
+ * @param {String} id
+ * @param {String} type
+ * @param {Function} handler
+ */
+ addEventListenerForElement: function (id, type, handler) {
+ if (typeof id !== "string") {
+ throw new Error("Expected a string ID in addEventListenerForElement but" +
+ " got: " + id);
+ }
+
+ // If no one is listening for this type of event yet, add one listener.
+ if (!this.listeners.has(type)) {
+ let target = this.highlighterEnv.pageListenerTarget;
+ target.addEventListener(type, this, true);
+ // Each type entry in the map is a map of ids:handlers.
+ this.listeners.set(type, new Map());
+ }
+
+ let listeners = this.listeners.get(type);
+ listeners.set(id, handler);
+ },
+
+ /**
+ * Remove an event listener from one of the elements inserted in the
+ * canvasFrame native anonymous container.
+ * @param {String} id
+ * @param {String} type
+ */
+ removeEventListenerForElement: function (id, type) {
+ let listeners = this.listeners.get(type);
+ if (!listeners) {
+ return;
+ }
+ listeners.delete(id);
+
+ // If no one is listening for event type anymore, remove the listener.
+ if (!this.listeners.has(type)) {
+ let target = this.highlighterEnv.pageListenerTarget;
+ target.removeEventListener(type, this, true);
+ }
+ },
+
+ handleEvent: function (event) {
+ let listeners = this.listeners.get(event.type);
+ if (!listeners) {
+ return;
+ }
+
+ // Hide the originalTarget property to avoid exposing references to native
+ // anonymous elements. See addEventListenerForElement's comment.
+ let isPropagationStopped = false;
+ let eventProxy = new Proxy(event, {
+ get: (obj, name) => {
+ if (name === "originalTarget") {
+ return null;
+ } else if (name === "stopPropagation") {
+ return () => {
+ isPropagationStopped = true;
+ };
+ }
+ return obj[name];
+ }
+ });
+
+ // Start at originalTarget, bubble through ancestors and call handlers when
+ // needed.
+ let node = event.originalTarget;
+ while (node) {
+ let handler = listeners.get(node.id);
+ if (handler) {
+ handler(eventProxy, node.id);
+ if (isPropagationStopped) {
+ break;
+ }
+ }
+ node = node.parentNode;
+ }
+ },
+
+ _removeAllListeners: function () {
+ if (this.highlighterEnv) {
+ let target = this.highlighterEnv.pageListenerTarget;
+ for (let [type] of this.listeners) {
+ target.removeEventListener(type, this, true);
+ }
+ }
+ this.listeners.clear();
+ },
+
+ getElement: function (id) {
+ let classList = new ClassList(this.getAttributeForElement(id, "class"));
+
+ on(classList, "update", () => {
+ this.setAttributeForElement(id, "class", classList.toString());
+ });
+
+ return {
+ getTextContent: () => this.getTextContentForElement(id),
+ setTextContent: text => this.setTextContentForElement(id, text),
+ setAttribute: (name, val) => this.setAttributeForElement(id, name, val),
+ getAttribute: name => this.getAttributeForElement(id, name),
+ removeAttribute: name => this.removeAttributeForElement(id, name),
+ hasAttribute: name => this.hasAttributeForElement(id, name),
+ getCanvasContext: type => this.getCanvasContext(id, type),
+ addEventListener: (type, handler) => {
+ return this.addEventListenerForElement(id, type, handler);
+ },
+ removeEventListener: (type, handler) => {
+ return this.removeEventListenerForElement(id, type, handler);
+ },
+ classList
+ };
+ },
+
+ get content() {
+ if (!this._content || Cu.isDeadWrapper(this._content)) {
+ return null;
+ }
+ return this._content;
+ },
+
+ /**
+ * The canvasFrame anonymous content container gets zoomed in/out with the
+ * page. If this is unwanted, i.e. if you want the inserted element to remain
+ * unzoomed, then this method can be used.
+ *
+ * Consumers of the CanvasFrameAnonymousContentHelper should call this method,
+ * it isn't executed automatically. Typically, AutoRefreshHighlighter can call
+ * it when _update is executed.
+ *
+ * The matching element will be scaled down or up by 1/zoomLevel (using css
+ * transform) to cancel the current zoom. The element's width and height
+ * styles will also be set according to the scale. Finally, the element's
+ * position will be set as absolute.
+ *
+ * Note that if the matching element already has an inline style attribute, it
+ * *won't* be preserved.
+ *
+ * @param {DOMNode} node This node is used to determine which container window
+ * should be used to read the current zoom value.
+ * @param {String} id The ID of the root element inserted with this API.
+ */
+ scaleRootElement: function (node, id) {
+ let zoom = getCurrentZoom(node);
+ let value = "position:absolute;width:100%;height:100%;";
+
+ if (zoom !== 1) {
+ value = "position:absolute;";
+ value += "transform-origin:top left;transform:scale(" + (1 / zoom) + ");";
+ value += "width:" + (100 * zoom) + "%;height:" + (100 * zoom) + "%;";
+ }
+
+ this.setAttributeForElement(id, "style", value);
+ }
+};
+exports.CanvasFrameAnonymousContentHelper = CanvasFrameAnonymousContentHelper;
+
+/**
+ * Move the infobar to the right place in the highlighter. This helper method is utilized
+ * in both css-grid.js and box-model.js to help position the infobar in an appropriate
+ * space over the highlighted node element or grid area. The infobar is used to display
+ * relevant information about the highlighted item (ex, node or grid name and dimensions).
+ *
+ * This method will first try to position the infobar to top or bottom of the container
+ * such that it has enough space for the height of the infobar. Afterwards, it will try
+ * to horizontally center align with the container element if possible.
+ *
+ * @param {DOMNode} container
+ * The container element which will be used to position the infobar.
+ * @param {Object} bounds
+ * The content bounds of the container element.
+ * @param {Window} win
+ * The window object.
+ */
+function moveInfobar(container, bounds, win) {
+ let winHeight = win.innerHeight * getCurrentZoom(win);
+ let winWidth = win.innerWidth * getCurrentZoom(win);
+ let winScrollY = win.scrollY;
+
+ // Ensure that containerBottom and containerTop are at least zero to avoid
+ // showing tooltips outside the viewport.
+ let containerBottom = Math.max(0, bounds.bottom) + INFOBAR_ARROW_SIZE;
+ let containerTop = Math.min(winHeight, bounds.top);
+
+ // Can the bar be above the node?
+ let top;
+ if (containerTop < INFOBAR_HEIGHT) {
+ // No. Can we move the bar under the node?
+ if (containerBottom + INFOBAR_HEIGHT > winHeight) {
+ // No. Let's move it inside. Can we show it at the top of the element?
+ if (containerTop < winScrollY) {
+ // No. Window is scrolled past the top of the element.
+ top = 0;
+ } else {
+ // Yes. Show it at the top of the element
+ top = containerTop;
+ }
+ container.setAttribute("position", "overlap");
+ } else {
+ // Yes. Let's move it under the node.
+ top = containerBottom;
+ container.setAttribute("position", "bottom");
+ }
+ } else {
+ // Yes. Let's move it on top of the node.
+ top = containerTop - INFOBAR_HEIGHT;
+ container.setAttribute("position", "top");
+ }
+
+ // Align the bar with the box's center if possible.
+ let left = bounds.right - bounds.width / 2;
+ // Make sure the while infobar is visible.
+ let buffer = 100;
+ if (left < buffer) {
+ left = buffer;
+ container.setAttribute("hide-arrow", "true");
+ } else if (left > winWidth - buffer) {
+ left = winWidth - buffer;
+ container.setAttribute("hide-arrow", "true");
+ } else {
+ container.removeAttribute("hide-arrow");
+ }
+
+ let style = "top:" + top + "px;left:" + left + "px;";
+ container.setAttribute("style", style);
+}
+exports.moveInfobar = moveInfobar;