summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/highlighters/utils
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/highlighters/utils')
-rw-r--r--devtools/server/actors/highlighters/utils/accessibility.js774
-rw-r--r--devtools/server/actors/highlighters/utils/canvas.js596
-rw-r--r--devtools/server/actors/highlighters/utils/markup.js787
-rw-r--r--devtools/server/actors/highlighters/utils/moz.build7
4 files changed, 2164 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..23c4210a65
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/accessibility.js
@@ -0,0 +1,774 @@
+/* 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("resource://devtools/shared/DevToolsUtils.js");
+const {
+ getCurrentZoom,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ moveInfobar,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ truncateString,
+} = require("resource://devtools/shared/inspector/utils.js");
+
+const STRINGS_URI = "devtools/shared/locales/accessibility.properties";
+loader.lazyRequireGetter(
+ this,
+ "LocalizationHelper",
+ "resource://devtools/shared/l10n.js",
+ 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("resource://devtools/shared/constants.js");
+
+// 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.root.getElementById(`${this.prefix}${id}`)
+ .textContent;
+ }
+
+ /**
+ * 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..24285f02e0
--- /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("resource://devtools/shared/layout/dom-matrix-2d.js");
+const {
+ getCurrentZoom,
+ getViewportDimensions,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ getComputedStyle,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+// 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..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;
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")