/* 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("devtools/server/actors/highlighters/auto-refresh"); const { CANVAS_SIZE, DEFAULT_COLOR, drawBubbleRect, drawLine, drawRect, drawRoundedRect, getBoundsFromPoints, getCurrentMatrix, getPathDescriptionFromPoints, getPointsFromDiagonal, updateCanvasElement, updateCanvasPosition, } = require("devtools/server/actors/highlighters/utils/canvas"); const { CanvasFrameAnonymousContentHelper, getComputedStyle, moveInfobar, } = require("devtools/server/actors/highlighters/utils/markup"); const { apply } = require("devtools/shared/layout/dom-matrix-2d"); const { getCurrentZoom, getDisplayPixelRatio, getWindowDimensions, setIgnoreLayoutChanges, } = require("devtools/shared/layout/utils"); const { stringifyGridFragments, } = require("devtools/server/actors/utils/css-grid-utils"); const { LocalizationHelper } = require("devtools/shared/l10n"); const STRINGS_URI = "devtools/shared/locales/highlighters.properties"; const L10N = new LocalizationHelper(STRINGS_URI); 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: *
* * *
*
*
* Grid Area Name * Grid Area Dimensions> *
*
*
*
*
*
* Grid Cell Position * Grid Cell Dimensions> *
*
*
*
*
* Grid Line Number * Grid Line Names> *
*
*
*
*/ 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 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 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). */ _hasMoved() { const hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this); const oldGridData = stringifyGridFragments(this.gridData); this.gridData = this.currentNode.getGridFragments(); const newGridData = stringifyGridFragments(this.gridData); return hasMoved || oldGridData !== newGridData; } /** * 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 '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.setStyle("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 element's position and size. // It also clear the '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", hideIfOffscreen: true, }); } /** * 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 = L10N.getFormatStr( "grid.rowColumnPositions", rowNumber, 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", hideIfOffscreen: true, }); } /** * 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;