/* 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 // element. // We create a 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 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 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 element. * @param {Object} canvasPosition * A pointer object {x, y} representing the 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 '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 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 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 ; 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;