diff options
Diffstat (limited to 'devtools/server/actors/highlighters/css-grid.js')
-rw-r--r-- | devtools/server/actors/highlighters/css-grid.js | 737 |
1 files changed, 737 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 000000000..0ed1ee961 --- /dev/null +++ b/devtools/server/actors/highlighters/css-grid.js @@ -0,0 +1,737 @@ +/* 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 Services = require("Services"); +const { extend } = require("sdk/core/heritage"); +const { AutoRefreshHighlighter } = require("./auto-refresh"); +const { + CanvasFrameAnonymousContentHelper, + createNode, + createSVGNode, + moveInfobar, +} = require("./utils/markup"); +const { + getCurrentZoom, + setIgnoreLayoutChanges +} = require("devtools/shared/layout/utils"); +const { stringifyGridFragments } = require("devtools/server/actors/utils/css-grid-utils"); + +const CSS_GRID_ENABLED_PREF = "layout.css.grid.enabled"; +const ROWS = "rows"; +const COLUMNS = "cols"; +const GRID_LINES_PROPERTIES = { + "edge": { + lineDash: [0, 0], + strokeStyle: "#4B0082" + }, + "explicit": { + lineDash: [5, 3], + strokeStyle: "#8A2BE2" + }, + "implicit": { + lineDash: [2, 2], + strokeStyle: "#9370DB" + } +}; + +// px +const GRID_GAP_PATTERN_WIDTH = 14; +const GRID_GAP_PATTERN_HEIGHT = 14; +const GRID_GAP_PATTERN_LINE_DASH = [5, 3]; +const GRID_GAP_PATTERN_STROKE_STYLE = "#9370DB"; + +/** + * Cached used by `CssGridHighlighter.getGridGapPattern`. + */ +const gCachedGridPattern = new WeakMap(); +// WeakMap key for the Row grid pattern. +const ROW_KEY = {}; +// WeakMap key for the Column grid pattern. +const COLUMN_KEY = {}; + +/** + * The CssGridHighlighter is the class that overlays a visual grid on top of + * display:grid elements. + * + * Usage example: + * let h = new CssGridHighlighter(env); + * h.show(node, options); + * h.hide(); + * h.destroy(); + * + * Available Options: + * - showGridArea(areaName) + * @param {String} areaName + * Shows the grid area highlight for the given area name. + * - showAllGridAreas + * Shows all the grid area highlights for the current grid. + * - showGridLineNumbers(isShown) + * @param {Boolean} + * Displays the grid line numbers on the grid lines if isShown is true. + * - showInfiniteLines(isShown) + * @param {Boolean} isShown + * Displays an infinite line to represent the grid lines if isShown is true. + * + * 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="..." /> + * </g> + * </svg> + * <div class="css-grid-infobar-container"> + * <div class="css-grid-infobar"> + * <div class="css-grid-infobar-text"> + * <span class="css-grid-infobar-areaname">Grid Area Name</span> + * <span class="css-grid-infobar-dimensions"Grid Area Dimensions></span> + * </div> + * </div> + * </div> + * </div> + */ +function CssGridHighlighter(highlighterEnv) { + AutoRefreshHighlighter.call(this, highlighterEnv); + + this.markup = new CanvasFrameAnonymousContentHelper(this.highlighterEnv, + this._buildMarkup.bind(this)); + + this.onNavigate = this.onNavigate.bind(this); + this.onWillNavigate = this.onWillNavigate.bind(this); + + this.highlighterEnv.on("navigate", this.onNavigate); + this.highlighterEnv.on("will-navigate", this.onWillNavigate); +} + +CssGridHighlighter.prototype = extend(AutoRefreshHighlighter.prototype, { + typeName: "CssGridHighlighter", + + ID_CLASS_PREFIX: "css-grid-", + + _buildMarkup() { + let container = createNode(this.win, { + attributes: { + "class": "highlighter-container" + } + }); + + // 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. + createNode(this.win, { + parent: container, + nodeType: "canvas", + attributes: { + "id": "canvas", + "class": "canvas", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Build the SVG element + let svg = createSVGNode(this.win, { + nodeType: "svg", + parent: container, + attributes: { + "id": "elements", + "width": "100%", + "height": "100%", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let regions = createSVGNode(this.win, { + nodeType: "g", + parent: svg, + attributes: { + "class": "regions" + }, + prefix: this.ID_CLASS_PREFIX + }); + + createSVGNode(this.win, { + nodeType: "path", + parent: regions, + attributes: { + "class": "areas", + "id": "areas" + }, + prefix: this.ID_CLASS_PREFIX + }); + + // Building the grid infobar markup + let infobarContainer = createNode(this.win, { + parent: container, + attributes: { + "class": "infobar-container", + "id": "infobar-container", + "position": "top", + "hidden": "true" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let infobar = createNode(this.win, { + parent: infobarContainer, + attributes: { + "class": "infobar" + }, + prefix: this.ID_CLASS_PREFIX + }); + + let textbox = createNode(this.win, { + parent: infobar, + attributes: { + "class": "infobar-text" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: textbox, + attributes: { + "class": "infobar-areaname", + "id": "infobar-areaname" + }, + prefix: this.ID_CLASS_PREFIX + }); + createNode(this.win, { + nodeType: "span", + parent: textbox, + attributes: { + "class": "infobar-dimensions", + "id": "infobar-dimensions" + }, + prefix: this.ID_CLASS_PREFIX + }); + + return container; + }, + + destroy() { + this.highlighterEnv.off("navigate", this.onNavigate); + this.highlighterEnv.off("will-navigate", this.onWillNavigate); + this.markup.destroy(); + + // Clear the pattern cache to avoid dead object exceptions (Bug 1342051). + gCachedGridPattern.delete(ROW_KEY); + gCachedGridPattern.delete(COLUMN_KEY); + + AutoRefreshHighlighter.prototype.destroy.call(this); + }, + + getElement(id) { + return this.markup.getElement(this.ID_CLASS_PREFIX + id); + }, + + get ctx() { + return this.canvas.getCanvasContext("2d"); + }, + + get canvas() { + return this.getElement("canvas"); + }, + + /** + * Gets the grid gap pattern used to render the gap regions. + * + * @param {Object} dimension + * Refers to the WeakMap key for the grid dimension type which is either the + * constant COLUMN or ROW. + * @return {CanvasPattern} grid gap pattern. + */ + getGridGapPattern(dimension) { + if (gCachedGridPattern.has(dimension)) { + return gCachedGridPattern.get(dimension); + } + + // Create the diagonal lines pattern for the rendering the grid gaps. + let canvas = createNode(this.win, { nodeType: "canvas" }); + canvas.width = GRID_GAP_PATTERN_WIDTH; + canvas.height = GRID_GAP_PATTERN_HEIGHT; + + let ctx = canvas.getContext("2d"); + ctx.setLineDash(GRID_GAP_PATTERN_LINE_DASH); + ctx.beginPath(); + ctx.translate(.5, .5); + + if (dimension === COLUMN_KEY) { + ctx.moveTo(0, 0); + ctx.lineTo(GRID_GAP_PATTERN_WIDTH, GRID_GAP_PATTERN_HEIGHT); + } else { + ctx.moveTo(GRID_GAP_PATTERN_WIDTH, 0); + ctx.lineTo(0, GRID_GAP_PATTERN_HEIGHT); + } + + ctx.strokeStyle = GRID_GAP_PATTERN_STROKE_STYLE; + ctx.stroke(); + + let pattern = ctx.createPattern(canvas, "repeat"); + gCachedGridPattern.set(dimension, pattern); + return pattern; + }, + + /** + * Called when the page navigates. Used to clear the cached gap patterns and avoid + * using DeadWrapper objects as gap patterns the next time. + */ + onNavigate() { + gCachedGridPattern.delete(ROW_KEY); + gCachedGridPattern.delete(COLUMN_KEY); + }, + + onWillNavigate({ isTopLevel }) { + if (isTopLevel) { + this.hide(); + } + }, + + _show() { + if (Services.prefs.getBoolPref(CSS_GRID_ENABLED_PREF) && !this.isGrid()) { + this.hide(); + return false; + } + + return this._update(); + }, + + /** + * Shows the grid area highlight for the given area name. + * + * @param {String} areaName + * Grid area name. + */ + showGridArea(areaName) { + this.renderGridArea(areaName); + this._showGridArea(); + }, + + /** + * Shows all the grid area highlights for the current grid. + */ + showAllGridAreas() { + this.renderGridArea(); + this._showGridArea(); + }, + + /** + * Clear the grid area highlights. + */ + clearGridAreas() { + let box = this.getElement("areas"); + box.setAttribute("d", ""); + }, + + /** + * 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.getGridFragments().length > 0; + }, + + /** + * 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() { + let hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this); + + let oldGridData = stringifyGridFragments(this.gridData); + this.gridData = this.currentNode.getGridFragments(); + let newGridData = stringifyGridFragments(this.gridData); + + return hasMoved || oldGridData !== newGridData; + }, + + /** + * 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); + + // Clear the canvas the grid area highlights. + this.clearCanvas(); + this.clearGridAreas(); + + // Start drawing the grid fragments. + for (let i = 0; i < this.gridData.length; i++) { + let fragment = this.gridData[i]; + let quad = this.currentQuads.content[i]; + this.renderFragment(fragment, quad); + } + + // Display the grid area highlights if needed. + if (this.options.showAllGridAreas) { + this.showAllGridAreas(); + } else if (this.options.showGridArea) { + this.showGridArea(this.options.showGridArea); + } + + this._showGrid(); + + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + return true; + }, + + /** + * Update the grid information displayed in the grid info bar. + * + * @param {GridArea} area + * The grid area object. + * @param {Number} x1 + * The first x-coordinate of the grid area rectangle. + * @param {Number} x2 + * The second x-coordinate of the grid area rectangle. + * @param {Number} y1 + * The first y-coordinate of the grid area rectangle. + * @param {Number} y2 + * The second y-coordinate of the grid area rectangle. + */ + _updateInfobar(area, x1, x2, y1, y2) { + let width = x2 - x1; + let height = y2 - y1; + let dim = parseFloat(width.toPrecision(6)) + + " \u00D7 " + + parseFloat(height.toPrecision(6)); + + this.getElement("infobar-areaname").setTextContent(area.name); + this.getElement("infobar-dimensions").setTextContent(dim); + + this._moveInfobar(x1, x2, y1, y2); + }, + + /** + * Move the grid infobar to the right place in the highlighter. + * + * @param {Number} x1 + * The first x-coordinate of the grid area rectangle. + * @param {Number} x2 + * The second x-coordinate of the grid area rectangle. + * @param {Number} y1 + * The first y-coordinate of the grid area rectangle. + * @param {Number} y2 + * The second y-coordinate of the grid area rectangle. + */ + _moveInfobar(x1, x2, y1, y2) { + let bounds = { + bottom: y2, + height: y2 - y1, + left: x1, + right: x2, + top: y1, + width: x2 - x1, + x: x1, + y: y1, + }; + let container = this.getElement("infobar-container"); + + moveInfobar(container, bounds, this.win); + }, + + clearCanvas() { + let ratio = parseFloat((this.win.devicePixelRatio || 1).toFixed(2)); + let width = this.win.innerWidth; + let height = this.win.innerHeight; + + // Resize the canvas taking the dpr into account so as to have crisp lines. + this.canvas.setAttribute("width", width * ratio); + this.canvas.setAttribute("height", height * ratio); + this.canvas.setAttribute("style", `width:${width}px;height:${height}px`); + this.ctx.scale(ratio, ratio); + + this.ctx.clearRect(0, 0, width, height); + }, + + getFirstRowLinePos(fragment) { + return fragment.rows.lines[0].start; + }, + + getLastRowLinePos(fragment) { + return fragment.rows.lines[fragment.rows.lines.length - 1].start; + }, + + getFirstColLinePos(fragment) { + return fragment.cols.lines[0].start; + }, + + 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; + }, + + renderFragment(fragment, quad) { + this.renderLines(fragment.cols, quad, COLUMNS, "left", "top", "height", + this.getFirstRowLinePos(fragment), + this.getLastRowLinePos(fragment)); + this.renderLines(fragment.rows, quad, ROWS, "top", "left", "width", + this.getFirstColLinePos(fragment), + this.getLastColLinePos(fragment)); + }, + + /** + * 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 {String} mainSide + * The main side of the given grid dimension - "top" for rows and + * "left" for columns. + * @param {String} crossSide + * The cross side of the given grid dimension - "left" for rows and + * "top" for columns. + * @param {String} mainSize + * The main size of the given grid dimension - "width" for rows and + * "height" for columns. + * @param {Number} startPos + * The start position of the cross side of the grid dimension. + * @param {Number} endPos + * The end position of the cross side of the grid dimension. + */ + renderLines(gridDimension, {bounds}, dimensionType, mainSide, crossSide, + mainSize, startPos, endPos) { + let lineStartPos = (bounds[crossSide] / getCurrentZoom(this.win)) + startPos; + let lineEndPos = (bounds[crossSide] / getCurrentZoom(this.win)) + endPos; + + if (this.options.showInfiniteLines) { + lineStartPos = 0; + lineEndPos = parseInt(this.canvas.getAttribute(mainSize), 10); + } + + let lastEdgeLineIndex = this.getLastEdgeLineIndex(gridDimension.tracks); + + for (let i = 0; i < gridDimension.lines.length; i++) { + let line = gridDimension.lines[i]; + let linePos = (bounds[mainSide] / getCurrentZoom(this.win)) + line.start; + + if (this.options.showGridLineNumbers) { + this.renderGridLineNumber(line.number, linePos, lineStartPos, dimensionType); + } + + if (i == 0 || i == lastEdgeLineIndex) { + this.renderLine(linePos, lineStartPos, lineEndPos, dimensionType, "edge"); + } else { + this.renderLine(linePos, lineStartPos, lineEndPos, dimensionType, + gridDimension.tracks[i - 1].type); + } + + // Render a second line to illustrate the gutter for non-zero breadth. + if (line.breadth > 0) { + this.renderGridGap(linePos, lineStartPos, lineEndPos, line.breadth, + dimensionType); + this.renderLine(linePos + line.breadth, lineStartPos, lineEndPos, dimensionType, + gridDimension.tracks[i].type); + } + } + }, + + /** + * 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 {[type]} lineType + * The grid line type - "edge", "explicit", or "implicit". + */ + renderLine(linePos, startPos, endPos, dimensionType, lineType) { + this.ctx.save(); + this.ctx.setLineDash(GRID_LINES_PROPERTIES[lineType].lineDash); + this.ctx.beginPath(); + this.ctx.translate(.5, .5); + + if (dimensionType === COLUMNS) { + this.ctx.moveTo(linePos, startPos); + this.ctx.lineTo(linePos, endPos); + } else { + this.ctx.moveTo(startPos, linePos); + this.ctx.lineTo(endPos, linePos); + } + + this.ctx.strokeStyle = GRID_LINES_PROPERTIES[lineType].strokeStyle; + this.ctx.stroke(); + this.ctx.restore(); + }, + + /** + * 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 {String} dimensionType + * The grid dimension type which is either the constant COLUMNS or ROWS. + */ + renderGridLineNumber(lineNumber, linePos, startPos, dimensionType) { + this.ctx.save(); + + if (dimensionType === COLUMNS) { + this.ctx.fillText(lineNumber, linePos, startPos); + } else { + let textWidth = this.ctx.measureText(lineNumber).width; + this.ctx.fillText(lineNumber, startPos - textWidth, linePos); + } + + this.ctx.restore(); + }, + + /** + * 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) { + this.ctx.save(); + + if (dimensionType === COLUMNS) { + this.ctx.fillStyle = this.getGridGapPattern(COLUMN_KEY); + this.ctx.fillRect(linePos, startPos, breadth, endPos - startPos); + } else { + this.ctx.fillStyle = this.getGridGapPattern(ROW_KEY); + this.ctx.fillRect(startPos, linePos, endPos - startPos, breadth); + } + + this.ctx.restore(); + }, + + /** + * 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) { + let paths = []; + let currentZoom = getCurrentZoom(this.win); + + for (let i = 0; i < this.gridData.length; i++) { + let fragment = this.gridData[i]; + let {bounds} = this.currentQuads.content[i]; + + for (let area of fragment.areas) { + if (areaName && areaName != area.name) { + continue; + } + + let rowStart = fragment.rows.lines[area.rowStart - 1]; + let rowEnd = fragment.rows.lines[area.rowEnd - 1]; + let columnStart = fragment.cols.lines[area.columnStart - 1]; + let columnEnd = fragment.cols.lines[area.columnEnd - 1]; + + let x1 = columnStart.start + columnStart.breadth + + (bounds.left / currentZoom); + let x2 = columnEnd.start + (bounds.left / currentZoom); + let y1 = rowStart.start + rowStart.breadth + + (bounds.top / currentZoom); + let y2 = rowEnd.start + (bounds.top / currentZoom); + + let path = "M" + x1 + "," + y1 + " " + + "L" + x2 + "," + y1 + " " + + "L" + x2 + "," + y2 + " " + + "L" + x1 + "," + y2; + paths.push(path); + + // Update and show the info bar when only displaying a single grid area. + if (areaName) { + this._updateInfobar(area, x1, x2, y1, y2); + this._showInfoBar(); + } + } + } + + let box = this.getElement("areas"); + box.setAttribute("d", paths.join(" ")); + }, + + /** + * Hide the highlighter, the canvas and the infobar. + */ + _hide() { + setIgnoreLayoutChanges(true); + this._hideGrid(); + this._hideGridArea(); + this._hideInfoBar(); + setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement); + }, + + _hideGrid() { + this.getElement("canvas").setAttribute("hidden", "true"); + }, + + _showGrid() { + this.getElement("canvas").removeAttribute("hidden"); + }, + + _hideGridArea() { + this.getElement("elements").setAttribute("hidden", "true"); + }, + + _showGridArea() { + this.getElement("elements").removeAttribute("hidden"); + }, + + _hideInfoBar() { + this.getElement("infobar-container").setAttribute("hidden", "true"); + }, + + _showInfoBar() { + this.getElement("infobar-container").removeAttribute("hidden"); + }, + +}); + +exports.CssGridHighlighter = CssGridHighlighter; |