diff options
Diffstat (limited to '')
-rw-r--r-- | devtools/server/actors/highlighters/css-grid.js | 1962 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/css-transform.js | 265 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/css/highlighters.css | 1059 | ||||
-rw-r--r-- | devtools/server/actors/highlighters/css/moz.build | 9 |
4 files changed, 3295 insertions, 0 deletions
diff --git a/devtools/server/actors/highlighters/css-grid.js b/devtools/server/actors/highlighters/css-grid.js new file mode 100644 index 0000000000..04c612eb02 --- /dev/null +++ b/devtools/server/actors/highlighters/css-grid.js @@ -0,0 +1,1962 @@ +/* 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 { + AutoRefreshHighlighter, +} = require("resource://devtools/server/actors/highlighters/auto-refresh.js"); +const { + CANVAS_SIZE, + DEFAULT_COLOR, + drawBubbleRect, + drawLine, + drawRect, + drawRoundedRect, + getBoundsFromPoints, + getCurrentMatrix, + getPathDescriptionFromPoints, + getPointsFromDiagonal, + updateCanvasElement, + updateCanvasPosition, +} = require("resource://devtools/server/actors/highlighters/utils/canvas.js"); +const { + CanvasFrameAnonymousContentHelper, + getComputedStyle, + moveInfobar, +} = require("resource://devtools/server/actors/highlighters/utils/markup.js"); +const { apply } = require("resource://devtools/shared/layout/dom-matrix-2d.js"); +const { + getCurrentZoom, + getDisplayPixelRatio, + getWindowDimensions, + setIgnoreLayoutChanges, +} = require("resource://devtools/shared/layout/utils.js"); +loader.lazyGetter(this, "HighlightersBundle", () => { + return new Localization(["devtools/shared/highlighters.ftl"], true); +}); + +const COLUMNS = "cols"; +const ROWS = "rows"; + +const GRID_FONT_SIZE = 10; +const GRID_FONT_FAMILY = "sans-serif"; +const GRID_AREA_NAME_FONT_SIZE = "20"; + +const GRID_LINES_PROPERTIES = { + edge: { + lineDash: [0, 0], + alpha: 1, + }, + explicit: { + lineDash: [5, 3], + alpha: 0.75, + }, + implicit: { + lineDash: [2, 2], + alpha: 0.5, + }, + areaEdge: { + lineDash: [0, 0], + alpha: 1, + lineWidth: 3, + }, +}; + +const GRID_GAP_PATTERN_WIDTH = 14; // px +const GRID_GAP_PATTERN_HEIGHT = 14; // px +const GRID_GAP_PATTERN_LINE_DASH = [5, 3]; // px +const GRID_GAP_ALPHA = 0.5; + +// This is the minimum distance a line can be to the edge of the document under which we +// push the line number arrow to be inside the grid. This offset is enough to fit the +// entire arrow + a stacked arrow behind it. +const OFFSET_FROM_EDGE = 32; +// This is how much inside the grid we push the arrow. This a factor of the arrow size. +// The goal here is for a row and a column arrow that have both been pushed inside the +// grid, in a corner, not to overlap. +const FLIP_ARROW_INSIDE_FACTOR = 2.5; + +/** + * Given an `edge` of a box, return the name of the edge one move to the right. + */ +function rotateEdgeRight(edge) { + switch (edge) { + case "top": + return "right"; + case "right": + return "bottom"; + case "bottom": + return "left"; + case "left": + return "top"; + default: + return edge; + } +} + +/** + * Given an `edge` of a box, return the name of the edge one move to the left. + */ +function rotateEdgeLeft(edge) { + switch (edge) { + case "top": + return "left"; + case "right": + return "top"; + case "bottom": + return "right"; + case "left": + return "bottom"; + default: + return edge; + } +} + +/** + * Given an `edge` of a box, return the name of the opposite edge. + */ +function reflectEdge(edge) { + switch (edge) { + case "top": + return "bottom"; + case "right": + return "left"; + case "bottom": + return "top"; + case "left": + return "right"; + default: + return edge; + } +} + +/** + * Cached used by `CssGridHighlighter.getGridGapPattern`. + */ +const gCachedGridPattern = new Map(); + +/** + * The CssGridHighlighter is the class that overlays a visual grid on top of + * display:[inline-]grid elements. + * + * Usage example: + * let h = new CssGridHighlighter(env); + * h.show(node, options); + * h.hide(); + * h.destroy(); + * + * @param {String} options.color + * The color that should be used to draw the highlighter for this grid. + * @param {Number} options.globalAlpha + * The alpha (transparency) value that should be used to draw the highlighter for + * this grid. + * @param {Boolean} options.showAllGridAreas + * Shows all the grid area highlights for the current grid if isShown is + * true. + * @param {String} options.showGridArea + * Shows the grid area highlight for the given area name. + * @param {Boolean} options.showGridAreasOverlay + * Displays an overlay of all the grid areas for the current grid + * container if isShown is true. + * @param {Object} options.showGridCell + * An object containing the grid fragment index, row and column numbers + * to the corresponding grid cell to highlight for the current grid. + * @param {Number} options.showGridCell.gridFragmentIndex + * Index of the grid fragment to render the grid cell highlight. + * @param {Number} options.showGridCell.rowNumber + * Row number of the grid cell to highlight. + * @param {Number} options.showGridCell.columnNumber + * Column number of the grid cell to highlight. + * @param {Object} options.showGridLineNames + * An object containing the grid fragment index and line number to the + * corresponding grid line to highlight for the current grid. + * @param {Number} options.showGridLineNames.gridFragmentIndex + * Index of the grid fragment to render the grid line highlight. + * @param {Number} options.showGridLineNames.lineNumber + * Line number of the grid line to highlight. + * @param {String} options.showGridLineNames.type + * The dimension type of the grid line. + * @param {Boolean} options.showGridLineNumbers + * Displays the grid line numbers on the grid lines if isShown is true. + * @param {Boolean} options.showInfiniteLines + * Displays an infinite line to represent the grid lines if isShown is + * true. + * @param {Number} options.zIndex + * The z-index to decide the displaying order. + * + * Structure: + * <div class="highlighter-container"> + * <canvas id="css-grid-canvas" class="css-grid-canvas"> + * <svg class="css-grid-elements" hidden="true"> + * <g class="css-grid-regions"> + * <path class="css-grid-areas" points="..." /> + * <path class="css-grid-cells" points="..." /> + * </g> + * </svg> + * <div class="css-grid-area-infobar-container"> + * <div class="css-grid-infobar"> + * <div class="css-grid-infobar-text"> + * <span class="css-grid-area-infobar-name">Grid Area Name</span> + * <span class="css-grid-area-infobar-dimensions">Grid Area Dimensions></span> + * </div> + * </div> + * </div> + * <div class="css-grid-cell-infobar-container"> + * <div class="css-grid-infobar"> + * <div class="css-grid-infobar-text"> + * <span class="css-grid-cell-infobar-position">Grid Cell Position</span> + * <span class="css-grid-cell-infobar-dimensions">Grid Cell Dimensions></span> + * </div> + * </div> + * <div class="css-grid-line-infobar-container"> + * <div class="css-grid-infobar"> + * <div class="css-grid-infobar-text"> + * <span class="css-grid-line-infobar-number">Grid Line Number</span> + * <span class="css-grid-line-infobar-names">Grid Line Names></span> + * </div> + * </div> + * </div> + * </div> + */ + +class CssGridHighlighter extends AutoRefreshHighlighter { + constructor(highlighterEnv) { + super(highlighterEnv); + + this.ID_CLASS_PREFIX = "css-grid-"; + + this.markup = new CanvasFrameAnonymousContentHelper( + this.highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.markup.initialize(); + + this.onPageHide = this.onPageHide.bind(this); + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("will-navigate", this.onWillNavigate); + + const { pageListenerTarget } = highlighterEnv; + pageListenerTarget.addEventListener("pagehide", this.onPageHide); + + // Initialize the <canvas> position to the top left corner of the page. + this._canvasPosition = { + x: 0, + y: 0, + }; + + // Calling `updateCanvasPosition` anyway since the highlighter could be initialized + // on a page that has scrolled already. + updateCanvasPosition( + this._canvasPosition, + this._scroll, + this.win, + this._winDimensions + ); + } + + _buildMarkup() { + const container = this.markup.createNode({ + attributes: { + class: "highlighter-container", + }, + }); + + const root = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // We use a <canvas> element so that we can draw an arbitrary number of lines + // which wouldn't be possible with HTML or SVG without having to insert and remove + // the whole markup on every update. + this.markup.createNode({ + parent: root, + nodeType: "canvas", + attributes: { + id: "canvas", + class: "canvas", + hidden: "true", + width: CANVAS_SIZE, + height: CANVAS_SIZE, + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the SVG element. + const svg = this.markup.createSVGNode({ + nodeType: "svg", + parent: root, + attributes: { + id: "elements", + width: "100%", + height: "100%", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const regions = this.markup.createSVGNode({ + nodeType: "g", + parent: svg, + attributes: { + class: "regions", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "path", + parent: regions, + attributes: { + class: "areas", + id: "areas", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + this.markup.createSVGNode({ + nodeType: "path", + parent: regions, + attributes: { + class: "cells", + id: "cells", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the grid area infobar markup. + const areaInfobarContainer = this.markup.createNode({ + parent: container, + attributes: { + class: "area-infobar-container", + id: "area-infobar-container", + position: "top", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const areaInfobar = this.markup.createNode({ + parent: areaInfobarContainer, + attributes: { + class: "infobar", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const areaTextbox = this.markup.createNode({ + parent: areaInfobar, + attributes: { + class: "infobar-text", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: areaTextbox, + attributes: { + class: "area-infobar-name", + id: "area-infobar-name", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: areaTextbox, + attributes: { + class: "area-infobar-dimensions", + id: "area-infobar-dimensions", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the grid cell infobar markup. + const cellInfobarContainer = this.markup.createNode({ + parent: container, + attributes: { + class: "cell-infobar-container", + id: "cell-infobar-container", + position: "top", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const cellInfobar = this.markup.createNode({ + parent: cellInfobarContainer, + attributes: { + class: "infobar", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const cellTextbox = this.markup.createNode({ + parent: cellInfobar, + attributes: { + class: "infobar-text", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: cellTextbox, + attributes: { + class: "cell-infobar-position", + id: "cell-infobar-position", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: cellTextbox, + attributes: { + class: "cell-infobar-dimensions", + id: "cell-infobar-dimensions", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Build the grid line infobar markup. + const lineInfobarContainer = this.markup.createNode({ + parent: container, + attributes: { + class: "line-infobar-container", + id: "line-infobar-container", + position: "top", + hidden: "true", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const lineInfobar = this.markup.createNode({ + parent: lineInfobarContainer, + attributes: { + class: "infobar", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const lineTextbox = this.markup.createNode({ + parent: lineInfobar, + attributes: { + class: "infobar-text", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: lineTextbox, + attributes: { + class: "line-infobar-number", + id: "line-infobar-number", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createNode({ + nodeType: "span", + parent: lineTextbox, + attributes: { + class: "line-infobar-names", + id: "line-infobar-names", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + return container; + } + + clearCache() { + gCachedGridPattern.clear(); + } + + /** + * Clear the grid area highlights. + */ + clearGridAreas() { + const areas = this.getElement("areas"); + areas.setAttribute("d", ""); + } + + /** + * Clear the grid cell highlights. + */ + clearGridCell() { + const cells = this.getElement("cells"); + cells.setAttribute("d", ""); + } + + destroy() { + const { highlighterEnv } = this; + highlighterEnv.off("will-navigate", this.onWillNavigate); + + const { pageListenerTarget } = highlighterEnv; + if (pageListenerTarget) { + pageListenerTarget.removeEventListener("pagehide", this.onPageHide); + } + + this.markup.destroy(); + + // Clear the pattern cache to avoid dead object exceptions (Bug 1342051). + this.clearCache(); + AutoRefreshHighlighter.prototype.destroy.call(this); + } + + get canvas() { + return this.getElement("canvas"); + } + + get color() { + return this.options.color || DEFAULT_COLOR; + } + + get ctx() { + return this.canvas.getCanvasContext("2d"); + } + + get globalAlpha() { + return this.options.globalAlpha || 1; + } + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + } + + getFirstColLinePos(fragment) { + return fragment.cols.lines[0].start; + } + + getFirstRowLinePos(fragment) { + return fragment.rows.lines[0].start; + } + + /** + * Gets the grid gap pattern used to render the gap regions based on the device + * pixel ratio given. + * + * @param {Number} devicePixelRatio + * The device pixel ratio we want the pattern for. + * @param {Object} dimension + * Refers to the Map key for the grid dimension type which is either the + * constant COLUMNS or ROWS. + * @return {CanvasPattern} grid gap pattern. + */ + getGridGapPattern(devicePixelRatio, dimension) { + let gridPatternMap = null; + + if (gCachedGridPattern.has(devicePixelRatio)) { + gridPatternMap = gCachedGridPattern.get(devicePixelRatio); + } else { + gridPatternMap = new Map(); + } + + if (gridPatternMap.has(dimension)) { + return gridPatternMap.get(dimension); + } + + // Create the diagonal lines pattern for the rendering the grid gaps. + const canvas = this.markup.createNode({ nodeType: "canvas" }); + const width = (canvas.width = GRID_GAP_PATTERN_WIDTH * devicePixelRatio); + const height = (canvas.height = GRID_GAP_PATTERN_HEIGHT * devicePixelRatio); + + const ctx = canvas.getContext("2d"); + ctx.save(); + ctx.setLineDash(GRID_GAP_PATTERN_LINE_DASH); + ctx.beginPath(); + ctx.translate(0.5, 0.5); + + if (dimension === COLUMNS) { + ctx.moveTo(0, 0); + ctx.lineTo(width, height); + } else { + ctx.moveTo(width, 0); + ctx.lineTo(0, height); + } + + ctx.strokeStyle = this.color; + ctx.globalAlpha = GRID_GAP_ALPHA * this.globalAlpha; + ctx.stroke(); + ctx.restore(); + + const pattern = ctx.createPattern(canvas, "repeat"); + + gridPatternMap.set(dimension, pattern); + gCachedGridPattern.set(devicePixelRatio, gridPatternMap); + + return pattern; + } + + getLastColLinePos(fragment) { + return fragment.cols.lines[fragment.cols.lines.length - 1].start; + } + + /** + * Get the GridLine index of the last edge of the explicit grid for a grid dimension. + * + * @param {GridTracks} tracks + * The grid track of a given grid dimension. + * @return {Number} index of the last edge of the explicit grid for a grid dimension. + */ + getLastEdgeLineIndex(tracks) { + let trackIndex = tracks.length - 1; + + // Traverse the grid track backwards until we find an explicit track. + while (trackIndex >= 0 && tracks[trackIndex].type != "explicit") { + trackIndex--; + } + + // The grid line index is the grid track index + 1. + return trackIndex + 1; + } + + getLastRowLinePos(fragment) { + return fragment.rows.lines[fragment.rows.lines.length - 1].start; + } + + /** + * The AutoRefreshHighlighter's _hasMoved method returns true only if the + * element's quads have changed. Override it so it also returns true if the + * element's grid has changed (which can happen when you change the + * grid-template-* CSS properties with the highlighter displayed). This + * check is prone to false positives, because it does a direct object + * comparison of the first grid fragment structure. This structure is + * generated by the first call to getGridFragments, and on any subsequent + * calls where a reflow is needed. Since a reflow is needed when the CSS + * changes, this will correctly detect that the grid structure has changed. + * However, it's possible that the reflow could generate a novel grid + * fragment object containing information that is unchanged -- a false + * positive. + */ + _hasMoved() { + const hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this); + + const oldFirstGridFragment = this.gridData?.[0]; + this.gridData = this.currentNode.getGridFragments(); + const newFirstGridFragment = this.gridData[0]; + + return hasMoved || oldFirstGridFragment !== newFirstGridFragment; + } + + /** + * Hide the highlighter, the canvas and the infobars. + */ + _hide() { + setIgnoreLayoutChanges(true); + this._hideGrid(); + this._hideGridElements(); + this._hideGridAreaInfoBar(); + this._hideGridCellInfoBar(); + this._hideGridLineInfoBar(); + setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement); + } + + _hideGrid() { + this.getElement("canvas").setAttribute("hidden", "true"); + } + + _hideGridAreaInfoBar() { + this.getElement("area-infobar-container").setAttribute("hidden", "true"); + } + + _hideGridCellInfoBar() { + this.getElement("cell-infobar-container").setAttribute("hidden", "true"); + } + + _hideGridElements() { + this.getElement("elements").setAttribute("hidden", "true"); + } + + _hideGridLineInfoBar() { + this.getElement("line-infobar-container").setAttribute("hidden", "true"); + } + + /** + * Checks if the current node has a CSS Grid layout. + * + * @return {Boolean} true if the current node has a CSS grid layout, false otherwise. + */ + isGrid() { + return this.currentNode.hasGridFragments(); + } + + /** + * Is a given grid fragment valid? i.e. does it actually have tracks? In some cases, we + * may have a fragment that defines column tracks but doesn't have any rows (or vice + * versa). In which case we do not want to draw anything for that fragment. + * + * @param {Object} fragment + * @return {Boolean} + */ + isValidFragment(fragment) { + return fragment.cols.tracks.length && fragment.rows.tracks.length; + } + + /** + * The <canvas>'s position needs to be updated if the page scrolls too much, in order + * to give the illusion that it always covers the viewport. + */ + _scrollUpdate() { + const hasUpdated = updateCanvasPosition( + this._canvasPosition, + this._scroll, + this.win, + this._winDimensions + ); + + if (hasUpdated) { + this._update(); + } + } + + _show() { + if (!this.isGrid()) { + this.hide(); + return false; + } + + // The grid pattern cache should be cleared in case the color changed. + this.clearCache(); + + // Hide the canvas, grid element highlights and infobar. + this._hide(); + + return this._update(); + } + + _showGrid() { + this.getElement("canvas").removeAttribute("hidden"); + } + + _showGridAreaInfoBar() { + this.getElement("area-infobar-container").removeAttribute("hidden"); + } + + _showGridCellInfoBar() { + this.getElement("cell-infobar-container").removeAttribute("hidden"); + } + + _showGridElements() { + this.getElement("elements").removeAttribute("hidden"); + } + + _showGridLineInfoBar() { + this.getElement("line-infobar-container").removeAttribute("hidden"); + } + + /** + * Shows all the grid area highlights for the current grid. + */ + showAllGridAreas() { + this.renderGridArea(); + } + + /** + * Shows the grid area highlight for the given area name. + * + * @param {String} areaName + * Grid area name. + */ + showGridArea(areaName) { + this.renderGridArea(areaName); + } + + /** + * Shows the grid cell highlight for the given grid cell options. + * + * @param {Number} options.gridFragmentIndex + * Index of the grid fragment to render the grid cell highlight. + * @param {Number} options.rowNumber + * Row number of the grid cell to highlight. + * @param {Number} options.columnNumber + * Column number of the grid cell to highlight. + */ + showGridCell({ gridFragmentIndex, rowNumber, columnNumber }) { + this.renderGridCell(gridFragmentIndex, rowNumber, columnNumber); + } + + /** + * Shows the grid line highlight for the given grid line options. + * + * @param {Number} options.gridFragmentIndex + * Index of the grid fragment to render the grid line highlight. + * @param {Number} options.lineNumber + * Line number of the grid line to highlight. + * @param {String} options.type + * The dimension type of the grid line. + */ + showGridLineNames({ gridFragmentIndex, lineNumber, type }) { + this.renderGridLineNames(gridFragmentIndex, lineNumber, type); + } + + /** + * If a page hide event is triggered for current window's highlighter, hide the + * highlighter. + */ + onPageHide({ target }) { + if (target.defaultView === this.win) { + this.hide(); + } + } + + /** + * Called when the page will-navigate. Used to hide the grid highlighter and clear + * the cached gap patterns and avoid using DeadWrapper obejcts as gap patterns the + * next time. + */ + onWillNavigate({ isTopLevel }) { + this.clearCache(); + + if (isTopLevel) { + this.hide(); + } + } + + renderFragment(fragment) { + if (!this.isValidFragment(fragment)) { + return; + } + + this.renderLines( + fragment.cols, + COLUMNS, + this.getFirstRowLinePos(fragment), + this.getLastRowLinePos(fragment) + ); + this.renderLines( + fragment.rows, + ROWS, + this.getFirstColLinePos(fragment), + this.getLastColLinePos(fragment) + ); + + if (this.options.showGridAreasOverlay) { + this.renderGridAreaOverlay(); + } + + // Line numbers are rendered in a 2nd step to avoid overlapping with existing lines. + if (this.options.showGridLineNumbers) { + this.renderLineNumbers( + fragment.cols, + COLUMNS, + this.getFirstRowLinePos(fragment) + ); + this.renderLineNumbers( + fragment.rows, + ROWS, + this.getFirstColLinePos(fragment) + ); + this.renderNegativeLineNumbers( + fragment.cols, + COLUMNS, + this.getLastRowLinePos(fragment) + ); + this.renderNegativeLineNumbers( + fragment.rows, + ROWS, + this.getLastColLinePos(fragment) + ); + } + } + + /** + * Render the grid area highlight for the given area name or for all the grid areas. + * + * @param {String} areaName + * Name of the grid area to be highlighted. If no area name is provided, all + * the grid areas should be highlighted. + */ + renderGridArea(areaName) { + const { devicePixelRatio } = this.win; + const displayPixelRatio = getDisplayPixelRatio(this.win); + const paths = []; + + for (let i = 0; i < this.gridData.length; i++) { + const fragment = this.gridData[i]; + + for (const area of fragment.areas) { + if (areaName && areaName != area.name) { + continue; + } + + const rowStart = fragment.rows.lines[area.rowStart - 1]; + const rowEnd = fragment.rows.lines[area.rowEnd - 1]; + const columnStart = fragment.cols.lines[area.columnStart - 1]; + const columnEnd = fragment.cols.lines[area.columnEnd - 1]; + + const x1 = columnStart.start + columnStart.breadth; + const y1 = rowStart.start + rowStart.breadth; + const x2 = columnEnd.start; + const y2 = rowEnd.start; + + const points = getPointsFromDiagonal( + x1, + y1, + x2, + y2, + this.currentMatrix + ); + + // Scale down by `devicePixelRatio` since SVG element already take them into + // account. + const svgPoints = points.map(point => ({ + x: Math.round(point.x / devicePixelRatio), + y: Math.round(point.y / devicePixelRatio), + })); + + // Scale down by `displayPixelRatio` since infobar's HTML elements already take it + // into account; and the zoom scaling is handled by `moveInfobar`. + const bounds = getBoundsFromPoints( + points.map(point => ({ + x: Math.round(point.x / displayPixelRatio), + y: Math.round(point.y / displayPixelRatio), + })) + ); + + paths.push(getPathDescriptionFromPoints(svgPoints)); + + // Update and show the info bar when only displaying a single grid area. + if (areaName) { + this._showGridAreaInfoBar(); + this._updateGridAreaInfobar(area, bounds); + } + } + } + + const areas = this.getElement("areas"); + areas.setAttribute("d", paths.join(" ")); + } + + /** + * Render grid area name on the containing grid area cell. + * + * @param {Object} fragment + * The grid fragment of the grid container. + * @param {Object} area + * The area overlay to render on the CSS highlighter canvas. + */ + renderGridAreaName(fragment, area) { + const { rowStart, rowEnd, columnStart, columnEnd } = area; + const { devicePixelRatio } = this.win; + const displayPixelRatio = getDisplayPixelRatio(this.win); + const offset = (displayPixelRatio / 2) % 1; + let fontSize = GRID_AREA_NAME_FONT_SIZE * displayPixelRatio; + const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio); + const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio); + + this.ctx.save(); + this.ctx.translate(offset - canvasX, offset - canvasY); + this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY; + this.ctx.globalAlpha = this.globalAlpha; + this.ctx.strokeStyle = this.color; + this.ctx.textAlign = "center"; + this.ctx.textBaseline = "middle"; + + // Draw the text for the grid area name. + for (let rowNumber = rowStart; rowNumber < rowEnd; rowNumber++) { + for ( + let columnNumber = columnStart; + columnNumber < columnEnd; + columnNumber++ + ) { + const row = fragment.rows.tracks[rowNumber - 1]; + const column = fragment.cols.tracks[columnNumber - 1]; + + // If the font size exceeds the bounds of the containing grid cell, size it its + // row or column dimension, whichever is smallest. + if ( + fontSize > column.breadth * displayPixelRatio || + fontSize > row.breadth * displayPixelRatio + ) { + fontSize = Math.min([column.breadth, row.breadth]); + this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY; + } + + const textWidth = this.ctx.measureText(area.name).width; + // The width of the character 'm' approximates the height of the text. + const textHeight = this.ctx.measureText("m").width; + // Padding in pixels for the line number text inside of the line number container. + const padding = 3 * displayPixelRatio; + + const boxWidth = textWidth + 2 * padding; + const boxHeight = textHeight + 2 * padding; + + let x = column.start + column.breadth / 2; + let y = row.start + row.breadth / 2; + + [x, y] = apply(this.currentMatrix, [x, y]); + + const rectXPos = x - boxWidth / 2; + const rectYPos = y - boxHeight / 2; + + // Draw a rounded rectangle with a border width of 1 pixel, + // a border color matching the grid color, and a white background. + this.ctx.lineWidth = 1 * displayPixelRatio; + this.ctx.strokeStyle = this.color; + this.ctx.fillStyle = "white"; + const radius = 2 * displayPixelRatio; + drawRoundedRect( + this.ctx, + rectXPos, + rectYPos, + boxWidth, + boxHeight, + radius + ); + + this.ctx.fillStyle = this.color; + this.ctx.fillText(area.name, x, y + padding); + } + } + + this.ctx.restore(); + } + + /** + * Renders the grid area overlay on the css grid highlighter canvas. + */ + renderGridAreaOverlay() { + const padding = 1; + + for (let i = 0; i < this.gridData.length; i++) { + const fragment = this.gridData[i]; + + for (const area of fragment.areas) { + const { rowStart, rowEnd, columnStart, columnEnd, type } = area; + + if (type === "implicit") { + continue; + } + + // Draw the line edges for the grid area. + const areaColStart = fragment.cols.lines[columnStart - 1]; + const areaColEnd = fragment.cols.lines[columnEnd - 1]; + + const areaRowStart = fragment.rows.lines[rowStart - 1]; + const areaRowEnd = fragment.rows.lines[rowEnd - 1]; + + const areaColStartLinePos = areaColStart.start + areaColStart.breadth; + const areaRowStartLinePos = areaRowStart.start + areaRowStart.breadth; + + this.renderLine( + areaColStartLinePos + padding, + areaRowStartLinePos, + areaRowEnd.start, + COLUMNS, + "areaEdge" + ); + this.renderLine( + areaColEnd.start - padding, + areaRowStartLinePos, + areaRowEnd.start, + COLUMNS, + "areaEdge" + ); + + this.renderLine( + areaRowStartLinePos + padding, + areaColStartLinePos, + areaColEnd.start, + ROWS, + "areaEdge" + ); + this.renderLine( + areaRowEnd.start - padding, + areaColStartLinePos, + areaColEnd.start, + ROWS, + "areaEdge" + ); + + this.renderGridAreaName(fragment, area); + } + } + } + + /** + * Render the grid cell highlight for the given grid fragment index, row and column + * number. + * + * @param {Number} gridFragmentIndex + * Index of the grid fragment to render the grid cell highlight. + * @param {Number} rowNumber + * Row number of the grid cell to highlight. + * @param {Number} columnNumber + * Column number of the grid cell to highlight. + */ + renderGridCell(gridFragmentIndex, rowNumber, columnNumber) { + const fragment = this.gridData[gridFragmentIndex]; + + if (!fragment) { + return; + } + + const row = fragment.rows.tracks[rowNumber - 1]; + const column = fragment.cols.tracks[columnNumber - 1]; + + if (!row || !column) { + return; + } + + const x1 = column.start; + const y1 = row.start; + const x2 = column.start + column.breadth; + const y2 = row.start + row.breadth; + + const { devicePixelRatio } = this.win; + const displayPixelRatio = getDisplayPixelRatio(this.win); + const points = getPointsFromDiagonal(x1, y1, x2, y2, this.currentMatrix); + + // Scale down by `devicePixelRatio` since SVG element already take them into account. + const svgPoints = points.map(point => ({ + x: Math.round(point.x / devicePixelRatio), + y: Math.round(point.y / devicePixelRatio), + })); + + // Scale down by `displayPixelRatio` since infobar's HTML elements already take it + // into account, and the zoom scaling is handled by `moveInfobar`. + const bounds = getBoundsFromPoints( + points.map(point => ({ + x: Math.round(point.x / displayPixelRatio), + y: Math.round(point.y / displayPixelRatio), + })) + ); + + const cells = this.getElement("cells"); + cells.setAttribute("d", getPathDescriptionFromPoints(svgPoints)); + + this._showGridCellInfoBar(); + this._updateGridCellInfobar(rowNumber, columnNumber, bounds); + } + + /** + * Render the grid gap area on the css grid highlighter canvas. + * + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {Number} endPos + * The end position of the cross side of the grid line. + * @param {Number} breadth + * The grid line breadth value. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + */ + renderGridGap(linePos, startPos, endPos, breadth, dimensionType) { + const { devicePixelRatio } = this.win; + const displayPixelRatio = getDisplayPixelRatio(this.win); + const offset = (displayPixelRatio / 2) % 1; + const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio); + const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio); + + linePos = Math.round(linePos); + startPos = Math.round(startPos); + breadth = Math.round(breadth); + + this.ctx.save(); + this.ctx.fillStyle = this.getGridGapPattern( + devicePixelRatio, + dimensionType + ); + this.ctx.translate(offset - canvasX, offset - canvasY); + + if (dimensionType === COLUMNS) { + if (isFinite(endPos)) { + endPos = Math.round(endPos); + } else { + endPos = this._winDimensions.height; + startPos = -endPos; + } + drawRect( + this.ctx, + linePos, + startPos, + linePos + breadth, + endPos, + this.currentMatrix + ); + } else { + if (isFinite(endPos)) { + endPos = Math.round(endPos); + } else { + endPos = this._winDimensions.width; + startPos = -endPos; + } + drawRect( + this.ctx, + startPos, + linePos, + endPos, + linePos + breadth, + this.currentMatrix + ); + } + + // Find current angle of grid by measuring the angle of two arbitrary points, + // then rotate canvas, so the hash pattern stays 45deg to the gridlines. + const p1 = apply(this.currentMatrix, [0, 0]); + const p2 = apply(this.currentMatrix, [1, 0]); + const angleRad = Math.atan2(p2[1] - p1[1], p2[0] - p1[0]); + this.ctx.rotate(angleRad); + + this.ctx.fill(); + this.ctx.restore(); + } + + /** + * Render the grid line name highlight for the given grid fragment index, lineNumber, + * and dimensionType. + * + * @param {Number} gridFragmentIndex + * Index of the grid fragment to render the grid line highlight. + * @param {Number} lineNumber + * Line number of the grid line to highlight. + * @param {String} dimensionType + * The dimension type of the grid line. + */ + renderGridLineNames(gridFragmentIndex, lineNumber, dimensionType) { + const fragment = this.gridData[gridFragmentIndex]; + + if (!fragment || !lineNumber || !dimensionType) { + return; + } + + const { names } = fragment[dimensionType].lines[lineNumber - 1]; + let linePos; + + if (dimensionType === ROWS) { + linePos = fragment.rows.lines[lineNumber - 1]; + } else if (dimensionType === COLUMNS) { + linePos = fragment.cols.lines[lineNumber - 1]; + } + + if (!linePos) { + return; + } + + const currentZoom = getCurrentZoom(this.win); + const { bounds } = this.currentQuads.content[gridFragmentIndex]; + + const rowYPosition = fragment.rows.lines[0]; + const colXPosition = fragment.rows.lines[0]; + + const x = + dimensionType === COLUMNS + ? linePos.start + bounds.left / currentZoom + : colXPosition.start + bounds.left / currentZoom; + + const y = + dimensionType === ROWS + ? linePos.start + bounds.top / currentZoom + : rowYPosition.start + bounds.top / currentZoom; + + this._showGridLineInfoBar(); + this._updateGridLineInfobar(names.join(", "), lineNumber, x, y); + } + + /** + * Render the grid line number on the css grid highlighter canvas. + * + * @param {Number} lineNumber + * The grid line number. + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {Number} breadth + * The grid line breadth value. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {Boolean||undefined} isStackedLine + * Boolean indicating if the line is stacked. + */ + // eslint-disable-next-line complexity + renderGridLineNumber( + lineNumber, + linePos, + startPos, + breadth, + dimensionType, + isStackedLine + ) { + const displayPixelRatio = getDisplayPixelRatio(this.win); + const { devicePixelRatio } = this.win; + const offset = (displayPixelRatio / 2) % 1; + const fontSize = GRID_FONT_SIZE * devicePixelRatio; + const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio); + const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio); + + linePos = Math.round(linePos); + startPos = Math.round(startPos); + breadth = Math.round(breadth); + + if (linePos + breadth < 0) { + // Don't render the line number since the line is not visible on screen. + return; + } + + this.ctx.save(); + this.ctx.translate(offset - canvasX, offset - canvasY); + this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY; + + // For a general grid box, the height of the character "m" will be its minimum width + // and height. If line number's text width is greater, then use the grid box's text + // width instead. + const textHeight = this.ctx.measureText("m").width; + const textWidth = Math.max( + textHeight, + this.ctx.measureText(lineNumber).width + ); + + // Padding in pixels for the line number text inside of the line number container. + const padding = 3 * devicePixelRatio; + const offsetFromEdge = 2 * devicePixelRatio; + + let boxWidth = textWidth + 2 * padding; + let boxHeight = textHeight + 2 * padding; + + // Calculate the x & y coordinates for the line number container, so that its arrow + // tip is centered on the line (or the gap if there is one), and is offset by the + // calculated padding value from the grid container edge. + let x, y; + + if (dimensionType === COLUMNS) { + x = linePos + breadth / 2; + y = + lineNumber > 0 ? startPos - offsetFromEdge : startPos + offsetFromEdge; + } else if (dimensionType === ROWS) { + y = linePos + breadth / 2; + x = + lineNumber > 0 ? startPos - offsetFromEdge : startPos + offsetFromEdge; + } + + [x, y] = apply(this.currentMatrix, [x, y]); + + // Draw a bubble rectangular arrow with a border width of 2 pixels, a border color + // matching the grid color and a white background (the line number will be written in + // black). + this.ctx.lineWidth = 2 * displayPixelRatio; + this.ctx.strokeStyle = this.color; + this.ctx.fillStyle = "white"; + this.ctx.globalAlpha = this.globalAlpha; + + // See param definitions of drawBubbleRect. + const radius = 2 * displayPixelRatio; + const margin = 2 * displayPixelRatio; + const arrowSize = 8 * displayPixelRatio; + + const minBoxSize = arrowSize * 2 + padding; + boxWidth = Math.max(boxWidth, minBoxSize); + boxHeight = Math.max(boxHeight, minBoxSize); + + // Determine which edge of the box to aim the line number arrow at. + const boxEdge = this.getBoxEdge(dimensionType, lineNumber); + + let { width, height } = this._winDimensions; + width *= displayPixelRatio; + height *= displayPixelRatio; + + // Don't draw if the line is out of the viewport. + if ( + (dimensionType === ROWS && (y < 0 || y > height)) || + (dimensionType === COLUMNS && (x < 0 || x > width)) + ) { + this.ctx.restore(); + return; + } + + // If the arrow's edge (the one perpendicular to the line direction) is too close to + // the edge of the viewport. Push the arrow inside the grid. + const minOffsetFromEdge = OFFSET_FROM_EDGE * displayPixelRatio; + switch (boxEdge) { + case "left": + if (x < minOffsetFromEdge) { + x += FLIP_ARROW_INSIDE_FACTOR * boxWidth; + } + break; + case "right": + if (width - x < minOffsetFromEdge) { + x -= FLIP_ARROW_INSIDE_FACTOR * boxWidth; + } + break; + case "top": + if (y < minOffsetFromEdge) { + y += FLIP_ARROW_INSIDE_FACTOR * boxHeight; + } + break; + case "bottom": + if (height - y < minOffsetFromEdge) { + y -= FLIP_ARROW_INSIDE_FACTOR * boxHeight; + } + break; + } + + // Offset stacked line numbers by a quarter of the box's width/height, so a part of + // them remains visible behind the number that sits at the top of the stack. + if (isStackedLine) { + const xOffset = boxWidth / 4; + const yOffset = boxHeight / 4; + + if (lineNumber > 0) { + x -= xOffset; + y -= yOffset; + } else { + x += xOffset; + y += yOffset; + } + } + + // If one the edges of the arrow that's parallel to the line is too close to the edge + // of the viewport (and therefore partly hidden), grow the arrow's size in the + // opposite direction. + // The goal is for the part that's not hidden to be exactly the size of a normal + // arrow and for the arrow to keep pointing at the line (keep being centered on it). + let grewBox = false; + const boxWidthBeforeGrowth = boxWidth; + const boxHeightBeforeGrowth = boxHeight; + + if (dimensionType === ROWS && y <= boxHeight / 2) { + grewBox = true; + boxHeight = 2 * (boxHeight - y); + } else if (dimensionType === ROWS && y >= height - boxHeight / 2) { + grewBox = true; + boxHeight = 2 * (y - height + boxHeight); + } else if (dimensionType === COLUMNS && x <= boxWidth / 2) { + grewBox = true; + boxWidth = 2 * (boxWidth - x); + } else if (dimensionType === COLUMNS && x >= width - boxWidth / 2) { + grewBox = true; + boxWidth = 2 * (x - width + boxWidth); + } + + // Draw the arrow box itself + drawBubbleRect( + this.ctx, + x, + y, + boxWidth, + boxHeight, + radius, + margin, + arrowSize, + boxEdge + ); + + // Determine the text position for it to be centered nicely inside the arrow box. + switch (boxEdge) { + case "left": + x -= boxWidth + arrowSize + radius - boxWidth / 2; + break; + case "right": + x += boxWidth + arrowSize + radius - boxWidth / 2; + break; + case "top": + y -= boxHeight + arrowSize + radius - boxHeight / 2; + break; + case "bottom": + y += boxHeight + arrowSize + radius - boxHeight / 2; + break; + } + + // Do a second pass to adjust the position, along the other axis, if the box grew + // during the previous step, so the text is also centered on that axis. + if (grewBox) { + if (dimensionType === ROWS && y <= boxHeightBeforeGrowth / 2) { + y = boxHeightBeforeGrowth / 2; + } else if ( + dimensionType === ROWS && + y >= height - boxHeightBeforeGrowth / 2 + ) { + y = height - boxHeightBeforeGrowth / 2; + } else if (dimensionType === COLUMNS && x <= boxWidthBeforeGrowth / 2) { + x = boxWidthBeforeGrowth / 2; + } else if ( + dimensionType === COLUMNS && + x >= width - boxWidthBeforeGrowth / 2 + ) { + x = width - boxWidthBeforeGrowth / 2; + } + } + + // Write the line number inside of the rectangle. + this.ctx.textAlign = "center"; + this.ctx.textBaseline = "middle"; + this.ctx.fillStyle = "black"; + const numberText = isStackedLine ? "" : lineNumber; + this.ctx.fillText(numberText, x, y); + this.ctx.restore(); + } + + /** + * Determine which edge of a line number box to aim the line number arrow at. + * + * @param {String} dimensionType + * The grid line dimension type which is either the constant COLUMNS or ROWS. + * @param {Number} lineNumber + * The grid line number. + * @return {String} The edge of the box: top, right, bottom or left. + */ + getBoxEdge(dimensionType, lineNumber) { + let boxEdge; + + if (dimensionType === COLUMNS) { + boxEdge = lineNumber > 0 ? "top" : "bottom"; + } else if (dimensionType === ROWS) { + boxEdge = lineNumber > 0 ? "left" : "right"; + } + + // Rotate box edge as needed for writing mode and text direction. + const { direction, writingMode } = getComputedStyle(this.currentNode); + + switch (writingMode) { + case "horizontal-tb": + // This is the initial value. No further adjustment needed. + break; + case "vertical-rl": + boxEdge = rotateEdgeRight(boxEdge); + break; + case "vertical-lr": + if (dimensionType === COLUMNS) { + boxEdge = rotateEdgeLeft(boxEdge); + } else { + boxEdge = rotateEdgeRight(boxEdge); + } + break; + case "sideways-rl": + boxEdge = rotateEdgeRight(boxEdge); + break; + case "sideways-lr": + boxEdge = rotateEdgeLeft(boxEdge); + break; + default: + console.error(`Unexpected writing-mode: ${writingMode}`); + } + + switch (direction) { + case "ltr": + // This is the initial value. No further adjustment needed. + break; + case "rtl": + if (dimensionType === ROWS) { + boxEdge = reflectEdge(boxEdge); + } + break; + default: + console.error(`Unexpected direction: ${direction}`); + } + + return boxEdge; + } + + /** + * Render the grid line on the css grid highlighter canvas. + * + * @param {Number} linePos + * The line position along the x-axis for a column grid line and + * y-axis for a row grid line. + * @param {Number} startPos + * The start position of the cross side of the grid line. + * @param {Number} endPos + * The end position of the cross side of the grid line. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {String} lineType + * The grid line type - "edge", "explicit", or "implicit". + */ + renderLine(linePos, startPos, endPos, dimensionType, lineType) { + const { devicePixelRatio } = this.win; + const lineWidth = getDisplayPixelRatio(this.win); + const offset = (lineWidth / 2) % 1; + const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio); + const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio); + + linePos = Math.round(linePos); + startPos = Math.round(startPos); + endPos = Math.round(endPos); + + this.ctx.save(); + this.ctx.setLineDash(GRID_LINES_PROPERTIES[lineType].lineDash); + this.ctx.translate(offset - canvasX, offset - canvasY); + + const lineOptions = { + matrix: this.currentMatrix, + }; + + if (this.options.showInfiniteLines) { + lineOptions.extendToBoundaries = [ + canvasX, + canvasY, + canvasX + CANVAS_SIZE, + canvasY + CANVAS_SIZE, + ]; + } + + if (dimensionType === COLUMNS) { + drawLine(this.ctx, linePos, startPos, linePos, endPos, lineOptions); + } else { + drawLine(this.ctx, startPos, linePos, endPos, linePos, lineOptions); + } + + this.ctx.strokeStyle = this.color; + this.ctx.globalAlpha = + GRID_LINES_PROPERTIES[lineType].alpha * this.globalAlpha; + + if (GRID_LINES_PROPERTIES[lineType].lineWidth) { + this.ctx.lineWidth = + GRID_LINES_PROPERTIES[lineType].lineWidth * devicePixelRatio; + } else { + this.ctx.lineWidth = lineWidth; + } + + this.ctx.stroke(); + this.ctx.restore(); + } + + /** + * Render the grid lines given the grid dimension information of the + * column or row lines. + * + * @param {GridDimension} gridDimension + * Column or row grid dimension object. + * @param {Object} quad.bounds + * The content bounds of the box model region quads. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {Number} startPos + * The start position of the cross side ("left" for ROWS and "top" for COLUMNS) + * of the grid dimension. + * @param {Number} endPos + * The end position of the cross side ("left" for ROWS and "top" for COLUMNS) + * of the grid dimension. + */ + renderLines(gridDimension, dimensionType, startPos, endPos) { + const { lines, tracks } = gridDimension; + const lastEdgeLineIndex = this.getLastEdgeLineIndex(tracks); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const linePos = line.start; + + if (i == 0 || i == lastEdgeLineIndex) { + this.renderLine(linePos, startPos, endPos, dimensionType, "edge"); + } else { + this.renderLine( + linePos, + startPos, + endPos, + dimensionType, + tracks[i - 1].type + ); + } + + // Render a second line to illustrate the gutter for non-zero breadth. + if (line.breadth > 0) { + this.renderGridGap( + linePos, + startPos, + endPos, + line.breadth, + dimensionType + ); + this.renderLine( + linePos + line.breadth, + startPos, + endPos, + dimensionType, + tracks[i].type + ); + } + } + } + + /** + * Render the grid lines given the grid dimension information of the + * column or row lines. + * + * @param {GridDimension} gridDimension + * Column or row grid dimension object. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {Number} startPos + * The start position of the cross side ("left" for ROWS and "top" for COLUMNS) + * of the grid dimension. + */ + renderLineNumbers(gridDimension, dimensionType, startPos) { + const { lines, tracks } = gridDimension; + + for (let i = 0, line; (line = lines[i++]); ) { + // If you place something using negative numbers, you can trigger some implicit + // grid creation above and to the left of the explicit grid (assuming a + // horizontal-tb writing mode). + // + // The first explicit grid line gets the number of 1, and any implicit grid lines + // before 1 get negative numbers. Since here we're rendering only the positive line + // numbers, we have to skip any implicit grid lines before the first one that is + // explicit. The API returns a 0 as the line's number for these implicit lines that + // occurs before the first explicit line. + if (line.number === 0) { + continue; + } + + // Check for overlapping lines by measuring the track width between them. + // We render a second box beneath the last overlapping + // line number to indicate there are lines beneath it. + const gridTrack = tracks[i - 1]; + + if (gridTrack) { + const { breadth } = gridTrack; + + if (breadth === 0) { + this.renderGridLineNumber( + line.number, + line.start, + startPos, + line.breadth, + dimensionType, + true + ); + continue; + } + } + + this.renderGridLineNumber( + line.number, + line.start, + startPos, + line.breadth, + dimensionType + ); + } + } + + /** + * Render the negative grid lines given the grid dimension information of the + * column or row lines. + * + * @param {GridDimension} gridDimension + * Column or row grid dimension object. + * @param {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + * @param {Number} startPos + * The start position of the cross side ("left" for ROWS and "top" for COLUMNS) + * of the grid dimension. + */ + renderNegativeLineNumbers(gridDimension, dimensionType, startPos) { + const { lines, tracks } = gridDimension; + + for (let i = 0, line; (line = lines[i++]); ) { + const linePos = line.start; + const negativeLineNumber = line.negativeNumber; + + // Don't render any negative line number greater than -1. + if (negativeLineNumber == 0) { + break; + } + + // Check for overlapping lines by measuring the track width between them. + // We render a second box beneath the last overlapping + // line number to indicate there are lines beneath it. + const gridTrack = tracks[i - 1]; + if (gridTrack) { + const { breadth } = gridTrack; + + // Ensure "-1" is always visible, since it is always the largest number. + if (breadth === 0 && negativeLineNumber != -1) { + this.renderGridLineNumber( + negativeLineNumber, + linePos, + startPos, + line.breadth, + dimensionType, + true + ); + continue; + } + } + + this.renderGridLineNumber( + negativeLineNumber, + linePos, + startPos, + line.breadth, + dimensionType + ); + } + } + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). Should be called whenever node's geometry + * or grid changes. + */ + _update() { + setIgnoreLayoutChanges(true); + + // Set z-index. + this.markup.content.root.firstElementChild.style.setProperty( + "z-index", + this.options.zIndex + ); + + const root = this.getElement("root"); + const cells = this.getElement("cells"); + const areas = this.getElement("areas"); + + // Set the grid cells and areas fill to the current grid colour. + cells.setAttribute("style", `fill: ${this.color}`); + areas.setAttribute("style", `fill: ${this.color}`); + + // Hide the root element and force the reflow in order to get the proper window's + // dimensions without increasing them. + root.setAttribute("style", "display: none"); + this.win.document.documentElement.offsetWidth; + this._winDimensions = getWindowDimensions(this.win); + const { width, height } = this._winDimensions; + + // Updates the <canvas> element's position and size. + // It also clear the <canvas>'s drawing context. + updateCanvasElement( + this.canvas, + this._canvasPosition, + this.win.devicePixelRatio + ); + + // Clear the grid area highlights. + this.clearGridAreas(); + this.clearGridCell(); + + // Update the current matrix used in our canvas' rendering. + const { currentMatrix, hasNodeTransformations } = getCurrentMatrix( + this.currentNode, + this.win + ); + this.currentMatrix = currentMatrix; + this.hasNodeTransformations = hasNodeTransformations; + + // Start drawing the grid fragments. + for (let i = 0; i < this.gridData.length; i++) { + this.renderFragment(this.gridData[i]); + } + + // Display the grid area highlights if needed. + if (this.options.showAllGridAreas) { + this.showAllGridAreas(); + } else if (this.options.showGridArea) { + this.showGridArea(this.options.showGridArea); + } + + // Display the grid cell highlights if needed. + if (this.options.showGridCell) { + this.showGridCell(this.options.showGridCell); + } + + // Display the grid line names if needed. + if (this.options.showGridLineNames) { + this.showGridLineNames(this.options.showGridLineNames); + } + + this._showGrid(); + this._showGridElements(); + + root.setAttribute( + "style", + `position: absolute; width: ${width}px; height: ${height}px; overflow: hidden` + ); + + setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement); + return true; + } + + /** + * Update the grid information displayed in the grid area info bar. + * + * @param {GridArea} area + * The grid area object. + * @param {Object} bounds + * A DOMRect-like object represent the grid area rectangle. + */ + _updateGridAreaInfobar(area, bounds) { + const { width, height } = bounds; + const dim = + parseFloat(width.toPrecision(6)) + + " \u00D7 " + + parseFloat(height.toPrecision(6)); + + this.getElement("area-infobar-name").setTextContent(area.name); + this.getElement("area-infobar-dimensions").setTextContent(dim); + + const container = this.getElement("area-infobar-container"); + moveInfobar(container, bounds, this.win, { + position: "bottom", + }); + } + + /** + * Update the grid information displayed in the grid cell info bar. + * + * @param {Number} rowNumber + * The grid cell's row number. + * @param {Number} columnNumber + * The grid cell's column number. + * @param {Object} bounds + * A DOMRect-like object represent the grid cell rectangle. + */ + _updateGridCellInfobar(rowNumber, columnNumber, bounds) { + const { width, height } = bounds; + const dim = + parseFloat(width.toPrecision(6)) + + " \u00D7 " + + parseFloat(height.toPrecision(6)); + const position = HighlightersBundle.formatValueSync( + "grid-row-column-positions", + { row: rowNumber, column: columnNumber } + ); + + this.getElement("cell-infobar-position").setTextContent(position); + this.getElement("cell-infobar-dimensions").setTextContent(dim); + + const container = this.getElement("cell-infobar-container"); + moveInfobar(container, bounds, this.win, { + position: "top", + }); + } + + /** + * Update the grid information displayed in the grid line info bar. + * + * @param {String} gridLineNames + * Comma-separated string of names for the grid line. + * @param {Number} gridLineNumber + * The grid line number. + * @param {Number} x + * The x-coordinate of the grid line. + * @param {Number} y + * The y-coordinate of the grid line. + */ + _updateGridLineInfobar(gridLineNames, gridLineNumber, x, y) { + this.getElement("line-infobar-number").setTextContent(gridLineNumber); + this.getElement("line-infobar-names").setTextContent(gridLineNames); + + const container = this.getElement("line-infobar-container"); + moveInfobar( + container, + getBoundsFromPoints([ + { x, y }, + { x, y }, + { x, y }, + { x, y }, + ]), + this.win + ); + } +} + +exports.CssGridHighlighter = CssGridHighlighter; diff --git a/devtools/server/actors/highlighters/css-transform.js b/devtools/server/actors/highlighters/css-transform.js new file mode 100644 index 0000000000..c9f16b42e0 --- /dev/null +++ b/devtools/server/actors/highlighters/css-transform.js @@ -0,0 +1,265 @@ +/* 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 { + AutoRefreshHighlighter, +} = require("resource://devtools/server/actors/highlighters/auto-refresh.js"); +const { + CanvasFrameAnonymousContentHelper, + getComputedStyle, +} = require("resource://devtools/server/actors/highlighters/utils/markup.js"); +const { + setIgnoreLayoutChanges, + getNodeBounds, +} = require("resource://devtools/shared/layout/utils.js"); + +// The minimum distance a line should be before it has an arrow marker-end +const ARROW_LINE_MIN_DISTANCE = 10; + +var MARKER_COUNTER = 1; + +/** + * The CssTransformHighlighter is the class that draws an outline around a + * transformed element and an outline around where it would be if untransformed + * as well as arrows connecting the 2 outlines' corners. + */ +class CssTransformHighlighter extends AutoRefreshHighlighter { + constructor(highlighterEnv) { + super(highlighterEnv); + + this.ID_CLASS_PREFIX = "css-transform-"; + + this.markup = new CanvasFrameAnonymousContentHelper( + this.highlighterEnv, + this._buildMarkup.bind(this) + ); + this.isReady = this.markup.initialize(); + } + + _buildMarkup() { + const container = this.markup.createNode({ + attributes: { + class: "highlighter-container", + }, + }); + + // The root wrapper is used to unzoom the highlighter when needed. + const rootWrapper = this.markup.createNode({ + parent: container, + attributes: { + id: "root", + class: "root", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + const svg = this.markup.createSVGNode({ + nodeType: "svg", + parent: rootWrapper, + attributes: { + id: "elements", + hidden: "true", + width: "100%", + height: "100%", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Add a marker tag to the svg root for the arrow tip + this.markerId = "arrow-marker-" + MARKER_COUNTER; + MARKER_COUNTER++; + const marker = this.markup.createSVGNode({ + nodeType: "marker", + parent: svg, + attributes: { + id: this.markerId, + markerWidth: "10", + markerHeight: "5", + orient: "auto", + markerUnits: "strokeWidth", + refX: "10", + refY: "5", + viewBox: "0 0 10 10", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createSVGNode({ + nodeType: "path", + parent: marker, + attributes: { + d: "M 0 0 L 10 5 L 0 10 z", + fill: "#08C", + }, + }); + + const shapesGroup = this.markup.createSVGNode({ + nodeType: "g", + parent: svg, + }); + + // Create the 2 polygons (transformed and untransformed) + this.markup.createSVGNode({ + nodeType: "polygon", + parent: shapesGroup, + attributes: { + id: "untransformed", + class: "untransformed", + }, + prefix: this.ID_CLASS_PREFIX, + }); + this.markup.createSVGNode({ + nodeType: "polygon", + parent: shapesGroup, + attributes: { + id: "transformed", + class: "transformed", + }, + prefix: this.ID_CLASS_PREFIX, + }); + + // Create the arrows + for (const nb of ["1", "2", "3", "4"]) { + this.markup.createSVGNode({ + nodeType: "line", + parent: shapesGroup, + attributes: { + id: "line" + nb, + class: "line", + "marker-end": "url(#" + this.markerId + ")", + }, + prefix: this.ID_CLASS_PREFIX, + }); + } + + return container; + } + + /** + * Destroy the nodes. Remove listeners. + */ + destroy() { + AutoRefreshHighlighter.prototype.destroy.call(this); + this.markup.destroy(); + } + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + } + + /** + * Show the highlighter on a given node + */ + _show() { + if (!this._isTransformed(this.currentNode)) { + this.hide(); + return false; + } + + return this._update(); + } + + /** + * Checks if the supplied node is transformed and not inline + */ + _isTransformed(node) { + const style = getComputedStyle(node); + return style && style.transform !== "none" && style.display !== "inline"; + } + + _setPolygonPoints(quad, id) { + const points = []; + for (const point of ["p1", "p2", "p3", "p4"]) { + points.push(quad[point].x + "," + quad[point].y); + } + this.getElement(id).setAttribute("points", points.join(" ")); + } + + _setLinePoints(p1, p2, id) { + const line = this.getElement(id); + line.setAttribute("x1", p1.x); + line.setAttribute("y1", p1.y); + line.setAttribute("x2", p2.x); + line.setAttribute("y2", p2.y); + + const dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + if (dist < ARROW_LINE_MIN_DISTANCE) { + line.removeAttribute("marker-end"); + } else { + line.setAttribute("marker-end", "url(#" + this.markerId + ")"); + } + } + + /** + * Update the highlighter on the current highlighted node (the one that was + * passed as an argument to show(node)). + * Should be called whenever node size or attributes change + */ + _update() { + setIgnoreLayoutChanges(true); + + // Getting the points for the transformed shape + const quads = this.currentQuads.border; + if ( + !quads.length || + quads[0].bounds.width <= 0 || + quads[0].bounds.height <= 0 + ) { + this._hideShapes(); + return false; + } + + const [quad] = quads; + + // Getting the points for the untransformed shape + const untransformedQuad = getNodeBounds(this.win, this.currentNode); + + this._setPolygonPoints(quad, "transformed"); + this._setPolygonPoints(untransformedQuad, "untransformed"); + for (const nb of ["1", "2", "3", "4"]) { + this._setLinePoints( + untransformedQuad["p" + nb], + quad["p" + nb], + "line" + nb + ); + } + + // Adapt to the current zoom + this.markup.scaleRootElement( + this.currentNode, + this.ID_CLASS_PREFIX + "root" + ); + + this._showShapes(); + + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + return true; + } + + /** + * Hide the highlighter, the outline and the infobar. + */ + _hide() { + setIgnoreLayoutChanges(true); + this._hideShapes(); + setIgnoreLayoutChanges( + false, + this.highlighterEnv.window.document.documentElement + ); + } + + _hideShapes() { + this.getElement("elements").setAttribute("hidden", "true"); + } + + _showShapes() { + this.getElement("elements").removeAttribute("hidden"); + } +} + +exports.CssTransformHighlighter = CssTransformHighlighter; diff --git a/devtools/server/actors/highlighters/css/highlighters.css b/devtools/server/actors/highlighters/css/highlighters.css new file mode 100644 index 0000000000..33c8a04aae --- /dev/null +++ b/devtools/server/actors/highlighters/css/highlighters.css @@ -0,0 +1,1059 @@ +/* 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/. */ + +:host { display: contents; } + +.highlighter-container { + --highlighter-accessibility-bounds-color: #6a5acd; + --highlighter-accessibility-bounds-opacity: 0.6; + --highlighter-box-border-color: #444444; + --highlighter-box-content-color: hsl(197, 71%, 73%); + --highlighter-box-margin-color: #edff64; + --highlighter-box-padding-color: #6a5acd; + --highlighter-bubble-text-color: hsl(216, 33%, 97%); + --highlighter-bubble-background-color: hsl(214, 13%, 24%); + --highlighter-bubble-border-color: rgba(255, 255, 255, 0.2); + --highlighter-bubble-arrow-size: 8px; + --highlighter-font-family: message-box; + --highlighter-font-size: 11px; + --highlighter-guide-color: hsl(200, 100%, 40%); + --highlighter-infobar-color: hsl(210, 30%, 85%); + + --grey-40: #b1b1b3; + --red-40: #ff3b6b; + --yellow-60: #d7b600; + --blue-60: #0060df; +} + +/** + * Highlighters are absolute positioned in the page by default. + * A single highlighter can have fixed position in its css class if needed (see below the + * eye dropper or rulers highlighter, for example); but if it has to handle the + * document's scrolling (as rulers does), it would lag a bit behind due the APZ (Async + * Pan/Zoom module), that performs asynchronously panning and zooming on the compositor + * thread rather than the main thread. + */ +.highlighter-container { + position: absolute; + width: 100%; + height: 100%; + /* The container for all highlighters doesn't react to pointer-events by + default. This is because most highlighters cover the whole viewport but + don't contain UIs that need to be accessed. + If your highlighter has UI that needs to be interacted with, add + 'pointer-events:auto;' on its container element. */ + pointer-events: none; +} + +.highlighter-container.box-model { + /* Make the box-model container have a z-index other than auto so it always sits above + other highlighters. */ + z-index: 1; +} + +.highlighter-container [hidden] { + display: none !important; +} + +.highlighter-container [dragging] { + cursor: grabbing; +} + +/* Box Model Highlighter */ + +.box-model-regions { + opacity: 0.6; +} + +/* Box model regions can be faded (see the onlyRegionArea option in + highlighters.js) in order to only display certain regions. */ +.box-model-regions [faded] { + display: none; +} + +.box-model-content { + fill: var(--highlighter-box-content-color); +} + +.box-model-padding { + fill: var(--highlighter-box-padding-color); +} + +.box-model-border { + fill: var(--highlighter-box-border-color); +} + +.box-model-margin { + fill: var(--highlighter-box-margin-color); +} + +.box-model-content, +.box-model-padding, +.box-model-border, +.box-model-margin { + stroke: none; +} + +.box-model-guide-top, +.box-model-guide-right, +.box-model-guide-bottom, +.box-model-guide-left { + stroke: var(--highlighter-guide-color); + stroke-dasharray: 5 3; + shape-rendering: crispEdges; +} + +@media (prefers-reduced-motion) { + .use-simple-highlighters :is( + .box-model-content, + .box-model-padding, + .box-model-border, + .box-model-margin + ) { + fill: none; + stroke-width: 3; + } + + .use-simple-highlighters .box-model-content { + stroke: var(--highlighter-box-content-color); + } + + .use-simple-highlighters .box-model-padding { + stroke: var(--highlighter-box-padding-color); + } + + .use-simple-highlighters .box-model-border { + stroke: var(--highlighter-box-border-color); + } + + .use-simple-highlighters .box-model-margin { + stroke: var(--highlighter-box-margin-color); + } +} + +/* Highlighter - Infobar */ + +[class$="infobar-container"] { + position: absolute; + max-width: 95%; + + font: var(--highlighter-font-family); + font-size: var(--highlighter-font-size); +} + +[class$="infobar"] { + position: relative; + + padding: 5px; + min-width: 75px; + + border-radius: 3px; + background: var(--highlighter-bubble-background-color) no-repeat padding-box; + + color: var(--highlighter-bubble-text-color); + text-shadow: none; + + border: 1px solid var(--highlighter-bubble-border-color); +} + +/* Arrows */ + +[class$="infobar-container"] > [class$="infobar"]:before { + left: calc(50% - var(--highlighter-bubble-arrow-size)); + border: var(--highlighter-bubble-arrow-size) solid + var(--highlighter-bubble-border-color); +} + +[class$="infobar-container"] > [class$="infobar"]:after { + left: calc(50% - 7px); + border: 7px solid var(--highlighter-bubble-background-color); +} + +[class$="infobar-container"] > [class$="infobar"]:before, +[class$="infobar-container"] > [class$="infobar"]:after { + content: ""; + display: none; + position: absolute; + height: 0; + width: 0; + border-left-color: transparent; + border-right-color: transparent; +} + +[class$="infobar-container"][position="top"]:not([hide-arrow]) + > [class$="infobar"]:before, +[class$="infobar-container"][position="top"]:not([hide-arrow]) + > [class$="infobar"]:after { + border-bottom: 0; + top: 100%; + display: block; +} + +[class$="infobar-container"][position="bottom"]:not([hide-arrow]) + > [class$="infobar"]:before, +[class$="infobar-container"][position="bottom"]:not([hide-arrow]) + > [class$="infobar"]:after { + border-top: 0; + bottom: 100%; + display: block; +} + +/* Text Container */ + +[class$="infobar-text"] { + overflow: hidden; + white-space: nowrap; + direction: ltr; + padding-bottom: 1px; + display: flex; + justify-content: center; + max-width: 768px; +} + +.box-model-infobar-tagname { + color: hsl(285, 100%, 75%); +} + +.box-model-infobar-id { + color: hsl(103, 46%, 54%); + overflow: hidden; + text-overflow: ellipsis; +} + +.box-model-infobar-classes, +.box-model-infobar-pseudo-classes { + color: hsl(200, 74%, 57%); + overflow: hidden; + text-overflow: ellipsis; +} + +[class$="infobar-dimensions"], +[class$="infobar-grid-type"], +[class$="infobar-flex-type"] { + border-inline-start: 1px solid #5a6169; + margin-inline-start: 6px; + padding-inline-start: 6px; +} + +[class$="infobar-grid-type"]:empty, +[class$="infobar-flex-type"]:empty { + display: none; +} + +[class$="infobar-dimensions"] { + color: var(--highlighter-infobar-color); +} + +[class$="infobar-grid-type"], +[class$="infobar-flex-type"] { + color: var(--grey-40); +} + +/* CSS Grid Highlighter */ + +.css-grid-canvas { + position: absolute; + pointer-events: none; + top: 0; + left: 0; + image-rendering: -moz-crisp-edges; +} + +.css-grid-regions { + opacity: 0.6; +} + +.css-grid-areas, +.css-grid-cells { + opacity: 0.5; + stroke: none; +} + +.css-grid-area-infobar-name, +.css-grid-cell-infobar-position, +.css-grid-line-infobar-number { + color: hsl(285, 100%, 75%); +} + +.css-grid-line-infobar-names:not(:empty) { + color: var(--highlighter-infobar-color); + border-inline-start: 1px solid #5a6169; + margin-inline-start: 6px; + padding-inline-start: 6px; +} + +/* CSS Transform Highlighter */ + +.css-transform-transformed { + fill: var(--highlighter-box-content-color); + opacity: 0.8; +} + +.css-transform-untransformed { + fill: #66cc52; + opacity: 0.8; +} + +.css-transform-transformed, +.css-transform-untransformed, +.css-transform-line { + stroke: var(--highlighter-guide-color); + stroke-dasharray: 5 3; + stroke-width: 2; +} + +/* Element Geometry Highlighter */ + +.geometry-editor-root { + /* The geometry editor can be interacted with, so it needs to react to + pointer events */ + pointer-events: auto; + user-select: none; +} + +.geometry-editor-offset-parent { + stroke: var(--highlighter-guide-color); + shape-rendering: crispEdges; + stroke-dasharray: 5 3; + fill: transparent; +} + +.geometry-editor-current-node { + stroke: var(--highlighter-guide-color); + fill: var(--highlighter-box-content-color); + shape-rendering: crispEdges; + opacity: 0.6; +} + +.geometry-editor-arrow { + stroke: var(--highlighter-guide-color); + shape-rendering: crispEdges; +} + +.geometry-editor-root circle { + stroke: var(--highlighter-guide-color); + fill: var(--highlighter-box-content-color); +} + +.geometry-editor-handler-top, +.geometry-editor-handler-bottom { + cursor: ns-resize; +} + +.geometry-editor-handler-right, +.geometry-editor-handler-left { + cursor: ew-resize; +} + +[dragging] .geometry-editor-handler-top, +[dragging] .geometry-editor-handler-right, +[dragging] .geometry-editor-handler-bottom, +[dragging] .geometry-editor-handler-left { + cursor: grabbing; +} + +.geometry-editor-handler-top.dragging, +.geometry-editor-handler-right.dragging, +.geometry-editor-handler-bottom.dragging, +.geometry-editor-handler-left.dragging { + fill: var(--highlighter-guide-color); +} + +.geometry-editor-label-bubble { + fill: var(--highlighter-bubble-background-color); + shape-rendering: crispEdges; +} + +.geometry-editor-label-text { + fill: var(--highlighter-bubble-text-color); + font: var(--highlighter-font-family); + font-size: 10px; + text-anchor: middle; + dominant-baseline: middle; +} + +/* Rulers Highlighter */ + +.rulers-highlighter-elements { + shape-rendering: crispEdges; + pointer-events: none; + position: fixed; + top: 0; + left: 0; +} + +.rulers-highlighter-elements > g { + opacity: 0.8; +} + +.rulers-highlighter-elements > g > rect { + fill: #fff; +} + +.rulers-highlighter-ruler-graduations { + stroke: #bebebe; +} + +.rulers-highlighter-ruler-markers { + stroke: #202020; +} + +.rulers-highlighter-horizontal-labels > text, +.rulers-highlighter-vertical-labels > text { + stroke: none; + fill: #202020; + font: var(--highlighter-font-family); + font-size: 9px; + dominant-baseline: hanging; +} + +.rulers-highlighter-horizontal-labels > text { + text-anchor: start; +} + +.rulers-highlighter-vertical-labels > text { + transform: rotate(-90deg); + text-anchor: end; +} + +.viewport-size-highlighter-viewport-infobar-container { + shape-rendering: crispEdges; + background-color: rgba(255, 255, 255, 0.7); + font: var(--highlighter-font-family); + position: fixed; + top: 30px; + right: 0px; + font-size: 12px; + padding: 4px; +} + +/* Measuring Tool Highlighter */ + +.measuring-tool-tool { + pointer-events: auto; +} + +.measuring-tool-root { + position: absolute; + top: 0; + left: 0; + pointer-events: auto; + cursor: crosshair; +} + +.measuring-tool-elements { + position: absolute; +} + +.measuring-tool-root path { + shape-rendering: geometricPrecision; + pointer-events: auto; +} + +.measuring-tool-root .measuring-tool-box-path, +.measuring-tool-root .measuring-tool-diagonal-path { + fill: rgba(135, 206, 235, 0.6); + stroke: var(--highlighter-guide-color); +} + +.measuring-tool-root circle { + stroke: var(--highlighter-guide-color); + stroke-width: 2px; + fill: #fff; + vector-effect: non-scaling-stroke; +} + +.measuring-tool-root circle.highlight { + fill: var(--highlighter-guide-color); +} + +.measuring-tool-handler-top, +.measuring-tool-handler-bottom { + cursor: ns-resize; +} + +.measuring-tool-handler-right, +.measuring-tool-handler-left { + cursor: ew-resize; +} + +.measuring-tool-handler-topleft, +.measuring-tool-handler-bottomright { + cursor: nwse-resize; +} + +.measuring-tool-handler-topright, +.measuring-tool-handler-bottomleft { + cursor: nesw-resize; +} + +.mirrored .measuring-tool-handler-topleft, +.mirrored .measuring-tool-handler-bottomright { + cursor: nesw-resize; +} + +.mirrored .measuring-tool-handler-topright, +.mirrored .measuring-tool-handler-bottomleft { + cursor: nwse-resize; +} + +[class^=measuring-tool-handler].dragging { + fill: var(--highlighter-guide-color); +} + +.dragging .measuring-tool-box-path, +.dragging .measuring-tool-diagonal-path { + opacity: 0.45; +} + +.measuring-tool-label-size, +.measuring-tool-label-position { + position: absolute; + top: 0; + left: 0; + display: inline-block; + border-radius: 4px; + padding: 4px; + white-space: pre-line; + font: var(--highlighter-font-family); + font-size: 10px; + pointer-events: none; + user-select: none; + box-sizing: border-box; +} + +.measuring-tool-label-position { + color: #fff; + background: hsla(214, 13%, 24%, 0.8); +} + +.measuring-tool-label-size { + color: var(--highlighter-bubble-text-color); + background: var(--highlighter-bubble-background-color); + border: 1px solid var(--highlighter-bubble-border-color); + line-height: 1.5em; +} + +[class^=measuring-tool-guide] { + stroke: var(--highlighter-guide-color); + stroke-dasharray: 5 3; + shape-rendering: crispEdges; +} + +/* Eye Dropper */ + +.eye-dropper-root { + --magnifier-width: 96px; + --magnifier-height: 96px; + /* Width accounts for all color formats (hsl being the longest) */ + --label-width: 160px; + --label-height: 23px; + --background-color: #e0e0e0; + color: #333; + + position: fixed; + /* Tool start position. This should match the X/Y defines in JS */ + top: 100px; + left: 100px; + + /* Prevent interacting with the page when hovering and clicking */ + pointer-events: auto; + + /* Offset the UI so it is centered around the pointer */ + transform: translate( + calc(var(--magnifier-width) / -2), + calc(var(--magnifier-height) / -2) + ); + + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.4)); + + /* We don't need the UI to be reversed in RTL locales, otherwise the # would appear + to the right of the hex code. Force LTR */ + direction: ltr; +} + +.eye-dropper-canvas { + image-rendering: -moz-crisp-edges; + cursor: none; + width: var(--magnifier-width); + height: var(--magnifier-height); + border-radius: 50%; + box-shadow: 0 0 0 3px var(--background-color); + display: block; +} + +.eye-dropper-color-container { + background-color: var(--background-color); + border-radius: 2px; + width: var(--label-width); + height: var(--label-height); + position: relative; + + --label-horizontal-center: translateX( + calc((var(--magnifier-width) - var(--label-width)) / 2) + ); + --label-horizontal-left: translateX( + calc((-1 * var(--label-width) + var(--magnifier-width) / 2)) + ); + --label-horizontal-right: translateX(calc(var(--magnifier-width) / 2)); + --label-vertical-top: translateY( + calc((-1 * var(--magnifier-height)) - var(--label-height)) + ); + + /* By default the color label container sits below the canvas. + Here we just center it horizontally */ + transform: var(--label-horizontal-center); + transition: transform 0.1s ease-in-out; +} + +/* If there isn't enough space below the canvas, we move the label container to the top */ +.eye-dropper-root[top] .eye-dropper-color-container { + transform: var(--label-horizontal-center) var(--label-vertical-top); +} + +/* If there isn't enough space right of the canvas to horizontally center the label + container, offset it to the left */ +.eye-dropper-root[left] .eye-dropper-color-container { + transform: var(--label-horizontal-left); +} + +.eye-dropper-root[left][top] .eye-dropper-color-container { + transform: var(--label-horizontal-left) var(--label-vertical-top); +} + +/* If there isn't enough space left of the canvas to horizontally center the label + container, offset it to the right */ +.eye-dropper-root[right] .eye-dropper-color-container { + transform: var(--label-horizontal-right); +} + +.eye-dropper-root[right][top] .eye-dropper-color-container { + transform: var(--label-horizontal-right) var(--label-vertical-top); +} + +.eye-dropper-color-preview { + width: 16px; + height: 16px; + position: absolute; + inset-inline-start: 3px; + inset-block-start: 3px; + box-shadow: 0px 0px 0px black; + border: solid 1px #fff; +} + +.eye-dropper-color-value { + text-shadow: 1px 1px 1px #fff; + font: var(--highlighter-font-family); + font-size: var(--highlighter-font-size); + text-align: center; + padding: 4px 0; +} + +/* Paused Debugger Overlay */ + +.paused-dbg-root { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + + width: 100vw; + height: 100vh; + + display: flex; + align-items: center; + flex-direction: column; + + /* We don't have access to DevTools themes here, but some of these colors come from the + themes. Theme variable names are given in comments. */ + --text-color: #585959; /* --theme-body-color-alt */ + --toolbar-background: #fcfcfc; /* --theme-toolbar-background */ + --toolbar-border: #dde1e4; /* --theme-splitter-color */ + --toolbar-box-shadow: 0 2px 2px 0 rgba(155, 155, 155, 0.26); /* --rdm-box-shadow */ + --overlay-background: #dde1e4a8; +} + +.paused-dbg-root[overlay] { + background-color: var(--overlay-background); + pointer-events: auto; +} + +.paused-dbg-toolbar { + /* Show the toolbar at the top, but not too high to prevent it overlaping OS toolbar on Android */ + margin-top: 30px; + display: inline-flex; + user-select: none; + + color: var(--text-color); + box-shadow: var(--toolbar-box-shadow); + background-color: var(--toolbar-background); + border: 1px solid var(--toolbar-border); + border-radius: 4px; + + font: var(--highlighter-font-family); + font-size: var(--highlighter-font-size); +} + +.paused-dbg-toolbar button { + margin: 8px 4px 6px 6px; + width: 16px; + height: 16px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 16px 16px; + background-color: var(--text-color); + + border: 0px; + appearance: none; +} + +.paused-dbg-divider { + width: 1px; + height: 16px; + margin-top: 10px; + background-color: var(--toolbar-border); +} + +.paused-dbg-reason, +.paused-dbg-step-button-wrapper, +.paused-dbg-resume-button-wrapper { + margin-top: 2px; + margin-bottom: 2px; +} + +.paused-dbg-step-button-wrapper, +.paused-dbg-resume-button-wrapper { + margin-left: 2px; + margin-right: 2px; +} + +button.paused-dbg-step-button { + margin-left: 6px; + margin-right: 6px; + mask-image: url(resource://devtools-shared-images/stepOver.svg); + padding: 0; +} + +button.paused-dbg-resume-button { + margin-right: 6px; + mask-image: url(resource://devtools-shared-images/resume.svg); + padding: 0; +} + +.paused-dbg-step-button-wrapper.hover, +.paused-dbg-resume-button-wrapper.hover { + background-color: var(--toolbar-border); + border-radius: 2px; +} + +.paused-dbg-reason { + padding: 3px 16px; + margin: 8px 0px; + font: var(--highlighter-font-family); + font-size: var(--highlighter-font-size); +} + + +/* Remote Node Picker Notice Highlighter */ + +#node-picker-notice-root { + position: fixed; + max-width: 100vw; + /* Position at the bottom of the screen so it doesn't get into the user's way */ + bottom: 0; + left: 0; + right: 0; + + z-index: 2; + + display: flex; + align-items: center; + flex-direction: column; + + /* We don't have access to DevTools themes here, but some of these colors come from the + themes. Theme variable names are given in comments. */ + --text-color: #585959; /* --theme-body-color-alt */ + --toolbar-background: #fcfcfc; /* --theme-toolbar-background */ + --toolbar-border: #dde1e4; /* --theme-splitter-color */ + --toolbar-button-hover-background: rgba(12, 12, 13, 0.15); /* --theme-toolbarbutton-hover-background */ + --toolbar-box-shadow: 0 2px 2px 0 rgba(155, 155, 155, 0.26); /* --rdm-box-shadow */ +} + +#node-picker-notice-root[overlay] { + pointer-events: auto; +} + +#node-picker-notice-toolbar { + display: flex; + align-items: center; + gap: 8px; + + padding: 8px 16px; + + color: var(--text-color); + box-shadow: var(--toolbar-box-shadow); + background-color: var(--toolbar-background); + border: 1px solid var(--toolbar-border); + border-radius: 2px; + + font: var(--highlighter-font-family); + font-size: var(--highlighter-font-size); + + user-select: none; +} + +#node-picker-notice-info { + font: var(--highlighter-font-family); + font-size: var(--highlighter-font-size); + text-align: center; +} + +#node-picker-notice-icon { + width: 16px; + height: 16px; + + background-image: url(resource://devtools-shared-images/command-pick.svg); + -moz-context-properties: fill; + fill: currentColor; + + background-size: contain; + background-repeat: no-repeat; +} + +#node-picker-notice-icon.touch { + background-image: url(resource://devtools-shared-images/command-pick-remote-touch.svg); +} + + +#node-picker-notice-hide-button { + border: 0px; + border-radius: 2px; + appearance: none; + background-color: var(--toolbar-border); + color: currentColor; + font-size: 1em; + padding-inline: 4px; +} + +/* We can't use :hover as it wouldn't work if the page is paused, so we add a specific class for this */ +#node-picker-notice-hide-button.hover { + background-color: var(--toolbar-button-hover-background); +} + +/* Shapes highlighter */ + +.shapes-root { + pointer-events: none; +} + +.shapes-shape-container { + position: absolute; + overflow: visible; +} + +.shapes-polygon, +.shapes-ellipse, +.shapes-rect, +.shapes-bounding-box, +.shapes-rotate-line, +.shapes-quad { + fill: transparent; + stroke: var(--highlighter-guide-color); + shape-rendering: geometricPrecision; + vector-effect: non-scaling-stroke; +} + +.shapes-markers { + fill: #fff; +} + +.shapes-markers-outline { + fill: var(--highlighter-guide-color); +} + +.shapes-marker-hover { + fill: var(--highlighter-guide-color); +} + +/* Accessible highlighter */ + +.accessible-infobar { + min-width: unset; +} + +.accessible-infobar-text { + display: grid; + grid-template-areas: + "role name" + "audit audit"; + grid-template-columns: min-content 1fr; +} + +.accessible-infobar-role { + grid-area: role; + color: #9cdcfe; +} + +.accessible-infobar-name { + grid-area: name; +} + +.accessible-infobar-audit { + grid-area: audit; + padding-top: 5px; + padding-bottom: 2px; +} + +.accessible-bounds { + fill: var(--highlighter-accessibility-bounds-color); + opacity: var(--highlighter-accessibility-bounds-opacity); +} + +@media (prefers-reduced-motion) { + .use-simple-highlighters .accessible-bounds { + fill: none; + stroke: var(--highlighter-accessibility-bounds-color); + stroke-width: 3; + } +} + +.accessible-infobar-name, +.accessible-infobar-audit { + color: var(--highlighter-infobar-color); +} + +.accessible-infobar-audit .accessible-contrast-ratio:empty::before, +.accessible-infobar-audit .accessible-contrast-ratio:empty::after, +.accessible-infobar-name:empty { + display: none; +} + +.accessible-infobar-audit .accessible-contrast-ratio::before { + content: ""; + height: 8px; + width: 8px; + display: inline-flex; + background-color: var(--accessibility-highlighter-contrast-ratio-color); + box-shadow: 0 0 0 1px var(--grey-40), + 4px 3px var(--accessibility-highlighter-contrast-ratio-bg), + 4px 3px 0 1px var(--grey-40); + margin-inline-start: 3px; + margin-inline-end: 9px; +} + +.accessible-infobar-audit .accessible-contrast-ratio::after { + margin-inline-start: 2px; +} + +.accessible-infobar-audit .accessible-contrast-ratio.AA::after, +.accessible-infobar-audit .accessible-contrast-ratio.AAA::after { + color: #90E274; +} + +.accessible-infobar-audit .accessible-audit::before, +.accessible-infobar-audit .accessible-contrast-ratio.FAIL::after { + display: inline-block; + width: 12px; + height: 12px; + content: ""; + vertical-align: -2px; + background-position: center; + background-repeat: no-repeat; + -moz-context-properties: fill; +} + +.accessible-infobar-audit .accessible-contrast-ratio.FAIL:after { + color: #E57180; + margin-inline-start: 3px; + background-image: url(resource://devtools-shared-images/error-small.svg); + fill: var(--red-40); +} + +.accessible-infobar-audit .accessible-contrast-ratio.AA::after { + content: "AA\2713"; +} + +.accessible-infobar-audit .accessible-contrast-ratio.AAA::after { + content: "AAA\2713"; +} + +.accessible-infobar-audit .accessible-contrast-ratio-label, +.accessible-infobar-audit .accessible-contrast-ratio-separator::before { + margin-inline-end: 3px; +} + +.accessible-infobar-audit .accessible-contrast-ratio-separator::before { + content: "-"; + margin-inline-start: 3px; +} + +.accessible-infobar-audit .accessible-audit { + display: block; + padding-block-end: 5px; +} + +.accessible-infobar-audit .accessible-audit:last-child { + padding-block-end: 0; +} + +.accessible-infobar-audit .accessible-audit::before { + margin-inline-end: 4px; + background-image: none; + fill: currentColor; +} + +.accessible-infobar-audit .accessible-audit.FAIL::before { + background-image: url(resource://devtools-shared-images/error-small.svg); + fill: var(--red-40); +} + +.accessible-infobar-audit .accessible-audit.WARNING::before { + background-image: url(chrome://devtools/skin/images/alert-small.svg); + fill: var(--yellow-60); +} + +.accessible-infobar-audit .accessible-audit.BEST_PRACTICES::before { + background-image: url(chrome://devtools/skin/images/info-small.svg); +} + +.accessible-infobar-name { + border-inline-start: 1px solid #5a6169; + margin-inline-start: 6px; + padding-inline-start: 6px; +} + +/* Tabbing-order highlighter */ + +.tabbing-order-infobar { + min-width: unset; +} + +.tabbing-order .tabbing-order-infobar-container { + font-size:calc(var(--highlighter-font-size) + 2px); +} + +.tabbing-order .tabbing-order-bounds { + position: absolute; + display: block; + outline: 2px solid #000; + outline-offset: -2px; +} + +.tabbing-order.focused .tabbing-order-bounds { + outline-color: var(--blue-60); +} + +.tabbing-order.focused .tabbing-order-infobar { + background-color: var(--blue-60); +} + +.tabbing-order.focused .tabbing-order-infobar-text { + text-decoration: underline; +} + +.tabbing-order.focused .tabbing-order-infobar:after { + border-top-color: var(--blue-60); + border-bottom-color: var(--blue-60); +} diff --git a/devtools/server/actors/highlighters/css/moz.build b/devtools/server/actors/highlighters/css/moz.build new file mode 100644 index 0000000000..6bdf0f9579 --- /dev/null +++ b/devtools/server/actors/highlighters/css/moz.build @@ -0,0 +1,9 @@ +# -*- 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( + "highlighters.css", +) |