fune/devtools/server/actors/highlighters/utils/markup.js
2016-12-23 16:45:22 +01:00

607 lines
20 KiB
JavaScript

/* 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 XHTML_NS = "http://www.w3.org/1999/xhtml";
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: the namespace to use to create the node, defaults to XHTML namespace.
* - 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 namespace = options.namespace || XHTML_NS;
let node = win.document.createElementNS(namespace, 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 window-ready event to fire.
let doc = this.highlighterEnv.document;
if (doc.documentElement && doc.readyState != "uninitialized") {
this._insert();
}
this._onWindowReady = this._onWindowReady.bind(this);
this.highlighterEnv.on("window-ready", this._onWindowReady);
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("window-ready", this._onWindowReady);
this.highlighterEnv = this.nodeBuilder = this._content = null;
this.anonymousContentDocument = null;
this.anonymousContentGlobal = null;
this._removeAllListeners();
},
_insert: function () {
let doc = this.highlighterEnv.document;
// Wait for DOMContentLoaded before injecting the anonymous content.
if (doc.readyState != "interactive" && doc.readyState != "complete") {
doc.addEventListener("DOMContentLoaded", this._insert.bind(this),
{ once: true });
return;
}
// Reject XUL documents. Check that after DOMContentLoaded as we query
// documentElement which is only available after this event.
if (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);
},
_onWindowReady: 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;