787 lines
25 KiB
JavaScript
787 lines
25 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 {
|
|
getCurrentZoom,
|
|
getWindowDimensions,
|
|
getViewportDimensions,
|
|
} = require("resource://devtools/shared/layout/utils.js");
|
|
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
|
|
|
|
const lazyContainer = {};
|
|
|
|
loader.lazyRequireGetter(
|
|
lazyContainer,
|
|
"CssLogic",
|
|
"resource://devtools/server/actors/inspector/css-logic.js",
|
|
true
|
|
);
|
|
loader.lazyRequireGetter(
|
|
this,
|
|
"isDocumentReady",
|
|
"resource://devtools/server/actors/inspector/utils.js",
|
|
true
|
|
);
|
|
|
|
exports.getComputedStyle = node =>
|
|
lazyContainer.CssLogic.getComputedStyle(node);
|
|
|
|
exports.getBindingElementAndPseudo = node =>
|
|
lazyContainer.CssLogic.getBindingElementAndPseudo(node);
|
|
|
|
exports.hasPseudoClassLock = (...args) =>
|
|
InspectorUtils.hasPseudoClassLock(...args);
|
|
|
|
exports.addPseudoClassLock = (...args) =>
|
|
InspectorUtils.addPseudoClassLock(...args);
|
|
|
|
exports.removePseudoClassLock = (...args) =>
|
|
InspectorUtils.removePseudoClassLock(...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-highlighter-styles/highlighters.css";
|
|
|
|
const _tokens = Symbol("classList/tokens");
|
|
|
|
/**
|
|
* Shims the element's `classList` for anonymous content elements; used
|
|
* internally by `CanvasFrameAnonymousContentHelper.getElement()` method.
|
|
*/
|
|
function ClassList(className) {
|
|
const 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);
|
|
}
|
|
EventEmitter.emit(this, "update");
|
|
},
|
|
remove(token) {
|
|
const index = this[_tokens].indexOf(token);
|
|
|
|
if (index > -1) {
|
|
this[_tokens].splice(index, 1);
|
|
}
|
|
EventEmitter.emit(this, "update");
|
|
},
|
|
toggle(token, force) {
|
|
// If force parameter undefined retain the toggle behavior
|
|
if (force === undefined) {
|
|
if (this.contains(token)) {
|
|
this.remove(token);
|
|
} else {
|
|
this.add(token);
|
|
}
|
|
} else if (force) {
|
|
// If force is true, enforce token addition
|
|
this.add(token);
|
|
} else {
|
|
// If force is falsy value, enforce token removal
|
|
this.remove(token);
|
|
}
|
|
},
|
|
get length() {
|
|
return this[_tokens].length;
|
|
},
|
|
*[Symbol.iterator]() {
|
|
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;
|
|
|
|
/**
|
|
* 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 = Node.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?
|
|
const doc = node.nodeType === Node.DOCUMENT_NODE ? node : node.ownerDocument;
|
|
if (!doc || !doc.defaultView) {
|
|
return false;
|
|
}
|
|
|
|
// Is the node connected to the document?
|
|
if (!node.isConnected) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
exports.isNodeValid = isNodeValid;
|
|
|
|
/**
|
|
* 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.
|
|
* @param {Object} options
|
|
* @param {Boolean} options.waitForDocumentToLoad
|
|
* Set to false to try to insert the anonymous content even if the document
|
|
* isn't loaded yet. Defaults to true.
|
|
*/
|
|
function CanvasFrameAnonymousContentHelper(
|
|
highlighterEnv,
|
|
nodeBuilder,
|
|
{ waitForDocumentToLoad = true } = {}
|
|
) {
|
|
this.highlighterEnv = highlighterEnv;
|
|
this.nodeBuilder = nodeBuilder;
|
|
this.waitForDocumentToLoad = !!waitForDocumentToLoad;
|
|
|
|
this._onWindowReady = this._onWindowReady.bind(this);
|
|
this.highlighterEnv.on("window-ready", this._onWindowReady);
|
|
|
|
this.listeners = new Map();
|
|
this.elements = new Map();
|
|
}
|
|
|
|
CanvasFrameAnonymousContentHelper.prototype = {
|
|
initialize() {
|
|
// _insert will resolve this promise once the markup is displayed
|
|
const onInitialized = new Promise(resolve => {
|
|
this._initialized = resolve;
|
|
});
|
|
// Only try to create the highlighter when the document is loaded,
|
|
// otherwise, wait for the window-ready event to fire.
|
|
const doc = this.highlighterEnv.document;
|
|
if (
|
|
doc.documentElement &&
|
|
(!this.waitForDocumentToLoad ||
|
|
isDocumentReady(doc) ||
|
|
doc.readyState !== "uninitialized")
|
|
) {
|
|
this._insert();
|
|
}
|
|
|
|
return onInitialized;
|
|
},
|
|
|
|
destroy() {
|
|
this._remove();
|
|
|
|
this.highlighterEnv.off("window-ready", this._onWindowReady);
|
|
this.highlighterEnv = this.nodeBuilder = this._content = null;
|
|
this.anonymousContentDocument = null;
|
|
this.anonymousContentWindow = null;
|
|
this.pageListenerTarget = null;
|
|
|
|
this._removeAllListeners();
|
|
this.elements.clear();
|
|
},
|
|
|
|
async _insert() {
|
|
if (this.waitForDocumentToLoad) {
|
|
await waitForContentLoaded(this.highlighterEnv.window);
|
|
}
|
|
if (!this.highlighterEnv) {
|
|
// CanvasFrameAnonymousContentHelper was already destroyed.
|
|
return;
|
|
}
|
|
|
|
// Highlighters are drawn inside the anonymous content of the
|
|
// highlighter environment document.
|
|
this.anonymousContentDocument = this.highlighterEnv.document;
|
|
this.anonymousContentWindow = this.highlighterEnv.window;
|
|
this.pageListenerTarget = this.highlighterEnv.pageListenerTarget;
|
|
|
|
// 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)
|
|
try {
|
|
// If we didn't wait for the document to load, we want to force a layout update
|
|
// to ensure the anonymous content will be rendered (see Bug 1580394).
|
|
const forceSynchronousLayoutUpdate = !this.waitForDocumentToLoad;
|
|
this._content = this.anonymousContentDocument.insertAnonymousContent(
|
|
forceSynchronousLayoutUpdate
|
|
);
|
|
} catch (e) {
|
|
// If the `insertAnonymousContent` fails throwing a `NS_ERROR_UNEXPECTED`, it means
|
|
// we don't have access to a `CustomContentContainer` yet (see bug 1365075).
|
|
// At this point, it could only happen on document's interactive state, and we
|
|
// need to wait until the `complete` state before inserting the anonymous content
|
|
// again.
|
|
if (
|
|
e.result === Cr.NS_ERROR_UNEXPECTED &&
|
|
this.anonymousContentDocument.readyState === "interactive"
|
|
) {
|
|
// The next state change will be "complete" since the current is "interactive"
|
|
await new Promise(resolve => {
|
|
this.anonymousContentDocument.addEventListener(
|
|
"readystatechange",
|
|
resolve,
|
|
{ once: true }
|
|
);
|
|
});
|
|
this._content = this.anonymousContentDocument.insertAnonymousContent();
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// Use createElementNS to make sure this is an HTML element.
|
|
// Document.createElement's behavior is different between SVG and HTML
|
|
// documents, see bug 1850007.
|
|
const link = this.anonymousContentDocument.createElementNS(
|
|
XHTML_NS,
|
|
"link"
|
|
);
|
|
link.href = STYLESHEET_URI;
|
|
link.rel = "stylesheet";
|
|
this._content.root.appendChild(link);
|
|
this._content.root.appendChild(this.nodeBuilder());
|
|
|
|
this._initialized();
|
|
},
|
|
|
|
_remove() {
|
|
try {
|
|
this.anonymousContentDocument.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.
|
|
}
|
|
},
|
|
|
|
/**
|
|
* The "window-ready" event can be triggered when:
|
|
* - a new window is created
|
|
* - a window is unfrozen from bfcache
|
|
* - when first attaching to a page
|
|
* - when swapping frame loaders (moving tabs, toggling RDM)
|
|
*/
|
|
_onWindowReady({ isTopLevel }) {
|
|
if (isTopLevel) {
|
|
this._removeAllListeners();
|
|
this.elements.clear();
|
|
this._insert();
|
|
}
|
|
},
|
|
|
|
_getNodeById(id) {
|
|
return this.content?.root.getElementById(id);
|
|
},
|
|
|
|
getBoundingClientRect(id) {
|
|
const node = this._getNodeById(id);
|
|
if (!node) {
|
|
return null;
|
|
}
|
|
return node.getBoundingClientRect();
|
|
},
|
|
|
|
getComputedStylePropertyValue(id, property) {
|
|
const node = this._getNodeById(id);
|
|
if (!node) {
|
|
return null;
|
|
}
|
|
return this.anonymousContentWindow
|
|
.getComputedStyle(node)
|
|
.getPropertyValue(property);
|
|
},
|
|
|
|
getTextContentForElement(id) {
|
|
return this._getNodeById(id)?.textContent;
|
|
},
|
|
|
|
setTextContentForElement(id, text) {
|
|
const node = this._getNodeById(id);
|
|
if (!node) {
|
|
return;
|
|
}
|
|
node.textContent = text;
|
|
},
|
|
|
|
setAttributeForElement(id, name, value) {
|
|
this._getNodeById(id)?.setAttribute(name, value);
|
|
},
|
|
|
|
getAttributeForElement(id, name) {
|
|
return this._getNodeById(id)?.getAttribute(name);
|
|
},
|
|
|
|
removeAttributeForElement(id, name) {
|
|
this._getNodeById(id)?.removeAttribute(name);
|
|
},
|
|
|
|
hasAttributeForElement(id, name) {
|
|
return typeof this.getAttributeForElement(id, name) === "string";
|
|
},
|
|
|
|
getCanvasContext(id, type = "2d") {
|
|
return this._getNodeById(id)?.getContext(type);
|
|
},
|
|
|
|
/**
|
|
* 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(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)) {
|
|
const target = this.pageListenerTarget;
|
|
target.addEventListener(type, this, true);
|
|
// Each type entry in the map is a map of ids:handlers.
|
|
this.listeners.set(type, new Map());
|
|
}
|
|
|
|
const 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(id, type) {
|
|
const 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)) {
|
|
const target = this.pageListenerTarget;
|
|
target.removeEventListener(type, this, true);
|
|
}
|
|
},
|
|
|
|
handleEvent(event) {
|
|
const 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;
|
|
const 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) {
|
|
const handler = listeners.get(node.id);
|
|
if (handler) {
|
|
handler(eventProxy, node.id);
|
|
if (isPropagationStopped) {
|
|
break;
|
|
}
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
},
|
|
|
|
_removeAllListeners() {
|
|
if (this.pageListenerTarget) {
|
|
const target = this.pageListenerTarget;
|
|
for (const [type] of this.listeners) {
|
|
target.removeEventListener(type, this, true);
|
|
}
|
|
}
|
|
this.listeners.clear();
|
|
},
|
|
|
|
getElement(id) {
|
|
if (this.elements.has(id)) {
|
|
return this.elements.get(id);
|
|
}
|
|
|
|
const classList = new ClassList(this.getAttributeForElement(id, "class"));
|
|
|
|
EventEmitter.on(classList, "update", () => {
|
|
this.setAttributeForElement(id, "class", classList.toString());
|
|
});
|
|
|
|
const element = {
|
|
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);
|
|
},
|
|
computedStyle: {
|
|
getPropertyValue: property =>
|
|
this.getComputedStylePropertyValue(id, property),
|
|
},
|
|
classList,
|
|
};
|
|
|
|
this.elements.set(id, element);
|
|
|
|
return element;
|
|
},
|
|
|
|
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(node, id) {
|
|
const boundaryWindow = this.highlighterEnv.window;
|
|
const zoom = getCurrentZoom(node);
|
|
// Hide the root element and force the reflow in order to get the proper window's
|
|
// dimensions without increasing them.
|
|
const root = this._getNodeById(id);
|
|
root.style.display = "none";
|
|
node.offsetWidth;
|
|
|
|
let { width, height } = getWindowDimensions(boundaryWindow);
|
|
let value = "";
|
|
|
|
if (zoom !== 1) {
|
|
value = `transform-origin:top left; transform:scale(${1 / zoom}); `;
|
|
width *= zoom;
|
|
height *= zoom;
|
|
}
|
|
|
|
value += `position:absolute; width:${width}px;height:${height}px; overflow:hidden;`;
|
|
root.style = value;
|
|
},
|
|
|
|
/**
|
|
* Helper function that creates SVG DOM nodes.
|
|
* @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.
|
|
*/
|
|
createSVGNode(options) {
|
|
if (!options.nodeType) {
|
|
options.nodeType = "box";
|
|
}
|
|
|
|
options.namespace = SVG_NS;
|
|
|
|
return this.createNode(options);
|
|
},
|
|
|
|
/**
|
|
* Helper function that creates DOM nodes.
|
|
* @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.
|
|
* - text: if provided, set the text content of the element.
|
|
*/
|
|
createNode(options) {
|
|
const type = options.nodeType || "div";
|
|
const namespace = options.namespace || XHTML_NS;
|
|
const doc = this.anonymousContentDocument;
|
|
|
|
const node = doc.createElementNS(namespace, type);
|
|
|
|
for (const 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);
|
|
}
|
|
|
|
if (options.text) {
|
|
node.appendChild(doc.createTextNode(options.text));
|
|
}
|
|
|
|
return node;
|
|
},
|
|
};
|
|
exports.CanvasFrameAnonymousContentHelper = CanvasFrameAnonymousContentHelper;
|
|
|
|
/**
|
|
* Wait for document readyness.
|
|
* @param {Object} iframeOrWindow
|
|
* IFrame or Window for which the content should be loaded.
|
|
*/
|
|
function waitForContentLoaded(iframeOrWindow) {
|
|
let loadEvent = "DOMContentLoaded";
|
|
// If we are waiting for an iframe to load and it is for a XUL window
|
|
// highlighter that is not browser toolbox, we must wait for IFRAME's "load".
|
|
if (
|
|
iframeOrWindow.contentWindow &&
|
|
iframeOrWindow.ownerGlobal !==
|
|
iframeOrWindow.contentWindow.browsingContext.topChromeWindow
|
|
) {
|
|
loadEvent = "load";
|
|
}
|
|
|
|
const doc = iframeOrWindow.contentDocument || iframeOrWindow.document;
|
|
if (isDocumentReady(doc)) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
iframeOrWindow.addEventListener(loadEvent, resolve, { once: true });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @param {Object} [options={}]
|
|
* Advanced options for the infobar.
|
|
* @param {String} options.position
|
|
* Force the infobar to be displayed either on "top" or "bottom". Any other value
|
|
* will be ingnored.
|
|
*/
|
|
function moveInfobar(container, bounds, win, options = {}) {
|
|
const zoom = getCurrentZoom(win);
|
|
const viewport = getViewportDimensions(win);
|
|
|
|
const { computedStyle } = container;
|
|
|
|
const margin = 2;
|
|
const arrowSize = parseFloat(
|
|
computedStyle.getPropertyValue("--highlighter-bubble-arrow-size")
|
|
);
|
|
const containerHeight = parseFloat(computedStyle.getPropertyValue("height"));
|
|
const containerWidth = parseFloat(computedStyle.getPropertyValue("width"));
|
|
const containerHalfWidth = containerWidth / 2;
|
|
|
|
const viewportWidth = viewport.width * zoom;
|
|
const viewportHeight = viewport.height * zoom;
|
|
let { pageXOffset, pageYOffset } = win;
|
|
|
|
pageYOffset *= zoom;
|
|
pageXOffset *= zoom;
|
|
|
|
// Defines the boundaries for the infobar.
|
|
const topBoundary = margin;
|
|
const bottomBoundary = viewportHeight - containerHeight - margin - 1;
|
|
const leftBoundary = containerHalfWidth + margin;
|
|
const rightBoundary = viewportWidth - containerHalfWidth - margin;
|
|
|
|
// Set the default values.
|
|
let top = bounds.y - containerHeight - arrowSize;
|
|
const bottom = bounds.bottom + margin + arrowSize;
|
|
let left = bounds.x + bounds.width / 2;
|
|
let isOverlapTheNode = false;
|
|
let positionAttribute = "top";
|
|
let position = "absolute";
|
|
|
|
// Here we start the math.
|
|
// We basically want to position absolutely the infobar, except when is pointing to a
|
|
// node that is offscreen or partially offscreen, in a way that the infobar can't
|
|
// be placed neither on top nor on bottom.
|
|
// In such cases, the infobar will overlap the node, and to limit the latency given
|
|
// by APZ (See Bug 1312103) it will be positioned as "fixed".
|
|
// It's a sort of "position: sticky" (but positioned as absolute instead of relative).
|
|
const canBePlacedOnTop = top >= pageYOffset;
|
|
const canBePlacedOnBottom = bottomBoundary + pageYOffset - bottom > 0;
|
|
const forcedOnTop = options.position === "top";
|
|
const forcedOnBottom = options.position === "bottom";
|
|
|
|
if (
|
|
(!canBePlacedOnTop && canBePlacedOnBottom && !forcedOnTop) ||
|
|
forcedOnBottom
|
|
) {
|
|
top = bottom;
|
|
positionAttribute = "bottom";
|
|
}
|
|
|
|
const isOffscreenOnTop = top < topBoundary + pageYOffset;
|
|
const isOffscreenOnBottom = top > bottomBoundary + pageYOffset;
|
|
const isOffscreenOnLeft = left < leftBoundary + pageXOffset;
|
|
const isOffscreenOnRight = left > rightBoundary + pageXOffset;
|
|
|
|
if (isOffscreenOnTop) {
|
|
top = topBoundary;
|
|
isOverlapTheNode = true;
|
|
} else if (isOffscreenOnBottom) {
|
|
top = bottomBoundary;
|
|
isOverlapTheNode = true;
|
|
} else if (isOffscreenOnLeft || isOffscreenOnRight) {
|
|
isOverlapTheNode = true;
|
|
top -= pageYOffset;
|
|
}
|
|
|
|
if (isOverlapTheNode) {
|
|
left = Math.min(Math.max(leftBoundary, left - pageXOffset), rightBoundary);
|
|
|
|
position = "fixed";
|
|
container.setAttribute("hide-arrow", "true");
|
|
} else {
|
|
position = "absolute";
|
|
container.removeAttribute("hide-arrow");
|
|
}
|
|
|
|
// We need to scale the infobar Independently from the highlighter's container;
|
|
// otherwise the `position: fixed` won't work, since "any value other than `none` for
|
|
// the transform, results in the creation of both a stacking context and a containing
|
|
// block. The object acts as a containing block for fixed positioned descendants."
|
|
// (See https://www.w3.org/TR/css-transforms-1/#transform-rendering)
|
|
// We also need to shift the infobar 50% to the left in order for it to appear centered
|
|
// on the element it points to.
|
|
container.setAttribute(
|
|
"style",
|
|
`
|
|
position:${position};
|
|
transform-origin: 0 0;
|
|
transform: scale(${1 / zoom}) translate(calc(${left}px - 50%), ${top}px)`
|
|
);
|
|
|
|
container.setAttribute("position", positionAttribute);
|
|
}
|
|
exports.moveInfobar = moveInfobar;
|