michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: /** michael@0: * The CSSTransformPreview module displays, using a a rectangle, with michael@0: * a given width and height and its transformed version, given a css transform michael@0: * property and origin. It also displays arrows from/to each corner. michael@0: * michael@0: * It is useful to visualize how a css transform affected an element. It can michael@0: * help debug tricky transformations. It is used today in a tooltip, and this michael@0: * tooltip is shown when hovering over a css transform declaration in the rule michael@0: * and computed view panels. michael@0: * michael@0: * TODO: For now, it multiplies matrices itself to calculate the coordinates of michael@0: * the transformed box, but that should be removed as soon as we can get access michael@0: * to getQuads(). michael@0: */ michael@0: michael@0: const HTML_NS = "http://www.w3.org/1999/xhtml"; michael@0: michael@0: /** michael@0: * The TransformPreview needs an element to output a canvas tag. michael@0: * michael@0: * Usage example: michael@0: * michael@0: * let t = new CSSTransformPreviewer(myRootElement); michael@0: * t.preview("rotate(45deg)", "top left", 200, 400); michael@0: * t.preview("skew(19deg)", "center", 100, 500); michael@0: * t.preview("matrix(1, -0.2, 0, 1, 0, 0)"); michael@0: * t.destroy(); michael@0: * michael@0: * @param {nsIDOMElement} parentEl michael@0: * Where the canvas will go michael@0: */ michael@0: function CSSTransformPreviewer(parentEl) { michael@0: this.parentEl = parentEl; michael@0: this.doc = this.parentEl.ownerDocument; michael@0: this.canvas = null; michael@0: this.ctx = null; michael@0: } michael@0: michael@0: module.exports.CSSTransformPreviewer = CSSTransformPreviewer; michael@0: michael@0: CSSTransformPreviewer.prototype = { michael@0: /** michael@0: * The preview look-and-feel can be changed using these properties michael@0: */ michael@0: MAX_DIM: 250, michael@0: PAD: 5, michael@0: ORIGINAL_FILL: "#1F303F", michael@0: ORIGINAL_STROKE: "#B2D8FF", michael@0: TRANSFORMED_FILL: "rgba(200, 200, 200, .5)", michael@0: TRANSFORMED_STROKE: "#B2D8FF", michael@0: ARROW_STROKE: "#329AFF", michael@0: ORIGIN_STROKE: "#329AFF", michael@0: ARROW_TIP_HEIGHT: 10, michael@0: ARROW_TIP_WIDTH: 8, michael@0: CORNER_SIZE_RATIO: 6, michael@0: michael@0: /** michael@0: * Destroy removes the canvas from the parentelement passed in the constructor michael@0: */ michael@0: destroy: function() { michael@0: if (this.canvas) { michael@0: this.parentEl.removeChild(this.canvas); michael@0: } michael@0: if (this._hiddenDiv) { michael@0: this.parentEl.removeChild(this._hiddenDiv); michael@0: } michael@0: this.parentEl = this.canvas = this.ctx = this.doc = null; michael@0: }, michael@0: michael@0: _createMarkup: function() { michael@0: this.canvas = this.doc.createElementNS(HTML_NS, "canvas"); michael@0: michael@0: this.canvas.setAttribute("id", "canvas"); michael@0: this.canvas.setAttribute("width", this.MAX_DIM); michael@0: this.canvas.setAttribute("height", this.MAX_DIM); michael@0: this.canvas.style.position = "relative"; michael@0: this.parentEl.appendChild(this.canvas); michael@0: michael@0: this.ctx = this.canvas.getContext("2d"); michael@0: }, michael@0: michael@0: _getComputed: function(name, value, width, height) { michael@0: if (!this._hiddenDiv) { michael@0: // Create a hidden element to apply the style to michael@0: this._hiddenDiv = this.doc.createElementNS(HTML_NS, "div"); michael@0: this._hiddenDiv.style.visibility = "hidden"; michael@0: this._hiddenDiv.style.position = "absolute"; michael@0: this.parentEl.appendChild(this._hiddenDiv); michael@0: } michael@0: michael@0: // Camelcase the name michael@0: name = name.replace(/-([a-z]{1})/g, (m, letter) => letter.toUpperCase()); michael@0: michael@0: // Apply width and height to make sure computation is made correctly michael@0: this._hiddenDiv.style.width = width + "px"; michael@0: this._hiddenDiv.style.height = height + "px"; michael@0: michael@0: // Show the hidden div, apply the style, read the computed style, hide the michael@0: // hidden div again michael@0: this._hiddenDiv.style.display = "block"; michael@0: this._hiddenDiv.style[name] = value; michael@0: let computed = this.doc.defaultView.getComputedStyle(this._hiddenDiv); michael@0: let computedValue = computed[name]; michael@0: this._hiddenDiv.style.display = "none"; michael@0: michael@0: return computedValue; michael@0: }, michael@0: michael@0: _getMatrixFromTransformString: function(transformStr) { michael@0: let matrix = transformStr.substring(0, transformStr.length - 1). michael@0: substring(transformStr.indexOf("(") + 1).split(","); michael@0: michael@0: matrix.forEach(function(value, index) { michael@0: matrix[index] = parseFloat(value, 10); michael@0: }); michael@0: michael@0: let transformMatrix = null; michael@0: michael@0: if (matrix.length === 6) { michael@0: // 2d transform michael@0: transformMatrix = [ michael@0: [matrix[0], matrix[2], matrix[4], 0], michael@0: [matrix[1], matrix[3], matrix[5], 0], michael@0: [0, 0, 1, 0], michael@0: [0, 0, 0, 1] michael@0: ]; michael@0: } else { michael@0: // 3d transform michael@0: transformMatrix = [ michael@0: [matrix[0], matrix[4], matrix[8], matrix[12]], michael@0: [matrix[1], matrix[5], matrix[9], matrix[13]], michael@0: [matrix[2], matrix[6], matrix[10], matrix[14]], michael@0: [matrix[3], matrix[7], matrix[11], matrix[15]] michael@0: ]; michael@0: } michael@0: michael@0: return transformMatrix; michael@0: }, michael@0: michael@0: _getOriginFromOriginString: function(originStr) { michael@0: let offsets = originStr.split(" "); michael@0: offsets.forEach(function(item, index) { michael@0: offsets[index] = parseInt(item, 10); michael@0: }); michael@0: michael@0: return offsets; michael@0: }, michael@0: michael@0: _multiply: function(m1, m2) { michael@0: let m = []; michael@0: for (let m1Line = 0; m1Line < m1.length; m1Line++) { michael@0: m[m1Line] = 0; michael@0: for (let m2Col = 0; m2Col < m2.length; m2Col++) { michael@0: m[m1Line] += m1[m1Line][m2Col] * m2[m2Col]; michael@0: } michael@0: } michael@0: return [m[0], m[1]]; michael@0: }, michael@0: michael@0: _getTransformedPoint: function(matrix, point, origin) { michael@0: let pointMatrix = [point[0] - origin[0], point[1] - origin[1], 1, 1]; michael@0: return this._multiply(matrix, pointMatrix); michael@0: }, michael@0: michael@0: _getTransformedPoints: function(matrix, rect, origin) { michael@0: return rect.map(point => { michael@0: let tPoint = this._getTransformedPoint(matrix, [point[0], point[1]], origin); michael@0: return [tPoint[0] + origin[0], tPoint[1] + origin[1]]; michael@0: }); michael@0: }, michael@0: michael@0: /** michael@0: * For canvas to avoid anti-aliasing michael@0: */ michael@0: _round: x => Math.round(x) + .5, michael@0: michael@0: _drawShape: function(points, fillStyle, strokeStyle) { michael@0: this.ctx.save(); michael@0: michael@0: this.ctx.lineWidth = 1; michael@0: this.ctx.strokeStyle = strokeStyle; michael@0: this.ctx.fillStyle = fillStyle; michael@0: michael@0: this.ctx.beginPath(); michael@0: this.ctx.moveTo(this._round(points[0][0]), this._round(points[0][1])); michael@0: for (var i = 1; i < points.length; i++) { michael@0: this.ctx.lineTo(this._round(points[i][0]), this._round(points[i][1])); michael@0: } michael@0: this.ctx.lineTo(this._round(points[0][0]), this._round(points[0][1])); michael@0: this.ctx.fill(); michael@0: this.ctx.stroke(); michael@0: michael@0: this.ctx.restore(); michael@0: }, michael@0: michael@0: _drawArrow: function(x1, y1, x2, y2) { michael@0: // do not draw if the line is too small michael@0: if (Math.abs(x2-x1) < 20 && Math.abs(y2-y1) < 20) { michael@0: return; michael@0: } michael@0: michael@0: this.ctx.save(); michael@0: michael@0: this.ctx.strokeStyle = this.ARROW_STROKE; michael@0: this.ctx.fillStyle = this.ARROW_STROKE; michael@0: this.ctx.lineWidth = 1; michael@0: michael@0: this.ctx.beginPath(); michael@0: this.ctx.moveTo(this._round(x1), this._round(y1)); michael@0: this.ctx.lineTo(this._round(x2), this._round(y2)); michael@0: this.ctx.stroke(); michael@0: michael@0: this.ctx.beginPath(); michael@0: this.ctx.translate(x2, y2); michael@0: let radians = Math.atan((y1 - y2) / (x1 - x2)); michael@0: radians += ((x1 >= x2) ? -90 : 90) * Math.PI / 180; michael@0: this.ctx.rotate(radians); michael@0: this.ctx.moveTo(0, 0); michael@0: this.ctx.lineTo(this.ARROW_TIP_WIDTH / 2, this.ARROW_TIP_HEIGHT); michael@0: this.ctx.lineTo(-this.ARROW_TIP_WIDTH / 2, this.ARROW_TIP_HEIGHT); michael@0: this.ctx.closePath(); michael@0: this.ctx.fill(); michael@0: michael@0: this.ctx.restore(); michael@0: }, michael@0: michael@0: _drawOrigin: function(x, y) { michael@0: this.ctx.save(); michael@0: michael@0: this.ctx.strokeStyle = this.ORIGIN_STROKE; michael@0: this.ctx.fillStyle = this.ORIGIN_STROKE; michael@0: michael@0: this.ctx.beginPath(); michael@0: this.ctx.arc(x, y, 4, 0, 2 * Math.PI, false); michael@0: this.ctx.stroke(); michael@0: this.ctx.fill(); michael@0: michael@0: this.ctx.restore(); michael@0: }, michael@0: michael@0: /** michael@0: * Computes the largest width and height of all the given shapes and changes michael@0: * all of the shapes' points (by reference) so they fit into the configured michael@0: * MAX_DIM - 2*PAD area. michael@0: * @return {Object} A {w, h} giving the size the canvas should be michael@0: */ michael@0: _fitAllShapes: function(allShapes) { michael@0: let allXs = [], allYs = []; michael@0: for (let shape of allShapes) { michael@0: for (let point of shape) { michael@0: allXs.push(point[0]); michael@0: allYs.push(point[1]); michael@0: } michael@0: } michael@0: let minX = Math.min.apply(Math, allXs); michael@0: let maxX = Math.max.apply(Math, allXs); michael@0: let minY = Math.min.apply(Math, allYs); michael@0: let maxY = Math.max.apply(Math, allYs); michael@0: michael@0: let spanX = maxX - minX; michael@0: let spanY = maxY - minY; michael@0: let isWide = spanX > spanY; michael@0: michael@0: let cw = isWide ? this.MAX_DIM : michael@0: this.MAX_DIM * Math.min(spanX, spanY) / Math.max(spanX, spanY); michael@0: let ch = !isWide ? this.MAX_DIM : michael@0: this.MAX_DIM * Math.min(spanX, spanY) / Math.max(spanX, spanY); michael@0: michael@0: let mapX = x => this.PAD + ((cw - 2 * this.PAD) / (maxX - minX)) * (x - minX); michael@0: let mapY = y => this.PAD + ((ch - 2 * this.PAD) / (maxY - minY)) * (y - minY); michael@0: michael@0: for (let shape of allShapes) { michael@0: for (let point of shape) { michael@0: point[0] = mapX(point[0]); michael@0: point[1] = mapY(point[1]); michael@0: } michael@0: } michael@0: michael@0: return {w: cw, h: ch}; michael@0: }, michael@0: michael@0: _drawShapes: function(shape, corner, transformed, transformedCorner) { michael@0: this._drawOriginal(shape); michael@0: this._drawOriginalCorner(corner); michael@0: this._drawTransformed(transformed); michael@0: this._drawTransformedCorner(transformedCorner); michael@0: }, michael@0: michael@0: _drawOriginal: function(points) { michael@0: this._drawShape(points, this.ORIGINAL_FILL, this.ORIGINAL_STROKE); michael@0: }, michael@0: michael@0: _drawTransformed: function(points) { michael@0: this._drawShape(points, this.TRANSFORMED_FILL, this.TRANSFORMED_STROKE); michael@0: }, michael@0: michael@0: _drawOriginalCorner: function(points) { michael@0: this._drawShape(points, this.ORIGINAL_STROKE, this.ORIGINAL_STROKE); michael@0: }, michael@0: michael@0: _drawTransformedCorner: function(points) { michael@0: this._drawShape(points, this.TRANSFORMED_STROKE, this.TRANSFORMED_STROKE); michael@0: }, michael@0: michael@0: _drawArrows: function(shape, transformed) { michael@0: this._drawArrow(shape[0][0], shape[0][1], transformed[0][0], transformed[0][1]); michael@0: this._drawArrow(shape[1][0], shape[1][1], transformed[1][0], transformed[1][1]); michael@0: this._drawArrow(shape[2][0], shape[2][1], transformed[2][0], transformed[2][1]); michael@0: this._drawArrow(shape[3][0], shape[3][1], transformed[3][0], transformed[3][1]); michael@0: }, michael@0: michael@0: /** michael@0: * Draw a transform preview michael@0: * michael@0: * @param {String} transform michael@0: * The css transform value as a string, as typed by the user, as long michael@0: * as it can be computed by the browser michael@0: * @param {String} origin michael@0: * Same as above for the transform-origin value. Defaults to "center" michael@0: * @param {Number} width michael@0: * The width of the container. Defaults to 200 michael@0: * @param {Number} height michael@0: * The height of the container. Defaults to 200 michael@0: * @return {Boolean} Whether or not the preview could be created. Will return michael@0: * false for instance if the transform is invalid michael@0: */ michael@0: preview: function(transform, origin="center", width=200, height=200) { michael@0: // Create/clear the canvas michael@0: if (!this.canvas) { michael@0: this._createMarkup(); michael@0: } michael@0: this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); michael@0: michael@0: // Get computed versions of transform and origin michael@0: transform = this._getComputed("transform", transform, width, height); michael@0: if (transform && transform !== "none") { michael@0: origin = this._getComputed("transform-origin", origin, width, height); michael@0: michael@0: // Get the matrix, origin and width height data for the previewed element michael@0: let originData = this._getOriginFromOriginString(origin); michael@0: let matrixData = this._getMatrixFromTransformString(transform); michael@0: michael@0: // Compute the original box rect and transformed box rect michael@0: let shapePoints = [ michael@0: [0, 0], michael@0: [width, 0], michael@0: [width, height], michael@0: [0, height] michael@0: ]; michael@0: let transformedPoints = this._getTransformedPoints(matrixData, shapePoints, originData); michael@0: michael@0: // Do the same for the corner triangle shape michael@0: let cornerSize = Math.min(shapePoints[2][1] - shapePoints[1][1], michael@0: shapePoints[1][0] - shapePoints[0][0]) / this.CORNER_SIZE_RATIO; michael@0: let cornerPoints = [ michael@0: [shapePoints[1][0], shapePoints[1][1]], michael@0: [shapePoints[1][0], shapePoints[1][1] + cornerSize], michael@0: [shapePoints[1][0] - cornerSize, shapePoints[1][1]] michael@0: ]; michael@0: let transformedCornerPoints = this._getTransformedPoints(matrixData, cornerPoints, originData); michael@0: michael@0: // Resize points to fit everything in the canvas michael@0: let {w, h} = this._fitAllShapes([ michael@0: shapePoints, michael@0: transformedPoints, michael@0: cornerPoints, michael@0: transformedCornerPoints, michael@0: [originData] michael@0: ]); michael@0: michael@0: this.canvas.setAttribute("width", w); michael@0: this.canvas.setAttribute("height", h); michael@0: michael@0: this._drawShapes(shapePoints, cornerPoints, transformedPoints, transformedCornerPoints) michael@0: this._drawArrows(shapePoints, transformedPoints); michael@0: this._drawOrigin(originData[0], originData[1]); michael@0: michael@0: return true; michael@0: } else { michael@0: return false; michael@0: } michael@0: } michael@0: };