gecko-dev/devtools/server/actors/highlighters/css-grid.js

733 строки
22 KiB
JavaScript

/* 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();
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;