diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/server/actors/highlighters/utils | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | devtools/server/actors/highlighters/utils/accessibility.js | 769 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/utils/canvas.js | 596 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/utils/markup.js | 856 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/utils/moz.build | 7 |
4 files changed, 2228 insertions, 0 deletions
diff --git a/devtools/server/actors/highlighters/utils/accessibility.js b/devtools/server/actors/highlighters/utils/accessibility.js new file mode 100644 index 0000000000..18e4549a5f --- /dev/null +++ b/devtools/server/actors/highlighters/utils/accessibility.js @@ -0,0 +1,769 @@ +/* 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 DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const { getCurrentZoom } = require("devtools/shared/layout/utils"); +const { + moveInfobar, +} = require("devtools/server/actors/highlighters/utils/markup"); +const { truncateString } = require("devtools/shared/inspector/utils"); + +const STRINGS_URI = "devtools/shared/locales/accessibility.properties"; +loader.lazyRequireGetter( + this, + "LocalizationHelper", + "devtools/shared/l10n", + true +); +DevToolsUtils.defineLazyGetter( + this, + "L10N", + () => new LocalizationHelper(STRINGS_URI) +); + +const { + accessibility: { + AUDIT_TYPE, + ISSUE_TYPE: { + [AUDIT_TYPE.KEYBOARD]: { + FOCUSABLE_NO_SEMANTICS, + FOCUSABLE_POSITIVE_TABINDEX, + INTERACTIVE_NO_ACTION, + INTERACTIVE_NOT_FOCUSABLE, + MOUSE_INTERACTIVE_ONLY, + NO_FOCUS_VISIBLE, + }, + [AUDIT_TYPE.TEXT_LABEL]: { + AREA_NO_NAME_FROM_ALT, + DIALOG_NO_NAME, + DOCUMENT_NO_TITLE, + EMBED_NO_NAME, + FIGURE_NO_NAME, + FORM_FIELDSET_NO_NAME, + FORM_FIELDSET_NO_NAME_FROM_LEGEND, + FORM_NO_NAME, + FORM_NO_VISIBLE_NAME, + FORM_OPTGROUP_NO_NAME_FROM_LABEL, + FRAME_NO_NAME, + HEADING_NO_CONTENT, + HEADING_NO_NAME, + IFRAME_NO_NAME_FROM_TITLE, + IMAGE_NO_NAME, + INTERACTIVE_NO_NAME, + MATHML_GLYPH_NO_NAME, + TOOLBAR_NO_NAME, + }, + }, + SCORES, + }, +} = require("devtools/shared/constants"); + +// Max string length for truncating accessible name values. +const MAX_STRING_LENGTH = 50; + +/** + * The AccessibleInfobar is a class responsible for creating the markup for the + * accessible highlighter. It is also reponsible for updating content within the + * infobar such as role and name values. + */ +class Infobar { + constructor(highlighter) { + this.highlighter = highlighter; + this.audit = new Audit(this); + } + + get markup() { + return this.highlighter.markup; + } + + get document() { + return this.highlighter.win.document; + } + + get bounds() { + return this.highlighter._bounds; + } + + get options() { + return this.highlighter.options; + } + + get prefix() { + return this.highlighter.ID_CLASS_PREFIX; + } + + get win() { + return this.highlighter.win; + } + + /** + * Move the Infobar to the right place in the highlighter. + * + * @param {Element} container + * Container of infobar. + */ + _moveInfobar(container) { + // Position the infobar using accessible's bounds + const { left: x, top: y, bottom, width } = this.bounds; + const infobarBounds = { x, y, bottom, width }; + + moveInfobar(container, infobarBounds, this.win); + } + + /** + * Build markup for infobar. + * + * @param {Element} root + * Root element to build infobar with. + */ + buildMarkup(root) { + const container = this.markup.createNode({ + parent: root, + attributes: { + class: "infobar-container", + id: "infobar-container", + "aria-hidden": "true", + hidden: "true", + }, + prefix: this.prefix, + }); + + const infobar = this.markup.createNode({ + parent: container, + attributes: { + class: "infobar", + id: "infobar", + }, + prefix: this.prefix, + }); + + const infobarText = this.markup.createNode({ + parent: infobar, + attributes: { + class: "infobar-text", + id: "infobar-text", + }, + prefix: this.prefix, + }); + + this.markup.createNode({ + nodeType: "span", + parent: infobarText, + attributes: { + class: "infobar-role", + id: "infobar-role", + }, + prefix: this.prefix, + }); + + this.markup.createNode({ + nodeType: "span", + parent: infobarText, + attributes: { + class: "infobar-name", + id: "infobar-name", + }, + prefix: this.prefix, + }); + + this.audit.buildMarkup(infobarText); + } + + /** + * Destroy the Infobar's highlighter. + */ + destroy() { + this.highlighter = null; + this.audit.destroy(); + this.audit = null; + } + + /** + * Gets the element with the specified ID. + * + * @param {String} id + * Element ID. + * @return {Element} The element with specified ID. + */ + getElement(id) { + return this.highlighter.getElement(id); + } + + /** + * Gets the text content of element. + * + * @param {String} id + * Element ID to retrieve text content from. + * @return {String} The text content of the element. + */ + getTextContent(id) { + const anonymousContent = this.markup.content; + return anonymousContent.getTextContentForElement(`${this.prefix}${id}`); + } + + /** + * Hide the accessible infobar. + */ + hide() { + const container = this.getElement("infobar-container"); + container.setAttribute("hidden", "true"); + } + + /** + * Show the accessible infobar highlighter. + */ + show() { + const container = this.getElement("infobar-container"); + + // Remove accessible's infobar "hidden" attribute. We do this first to get the + // computed styles of the infobar container. + container.removeAttribute("hidden"); + + // Update the infobar's position and content. + this.update(container); + } + + /** + * Update content of the infobar. + */ + update(container) { + const { audit, name, role } = this.options; + + this.updateRole(role, this.getElement("infobar-role")); + this.updateName(name, this.getElement("infobar-name")); + this.audit.update(audit); + + // Position the infobar. + this._moveInfobar(container); + } + + /** + * Sets the text content of the specified element. + * + * @param {Element} el + * Element to set text content on. + * @param {String} text + * Text for content. + */ + setTextContent(el, text) { + el.setTextContent(text); + } + + /** + * Show the accessible's name message. + * + * @param {String} name + * Accessible's name value. + * @param {Element} el + * Element to set text content on. + */ + updateName(name, el) { + const nameText = name ? `"${truncateString(name, MAX_STRING_LENGTH)}"` : ""; + this.setTextContent(el, nameText); + } + + /** + * Show the accessible's role. + * + * @param {String} role + * Accessible's role value. + * @param {Element} el + * Element to set text content on. + */ + updateRole(role, el) { + this.setTextContent(el, role); + } +} + +/** + * Audit component used within the accessible highlighter infobar. This component is + * responsible for rendering and updating its containing AuditReport components that + * display various audit information such as contrast ratio score. + */ +class Audit { + constructor(infobar) { + this.infobar = infobar; + + // A list of audit reports to be shown on the fly when highlighting an accessible + // object. + this.reports = { + [AUDIT_TYPE.CONTRAST]: new ContrastRatio(this), + [AUDIT_TYPE.KEYBOARD]: new Keyboard(this), + [AUDIT_TYPE.TEXT_LABEL]: new TextLabel(this), + }; + } + + get prefix() { + return this.infobar.prefix; + } + + get markup() { + return this.infobar.markup; + } + + buildMarkup(root) { + const audit = this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "infobar-audit", + id: "infobar-audit", + }, + prefix: this.prefix, + }); + + Object.values(this.reports).forEach(report => report.buildMarkup(audit)); + } + + update(audit = {}) { + const el = this.getElement("infobar-audit"); + el.setAttribute("hidden", true); + + let updated = false; + Object.values(this.reports).forEach(report => { + if (report.update(audit)) { + updated = true; + } + }); + + if (updated) { + el.removeAttribute("hidden"); + } + } + + getElement(id) { + return this.infobar.getElement(id); + } + + setTextContent(el, text) { + return this.infobar.setTextContent(el, text); + } + + destroy() { + this.infobar = null; + Object.values(this.reports).forEach(report => report.destroy()); + this.reports = null; + } +} + +/** + * A common interface between audit report components used to render accessibility audit + * information for the currently highlighted accessible object. + */ +class AuditReport { + constructor(audit) { + this.audit = audit; + } + + get prefix() { + return this.audit.prefix; + } + + get markup() { + return this.audit.markup; + } + + getElement(id) { + return this.audit.getElement(id); + } + + setTextContent(el, text) { + return this.audit.setTextContent(el, text); + } + + destroy() { + this.audit = null; + } +} + +/** + * Contrast ratio audit report that is used to display contrast ratio score as part of the + * inforbar, + */ +class ContrastRatio extends AuditReport { + buildMarkup(root) { + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "contrast-ratio-label", + id: "contrast-ratio-label", + }, + prefix: this.prefix, + }); + + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "contrast-ratio-error", + id: "contrast-ratio-error", + }, + prefix: this.prefix, + text: L10N.getStr("accessibility.contrast.ratio.error"), + }); + + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "contrast-ratio", + id: "contrast-ratio-min", + }, + prefix: this.prefix, + }); + + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "contrast-ratio-separator", + id: "contrast-ratio-separator", + }, + prefix: this.prefix, + }); + + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "contrast-ratio", + id: "contrast-ratio-max", + }, + prefix: this.prefix, + }); + } + + _fillAndStyleContrastValue(el, { value, className, color, backgroundColor }) { + value = value.toFixed(2); + this.setTextContent(el, value); + el.classList.add(className); + el.setAttribute( + "style", + `--accessibility-highlighter-contrast-ratio-color: rgba(${color});` + + `--accessibility-highlighter-contrast-ratio-bg: rgba(${backgroundColor});` + ); + el.removeAttribute("hidden"); + } + + /** + * Update contrast ratio score infobar markup. + * @param {Object} + * Audit report for a given highlighted accessible. + * @return {Boolean} + * True if the contrast ratio markup was updated correctly and infobar audit + * block should be visible. + */ + update(audit) { + const els = {}; + for (const key of ["label", "min", "max", "error", "separator"]) { + const el = (els[key] = this.getElement(`contrast-ratio-${key}`)); + if (["min", "max"].includes(key)) { + Object.values(SCORES).forEach(className => + el.classList.remove(className) + ); + this.setTextContent(el, ""); + } + + el.setAttribute("hidden", true); + el.removeAttribute("style"); + } + + if (!audit) { + return false; + } + + const contrastRatio = audit[AUDIT_TYPE.CONTRAST]; + if (!contrastRatio) { + return false; + } + + const { isLargeText, error } = contrastRatio; + this.setTextContent( + els.label, + L10N.getStr( + `accessibility.contrast.ratio.label${isLargeText ? ".large" : ""}` + ) + ); + els.label.removeAttribute("hidden"); + if (error) { + els.error.removeAttribute("hidden"); + return true; + } + + if (contrastRatio.value) { + const { value, color, score, backgroundColor } = contrastRatio; + this._fillAndStyleContrastValue(els.min, { + value, + className: score, + color, + backgroundColor, + }); + return true; + } + + const { + min, + max, + color, + backgroundColorMin, + backgroundColorMax, + scoreMin, + scoreMax, + } = contrastRatio; + this._fillAndStyleContrastValue(els.min, { + value: min, + className: scoreMin, + color, + backgroundColor: backgroundColorMin, + }); + els.separator.removeAttribute("hidden"); + this._fillAndStyleContrastValue(els.max, { + value: max, + className: scoreMax, + color, + backgroundColor: backgroundColorMax, + }); + + return true; + } +} + +/** + * Keyboard audit report that is used to display a problem with keyboard + * accessibility as part of the inforbar. + */ +class Keyboard extends AuditReport { + /** + * A map from keyboard issues to annotation component properties. + */ + static get ISSUE_TO_INFOBAR_LABEL_MAP() { + return { + [FOCUSABLE_NO_SEMANTICS]: "accessibility.keyboard.issue.semantics", + [FOCUSABLE_POSITIVE_TABINDEX]: "accessibility.keyboard.issue.tabindex", + [INTERACTIVE_NO_ACTION]: "accessibility.keyboard.issue.action", + [INTERACTIVE_NOT_FOCUSABLE]: "accessibility.keyboard.issue.focusable", + [MOUSE_INTERACTIVE_ONLY]: "accessibility.keyboard.issue.mouse.only", + [NO_FOCUS_VISIBLE]: "accessibility.keyboard.issue.focus.visible", + }; + } + + buildMarkup(root) { + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "audit", + id: "keyboard", + }, + prefix: this.prefix, + }); + } + + /** + * Update keyboard audit infobar markup. + * @param {Object} + * Audit report for a given highlighted accessible. + * @return {Boolean} + * True if the keyboard markup was updated correctly and infobar audit + * block should be visible. + */ + update(audit) { + const el = this.getElement("keyboard"); + el.setAttribute("hidden", true); + Object.values(SCORES).forEach(className => el.classList.remove(className)); + + if (!audit) { + return false; + } + + const keyboardAudit = audit[AUDIT_TYPE.KEYBOARD]; + if (!keyboardAudit) { + return false; + } + + const { issue, score } = keyboardAudit; + this.setTextContent( + el, + L10N.getStr(Keyboard.ISSUE_TO_INFOBAR_LABEL_MAP[issue]) + ); + el.classList.add(score); + el.removeAttribute("hidden"); + + return true; + } +} + +/** + * Text label audit report that is used to display a problem with text alternatives + * as part of the inforbar. + */ +class TextLabel extends AuditReport { + /** + * A map from text label issues to annotation component properties. + */ + static get ISSUE_TO_INFOBAR_LABEL_MAP() { + return { + [AREA_NO_NAME_FROM_ALT]: "accessibility.text.label.issue.area", + [DIALOG_NO_NAME]: "accessibility.text.label.issue.dialog", + [DOCUMENT_NO_TITLE]: "accessibility.text.label.issue.document.title", + [EMBED_NO_NAME]: "accessibility.text.label.issue.embed", + [FIGURE_NO_NAME]: "accessibility.text.label.issue.figure", + [FORM_FIELDSET_NO_NAME]: "accessibility.text.label.issue.fieldset", + [FORM_FIELDSET_NO_NAME_FROM_LEGEND]: + "accessibility.text.label.issue.fieldset.legend2", + [FORM_NO_NAME]: "accessibility.text.label.issue.form", + [FORM_NO_VISIBLE_NAME]: "accessibility.text.label.issue.form.visible", + [FORM_OPTGROUP_NO_NAME_FROM_LABEL]: + "accessibility.text.label.issue.optgroup.label2", + [FRAME_NO_NAME]: "accessibility.text.label.issue.frame", + [HEADING_NO_CONTENT]: "accessibility.text.label.issue.heading.content", + [HEADING_NO_NAME]: "accessibility.text.label.issue.heading", + [IFRAME_NO_NAME_FROM_TITLE]: "accessibility.text.label.issue.iframe", + [IMAGE_NO_NAME]: "accessibility.text.label.issue.image", + [INTERACTIVE_NO_NAME]: "accessibility.text.label.issue.interactive", + [MATHML_GLYPH_NO_NAME]: "accessibility.text.label.issue.glyph", + [TOOLBAR_NO_NAME]: "accessibility.text.label.issue.toolbar", + }; + } + + buildMarkup(root) { + this.markup.createNode({ + nodeType: "span", + parent: root, + attributes: { + class: "audit", + id: "text-label", + }, + prefix: this.prefix, + }); + } + + /** + * Update text label audit infobar markup. + * @param {Object} + * Audit report for a given highlighted accessible. + * @return {Boolean} + * True if the text label markup was updated correctly and infobar + * audit block should be visible. + */ + update(audit) { + const el = this.getElement("text-label"); + el.setAttribute("hidden", true); + Object.values(SCORES).forEach(className => el.classList.remove(className)); + + if (!audit) { + return false; + } + + const textLabelAudit = audit[AUDIT_TYPE.TEXT_LABEL]; + if (!textLabelAudit) { + return false; + } + + const { issue, score } = textLabelAudit; + this.setTextContent( + el, + L10N.getStr(TextLabel.ISSUE_TO_INFOBAR_LABEL_MAP[issue]) + ); + el.classList.add(score); + el.removeAttribute("hidden"); + + return true; + } +} + +/** + * A helper function that calculate accessible object bounds and positioning to + * be used for highlighting. + * + * @param {Object} win + * window that contains accessible object. + * @param {Object} options + * Object used for passing options: + * - {Number} x + * x coordinate of the top left corner of the accessible object + * - {Number} y + * y coordinate of the top left corner of the accessible object + * - {Number} w + * width of the the accessible object + * - {Number} h + * height of the the accessible object + * @return {Object|null} Returns, if available, positioning and bounds information for + * the accessible object. + */ +function getBounds(win, { x, y, w, h }) { + const { mozInnerScreenX, mozInnerScreenY, scrollX, scrollY } = win; + const zoom = getCurrentZoom(win); + let left = x; + let right = x + w; + let top = y; + let bottom = y + h; + + left -= mozInnerScreenX - scrollX; + right -= mozInnerScreenX - scrollX; + top -= mozInnerScreenY - scrollY; + bottom -= mozInnerScreenY - scrollY; + + left *= zoom; + right *= zoom; + top *= zoom; + bottom *= zoom; + + const width = right - left; + const height = bottom - top; + + return { left, right, top, bottom, width, height }; +} + +/** + * A helper function that calculate accessible object bounds and positioning to + * be used for highlighting in browser toolbox. + * + * @param {Object} win + * window that contains accessible object. + * @param {Object} options + * Object used for passing options: + * - {Number} x + * x coordinate of the top left corner of the accessible object + * - {Number} y + * y coordinate of the top left corner of the accessible object + * - {Number} w + * width of the the accessible object + * - {Number} h + * height of the the accessible object + * - {Number} zoom + * zoom level of the accessible object's parent window + * @return {Object|null} Returns, if available, positioning and bounds information for + * the accessible object. + */ +function getBoundsXUL(win, { x, y, w, h, zoom }) { + const { mozInnerScreenX, mozInnerScreenY } = win; + let left = x; + let right = x + w; + let top = y; + let bottom = y + h; + + left *= zoom; + right *= zoom; + top *= zoom; + bottom *= zoom; + + left -= mozInnerScreenX; + right -= mozInnerScreenX; + top -= mozInnerScreenY; + bottom -= mozInnerScreenY; + + const width = right - left; + const height = bottom - top; + + return { left, right, top, bottom, width, height }; +} + +exports.MAX_STRING_LENGTH = MAX_STRING_LENGTH; +exports.getBounds = getBounds; +exports.getBoundsXUL = getBoundsXUL; +exports.Infobar = Infobar; diff --git a/devtools/server/actors/highlighters/utils/canvas.js b/devtools/server/actors/highlighters/utils/canvas.js new file mode 100644 index 0000000000..06f143d04d --- /dev/null +++ b/devtools/server/actors/highlighters/utils/canvas.js @@ -0,0 +1,596 @@ +/* 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 { + apply, + getNodeTransformationMatrix, + getWritingModeMatrix, + identity, + isIdentity, + multiply, + scale, + translate, +} = require("devtools/shared/layout/dom-matrix-2d"); +const { + getCurrentZoom, + getViewportDimensions, +} = require("devtools/shared/layout/utils"); +const { + getComputedStyle, +} = require("devtools/server/actors/highlighters/utils/markup"); + +// A set of utility functions for highlighters that render their content to a <canvas> +// element. + +// We create a <canvas> element that has always 4096x4096 physical pixels, to displays +// our grid's overlay. +// Then, we move the element around when needed, to give the perception that it always +// covers the screen (See bug 1345434). +// +// This canvas size value is the safest we can use because most GPUs can handle it. +// It's also far from the maximum canvas memory allocation limit (4096x4096x4 is +// 67.108.864 bytes, where the limit is 500.000.000 bytes, see +// gfx_max_alloc_size in modules/libpref/init/StaticPrefList.yaml. +// +// Note: +// Once bug 1232491 lands, we could try to refactor this code to use the values from +// the displayport API instead. +// +// Using a fixed value should also solve bug 1348293. +const CANVAS_SIZE = 4096; + +// The default color used for the canvas' font, fill and stroke colors. +const DEFAULT_COLOR = "#9400FF"; + +/** + * Draws a rect to the context given and applies a transformation matrix if passed. + * The coordinates are the start and end points of the rectangle's diagonal. + * + * @param {CanvasRenderingContext2D} ctx + * The 2D canvas context. + * @param {Number} x1 + * The x-axis coordinate of the rectangle's diagonal start point. + * @param {Number} y1 + * The y-axis coordinate of the rectangle's diagonal start point. + * @param {Number} x2 + * The x-axis coordinate of the rectangle's diagonal end point. + * @param {Number} y2 + * The y-axis coordinate of the rectangle's diagonal end point. + * @param {Array} [matrix=identity()] + * The transformation matrix to apply. + */ +function clearRect(ctx, x1, y1, x2, y2, matrix = identity()) { + const p = getPointsFromDiagonal(x1, y1, x2, y2, matrix); + + // We are creating a clipping path and want it removed after we clear it's + // contents so we need to save the context. + ctx.save(); + + // Create a path to be cleared. + ctx.beginPath(); + ctx.moveTo(Math.round(p[0].x), Math.round(p[0].y)); + ctx.lineTo(Math.round(p[1].x), Math.round(p[1].y)); + ctx.lineTo(Math.round(p[2].x), Math.round(p[2].y)); + ctx.lineTo(Math.round(p[3].x), Math.round(p[3].y)); + ctx.closePath(); + + // Restrict future drawing to the inside of the path. + ctx.clip(); + + // Clear any transforms applied to the canvas so that clearRect() really does + // clear everything. + ctx.setTransform(1, 0, 0, 1, 0, 0); + + // Clear the contents of our clipped path by attempting to clear the canvas. + ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); + + // Restore the context to the state it was before changing transforms and + // adding clipping paths. + ctx.restore(); +} + +/** + * Draws an arrow-bubble rectangle in the provided canvas context. + * + * @param {CanvasRenderingContext2D} ctx + * The 2D canvas context. + * @param {Number} x + * The x-axis origin of the rectangle. + * @param {Number} y + * The y-axis origin of the rectangle. + * @param {Number} width + * The width of the rectangle. + * @param {Number} height + * The height of the rectangle. + * @param {Number} radius + * The radius of the rounding. + * @param {Number} margin + * The distance of the origin point from the pointer. + * @param {Number} arrowSize + * The size of the arrow. + * @param {String} alignment + * The alignment of the rectangle in relation to its position to the grid. + */ +function drawBubbleRect( + ctx, + x, + y, + width, + height, + radius, + margin, + arrowSize, + alignment +) { + let angle = 0; + + if (alignment === "bottom") { + angle = 180; + } else if (alignment === "right") { + angle = 90; + [width, height] = [height, width]; + } else if (alignment === "left") { + [width, height] = [height, width]; + angle = 270; + } + + const originX = x; + const originY = y; + + ctx.save(); + ctx.translate(originX, originY); + ctx.rotate(angle * (Math.PI / 180)); + ctx.translate(-originX, -originY); + ctx.translate(-width / 2, -height - arrowSize - margin); + + // The contour of the bubble is drawn with a path. The canvas context will have taken + // care of transforming the coordinates before calling the function, so we just always + // draw with the arrow pointing down. The top edge has rounded corners too. + ctx.beginPath(); + // Start at the top/left corner (below the rounded corner). + ctx.moveTo(x, y + radius); + // Go down. + ctx.lineTo(x, y + height); + // Go down and the right, to draw the first half of the arrow tip. + ctx.lineTo(x + width / 2, y + height + arrowSize); + // Go back up and to the right, to draw the second half of the arrow tip. + ctx.lineTo(x + width, y + height); + // Go up to just below the top/right rounded corner. + ctx.lineTo(x + width, y + radius); + // Draw the top/right rounded corner. + ctx.arcTo(x + width, y, x + width - radius, y, radius); + // Go to the left. + ctx.lineTo(x + radius, y); + // Draw the top/left rounded corner. + ctx.arcTo(x, y, x, y + radius, radius); + + ctx.stroke(); + ctx.fill(); + + ctx.restore(); +} + +/** + * Draws a line to the context given and applies a transformation matrix if passed. + * + * @param {CanvasRenderingContext2D} ctx + * The 2D canvas context. + * @param {Number} x1 + * The x-axis of the coordinate for the begin of the line. + * @param {Number} y1 + * The y-axis of the coordinate for the begin of the line. + * @param {Number} x2 + * The x-axis of the coordinate for the end of the line. + * @param {Number} y2 + * The y-axis of the coordinate for the end of the line. + * @param {Object} [options] + * The options object. + * @param {Array} [options.matrix=identity()] + * The transformation matrix to apply. + * @param {Array} [options.extendToBoundaries] + * If set, the line will be extended to reach the boundaries specified. + */ +function drawLine(ctx, x1, y1, x2, y2, options) { + const matrix = options.matrix || identity(); + + const p1 = apply(matrix, [x1, y1]); + const p2 = apply(matrix, [x2, y2]); + + x1 = p1[0]; + y1 = p1[1]; + x2 = p2[0]; + y2 = p2[1]; + + if (options.extendToBoundaries) { + if (p1[1] === p2[1]) { + x1 = options.extendToBoundaries[0]; + x2 = options.extendToBoundaries[2]; + } else { + y1 = options.extendToBoundaries[1]; + x1 = ((p2[0] - p1[0]) * (y1 - p1[1])) / (p2[1] - p1[1]) + p1[0]; + y2 = options.extendToBoundaries[3]; + x2 = ((p2[0] - p1[0]) * (y2 - p1[1])) / (p2[1] - p1[1]) + p1[0]; + } + } + + ctx.beginPath(); + ctx.moveTo(Math.round(x1), Math.round(y1)); + ctx.lineTo(Math.round(x2), Math.round(y2)); +} + +/** + * Draws a rect to the context given and applies a transformation matrix if passed. + * The coordinates are the start and end points of the rectangle's diagonal. + * + * @param {CanvasRenderingContext2D} ctx + * The 2D canvas context. + * @param {Number} x1 + * The x-axis coordinate of the rectangle's diagonal start point. + * @param {Number} y1 + * The y-axis coordinate of the rectangle's diagonal start point. + * @param {Number} x2 + * The x-axis coordinate of the rectangle's diagonal end point. + * @param {Number} y2 + * The y-axis coordinate of the rectangle's diagonal end point. + * @param {Array} [matrix=identity()] + * The transformation matrix to apply. + */ +function drawRect(ctx, x1, y1, x2, y2, matrix = identity()) { + const p = getPointsFromDiagonal(x1, y1, x2, y2, matrix); + + ctx.beginPath(); + ctx.moveTo(Math.round(p[0].x), Math.round(p[0].y)); + ctx.lineTo(Math.round(p[1].x), Math.round(p[1].y)); + ctx.lineTo(Math.round(p[2].x), Math.round(p[2].y)); + ctx.lineTo(Math.round(p[3].x), Math.round(p[3].y)); + ctx.closePath(); +} + +/** + * Draws a rounded rectangle in the provided canvas context. + * + * @param {CanvasRenderingContext2D} ctx + * The 2D canvas context. + * @param {Number} x + * The x-axis origin of the rectangle. + * @param {Number} y + * The y-axis origin of the rectangle. + * @param {Number} width + * The width of the rectangle. + * @param {Number} height + * The height of the rectangle. + * @param {Number} radius + * The radius of the rounding. + */ +function drawRoundedRect(ctx, x, y, width, height, radius) { + ctx.beginPath(); + ctx.moveTo(x, y + radius); + ctx.lineTo(x, y + height - radius); + ctx.arcTo(x, y + height, x + radius, y + height, radius); + ctx.lineTo(x + width - radius, y + height); + ctx.arcTo(x + width, y + height, x + width, y + height - radius, radius); + ctx.lineTo(x + width, y + radius); + ctx.arcTo(x + width, y, x + width - radius, y, radius); + ctx.lineTo(x + radius, y); + ctx.arcTo(x, y, x, y + radius, radius); + ctx.stroke(); + ctx.fill(); +} + +/** + * Given an array of four points and returns a DOMRect-like object representing the + * boundaries defined by the four points. + * + * @param {Array} points + * An array with 4 pointer objects {x, y} representing the box quads. + * @return {Object} DOMRect-like object of the 4 points. + */ +function getBoundsFromPoints(points) { + const bounds = {}; + + bounds.left = Math.min(points[0].x, points[1].x, points[2].x, points[3].x); + bounds.right = Math.max(points[0].x, points[1].x, points[2].x, points[3].x); + bounds.top = Math.min(points[0].y, points[1].y, points[2].y, points[3].y); + bounds.bottom = Math.max(points[0].y, points[1].y, points[2].y, points[3].y); + + bounds.x = bounds.left; + bounds.y = bounds.top; + bounds.width = bounds.right - bounds.left; + bounds.height = bounds.bottom - bounds.top; + + return bounds; +} + +/** + * Returns the current matrices for both canvas drawing and SVG taking into account the + * following transformations, in this order: + * 1. The scale given by the display pixel ratio. + * 2. The translation to the top left corner of the element. + * 3. The scale given by the current zoom. + * 4. The translation given by the top and left padding of the element. + * 5. Any CSS transformation applied directly to the element (only 2D + * transformation; the 3D transformation are flattened, see `dom-matrix-2d` module + * for further details.) + * 6. Rotate, translate, and reflect as needed to match the writing mode and text + * direction of the element. + * + * The transformations of the element's ancestors are not currently computed (see + * bug 1355675). + * + * @param {Element} element + * The current element. + * @param {Window} window + * The window object. + * @param {Object} [options.ignoreWritingModeAndTextDirection=false] + * Avoid transforming the current matrix to match the text direction + * and writing mode. + * @return {Object} An object with the following properties: + * - {Array} currentMatrix + * The current matrix. + * - {Boolean} hasNodeTransformations + * true if the node has transformed and false otherwise. + */ +function getCurrentMatrix( + element, + window, + { ignoreWritingModeAndTextDirection } = {} +) { + const computedStyle = getComputedStyle(element); + + const paddingTop = parseFloat(computedStyle.paddingTop); + const paddingRight = parseFloat(computedStyle.paddingRight); + const paddingBottom = parseFloat(computedStyle.paddingBottom); + const paddingLeft = parseFloat(computedStyle.paddingLeft); + const borderTop = parseFloat(computedStyle.borderTopWidth); + const borderRight = parseFloat(computedStyle.borderRightWidth); + const borderBottom = parseFloat(computedStyle.borderBottomWidth); + const borderLeft = parseFloat(computedStyle.borderLeftWidth); + + const nodeMatrix = getNodeTransformationMatrix( + element, + window.document.documentElement + ); + + let currentMatrix = identity(); + let hasNodeTransformations = false; + + // Scale based on the device pixel ratio. + currentMatrix = multiply(currentMatrix, scale(window.devicePixelRatio)); + + // Apply the current node's transformation matrix, relative to the inspected window's + // root element, but only if it's not a identity matrix. + if (isIdentity(nodeMatrix)) { + hasNodeTransformations = false; + } else { + currentMatrix = multiply(currentMatrix, nodeMatrix); + hasNodeTransformations = true; + } + + // Translate the origin based on the node's padding and border values. + currentMatrix = multiply( + currentMatrix, + translate(paddingLeft + borderLeft, paddingTop + borderTop) + ); + + // Adjust as needed to match the writing mode and text direction of the element. + const size = { + width: + element.offsetWidth - + borderLeft - + borderRight - + paddingLeft - + paddingRight, + height: + element.offsetHeight - + borderTop - + borderBottom - + paddingTop - + paddingBottom, + }; + + if (!ignoreWritingModeAndTextDirection) { + const writingModeMatrix = getWritingModeMatrix(size, computedStyle); + if (!isIdentity(writingModeMatrix)) { + currentMatrix = multiply(currentMatrix, writingModeMatrix); + } + } + + return { currentMatrix, hasNodeTransformations }; +} + +/** + * Given an array of four points, returns a string represent a path description. + * + * @param {Array} points + * An array with 4 pointer objects {x, y} representing the box quads. + * @return {String} a Path Description that can be used in svg's <path> element. + */ +function getPathDescriptionFromPoints(points) { + return ( + "M" + + points[0].x + + "," + + points[0].y + + " " + + "L" + + points[1].x + + "," + + points[1].y + + " " + + "L" + + points[2].x + + "," + + points[2].y + + " " + + "L" + + points[3].x + + "," + + points[3].y + ); +} + +/** + * Given the rectangle's diagonal start and end coordinates, returns an array containing + * the four coordinates of a rectangle. If a matrix is provided, applies the matrix + * function to each of the coordinates' value. + * + * @param {Number} x1 + * The x-axis coordinate of the rectangle's diagonal start point. + * @param {Number} y1 + * The y-axis coordinate of the rectangle's diagonal start point. + * @param {Number} x2 + * The x-axis coordinate of the rectangle's diagonal end point. + * @param {Number} y2 + * The y-axis coordinate of the rectangle's diagonal end point. + * @param {Array} [matrix=identity()] + * A transformation matrix to apply. + * @return {Array} the four coordinate points of the given rectangle transformed by the + * matrix given. + */ +function getPointsFromDiagonal(x1, y1, x2, y2, matrix = identity()) { + return [ + [x1, y1], + [x2, y1], + [x2, y2], + [x1, y2], + ].map(point => { + const transformedPoint = apply(matrix, point); + + return { x: transformedPoint[0], y: transformedPoint[1] }; + }); +} + +/** + * Updates the <canvas> element's style in accordance with the current window's + * device pixel ratio, and the position calculated in `getCanvasPosition`. It also + * clears the drawing context. This is called on canvas update after a scroll event where + * `getCanvasPosition` updates the new canvasPosition. + * + * @param {Canvas} canvas + * The <canvas> element. + * @param {Object} canvasPosition + * A pointer object {x, y} representing the <canvas> position to the top left + * corner of the page. + * @param {Number} devicePixelRatio + * The device pixel ratio. + * @param {Window} [options.zoomWindow] + * Optional window object used to calculate zoom (default = undefined). + */ +function updateCanvasElement( + canvas, + canvasPosition, + devicePixelRatio, + { zoomWindow } = {} +) { + let { x, y } = canvasPosition; + const size = CANVAS_SIZE / devicePixelRatio; + + if (zoomWindow) { + const zoom = getCurrentZoom(zoomWindow); + x *= zoom; + y *= zoom; + } + + // Resize the canvas taking the dpr into account so as to have crisp lines, and + // translating it to give the perception that it always covers the viewport. + canvas.setAttribute( + "style", + `width: ${size}px; height: ${size}px; transform: translate(${x}px, ${y}px);` + ); + canvas.getCanvasContext("2d").clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); +} + +/** + * Calculates and returns the <canvas>'s position in accordance with the page's scroll, + * document's size, canvas size, and viewport's size. This is called when a page's scroll + * is detected. + * + * @param {Object} canvasPosition + * A pointer object {x, y} representing the <canvas> position to the top left + * corner of the page. + * @param {Object} scrollPosition + * A pointer object {x, y} representing the window's pageXOffset and pageYOffset. + * @param {Window} window + * The window object. + * @param {Object} windowDimensions + * An object {width, height} representing the window's dimensions for the + * `window` given. + * @return {Boolean} true if the <canvas> position was updated and false otherwise. + */ +function updateCanvasPosition( + canvasPosition, + scrollPosition, + window, + windowDimensions +) { + let { x: canvasX, y: canvasY } = canvasPosition; + const { x: scrollX, y: scrollY } = scrollPosition; + const cssCanvasSize = CANVAS_SIZE / window.devicePixelRatio; + const viewportSize = getViewportDimensions(window); + const { height, width } = windowDimensions; + const canvasWidth = cssCanvasSize; + const canvasHeight = cssCanvasSize; + let hasUpdated = false; + + // Those values indicates the relative horizontal and vertical space the page can + // scroll before we have to reposition the <canvas>; they're 1/4 of the delta between + // the canvas' size and the viewport's size: that's because we want to consider both + // sides (top/bottom, left/right; so 1/2 for each side) and also we don't want to + // shown the edges of the canvas in case of fast scrolling (to avoid showing undraw + // areas, therefore another 1/2 here). + const bufferSizeX = (canvasWidth - viewportSize.width) >> 2; + const bufferSizeY = (canvasHeight - viewportSize.height) >> 2; + + // Defines the boundaries for the canvas. + const leftBoundary = 0; + const rightBoundary = width - canvasWidth; + const topBoundary = 0; + const bottomBoundary = height - canvasHeight; + + // Defines the thresholds that triggers the canvas' position to be updated. + const leftThreshold = scrollX - bufferSizeX; + const rightThreshold = + scrollX - canvasWidth + viewportSize.width + bufferSizeX; + const topThreshold = scrollY - bufferSizeY; + const bottomThreshold = + scrollY - canvasHeight + viewportSize.height + bufferSizeY; + + if (canvasX < rightBoundary && canvasX < rightThreshold) { + canvasX = Math.min(leftThreshold, rightBoundary); + hasUpdated = true; + } else if (canvasX > leftBoundary && canvasX > leftThreshold) { + canvasX = Math.max(rightThreshold, leftBoundary); + hasUpdated = true; + } + + if (canvasY < bottomBoundary && canvasY < bottomThreshold) { + canvasY = Math.min(topThreshold, bottomBoundary); + hasUpdated = true; + } else if (canvasY > topBoundary && canvasY > topThreshold) { + canvasY = Math.max(bottomThreshold, topBoundary); + hasUpdated = true; + } + + // Update the canvas position with the calculated canvasX and canvasY positions. + canvasPosition.x = canvasX; + canvasPosition.y = canvasY; + + return hasUpdated; +} + +exports.CANVAS_SIZE = CANVAS_SIZE; +exports.DEFAULT_COLOR = DEFAULT_COLOR; +exports.clearRect = clearRect; +exports.drawBubbleRect = drawBubbleRect; +exports.drawLine = drawLine; +exports.drawRect = drawRect; +exports.drawRoundedRect = drawRoundedRect; +exports.getBoundsFromPoints = getBoundsFromPoints; +exports.getCurrentMatrix = getCurrentMatrix; +exports.getPathDescriptionFromPoints = getPathDescriptionFromPoints; +exports.getPointsFromDiagonal = getPointsFromDiagonal; +exports.updateCanvasElement = updateCanvasElement; +exports.updateCanvasPosition = updateCanvasPosition; diff --git a/devtools/server/actors/highlighters/utils/markup.js b/devtools/server/actors/highlighters/utils/markup.js new file mode 100644 index 0000000000..c3e75a1ee2 --- /dev/null +++ b/devtools/server/actors/highlighters/utils/markup.js @@ -0,0 +1,856 @@ +/* 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 { Cu, Cr } = require("chrome"); +const { + getCurrentZoom, + getWindowDimensions, + getViewportDimensions, + loadSheet, + removeSheet, +} = require("devtools/shared/layout/utils"); +const EventEmitter = require("devtools/shared/event-emitter"); +const InspectorUtils = require("InspectorUtils"); + +const lazyContainer = {}; + +loader.lazyRequireGetter( + lazyContainer, + "CssLogic", + "devtools/server/actors/inspector/css-logic", + true +); +loader.lazyRequireGetter( + this, + "isDocumentReady", + "devtools/server/actors/inspector/utils", + 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"; +// Highlighter in parent process will create an iframe relative to its target +// window. We need to make sure that the iframe is styled correctly. Note: +// this styles are taken from browser/base/content/browser.css +// iframe.devtools-highlighter-renderer rules. +const XUL_HIGHLIGHTER_STYLES_SHEET = `data:text/css;charset=utf-8, +:root > iframe.devtools-highlighter-renderer { + border: none; + pointer-events: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 2; +}`; + +const STYLESHEET_URI = + "resource://devtools/server/actors/" + "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]: 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) { + // XXX: We temporarily return true for HTML documents if the document disables + // scroll frames since the regular highlighter is broken in this case. This + // should be removed when bug 1594587 is fixed. + return ( + window.document.documentElement.namespaceURI === XUL_NS || + (window.isChromeWindow && + window.document.documentElement.getAttribute("scrolling") === "false") + ); +} +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. + */ +function CanvasFrameAnonymousContentHelper(highlighterEnv, nodeBuilder) { + this.highlighterEnv = highlighterEnv; + this.nodeBuilder = nodeBuilder; + + 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 && + (isDocumentReady(doc) || doc.readyState !== "uninitialized") + ) { + this._insert(); + } + + return onInitialized; + }, + + destroy() { + this._remove(); + if (this._iframe) { + // If iframe is used, remove one ref count from its numberOfHighlighters + // data attribute. + const numberOfHighlighters = + parseInt(this._iframe.dataset.numberOfHighlighters, 10) - 1; + this._iframe.dataset.numberOfHighlighters = numberOfHighlighters; + // If we reached 0, we can now remove the iframe and its styling from + // target window. + if (numberOfHighlighters === 0) { + this._iframe.remove(); + removeSheet(this.highlighterEnv.window, XUL_HIGHLIGHTER_STYLES_SHEET); + } + this._iframe = null; + } + + 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() { + await waitForContentLoaded(this.highlighterEnv.window); + if (!this.highlighterEnv) { + // CanvasFrameAnonymousContentHelper was already destroyed. + return; + } + if (isXUL(this.highlighterEnv.window)) { + // In order to use anonymous content, we need to create and use an IFRAME + // inside a XUL document first and use its window/document the same way we + // would normally use highlighter environment's window/document. See + // TODO: bug 1594587 for more details. + // + // Note: xul:window is not necessarily the top chrome window (as it's the + // case with about:devtools-toolbox). We need to ensure that we use the + // top chrome window to look up or create the iframe. + if (!this._iframe) { + const { documentElement } = this.highlighterEnv.window.document; + this._iframe = documentElement.querySelector( + ":scope > .devtools-highlighter-renderer" + ); + if (this._iframe) { + // If iframe is used and already exists, add one ref count to its + // numberOfHighlighters data attribute. + const numberOfHighlighters = + parseInt(this._iframe.dataset.numberOfHighlighters, 10) + 1; + this._iframe.dataset.numberOfHighlighters = numberOfHighlighters; + } else { + this._iframe = this.highlighterEnv.window.document.createElement( + "iframe" + ); + this._iframe.classList.add("devtools-highlighter-renderer"); + // If iframe is used for the first time, add ref count of one to its + // numberOfHighlighters data attribute. + this._iframe.dataset.numberOfHighlighters = 1; + documentElement.append(this._iframe); + loadSheet(this.highlighterEnv.window, XUL_HIGHLIGHTER_STYLES_SHEET); + } + } + + await waitForContentLoaded(this._iframe); + if (!this.highlighterEnv) { + // CanvasFrameAnonymousContentHelper was already destroyed. + return; + } + + // If it's a XUL window anonymous content will be inserted inside a newly + // created IFRAME in the chrome window. + this.anonymousContentDocument = this._iframe.contentDocument; + this.anonymousContentWindow = this._iframe.contentWindow; + this.pageListenerTarget = this._iframe.contentWindow; + } else { + // Regular 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; + } + + // For now highlighters.css is injected in content as a ua sheet because + // we no longer support scoped style sheets (see bug 1345702). + // If it did, highlighters.css would be injected as an anonymous content + // node using CanvasFrameAnonymousContentHelper instead. + loadSheet(this.anonymousContentWindow, STYLESHEET_URI); + + const 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) + try { + this._content = this.anonymousContentDocument.insertAnonymousContent( + node + ); + } 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( + node + ); + } else { + throw e; + } + } + + 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(); + if (this._iframe) { + // When we are switching top level targets, we can remove the iframe and + // its styling as well, since it will be re-created for the new top + // level target document. + this._iframe.remove(); + removeSheet(this.highlighterEnv.window, XUL_HIGHLIGHTER_STYLES_SHEET); + this._iframe = null; + } + + this._insert(); + } + }, + + getComputedStylePropertyValue(id, property) { + return ( + this.content && this.content.getComputedStylePropertyValue(id, property) + ); + }, + + getTextContentForElement(id) { + return this.content && this.content.getTextContentForElement(id); + }, + + setTextContentForElement(id, text) { + if (this.content) { + this.content.setTextContentForElement(id, text); + } + }, + + setAttributeForElement(id, name, value) { + if (this.content) { + this.content.setAttributeForElement(id, name, value); + } + }, + + getAttributeForElement(id, name) { + return this.content && this.content.getAttributeForElement(id, name); + }, + + removeAttributeForElement(id, name) { + if (this.content) { + this.content.removeAttributeForElement(id, name); + } + }, + + hasAttributeForElement(id, name) { + return typeof this.getAttributeForElement(id, name) === "string"; + }, + + getCanvasContext(id, type = "2d") { + return this.content && this.content.getCanvasContext(id, 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. + this.setAttributeForElement(id, "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`; + + this.setAttributeForElement(id, "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. + * @param {Boolean} options.hideIfOffscreen + * If set to `true`, hides the infobar if it's offscreen, instead of automatically + * reposition it. + */ +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 && options.hideIfOffscreen) { + container.setAttribute("hidden", "true"); + return; + } else 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; diff --git a/devtools/server/actors/highlighters/utils/moz.build b/devtools/server/actors/highlighters/utils/moz.build new file mode 100644 index 0000000000..ab4f96912d --- /dev/null +++ b/devtools/server/actors/highlighters/utils/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules("accessibility.js", "canvas.js", "markup.js") |