diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/server/actors/highlighters/utils/markup.js | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | devtools/server/actors/highlighters/utils/markup.js | 787 |
1 files changed, 787 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 0000000000..a550ca0076 --- /dev/null +++ b/devtools/server/actors/highlighters/utils/markup.js @@ -0,0 +1,787 @@ +/* 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; |