/* 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 { CanvasFrameAnonymousContentHelper, getComputedStyle, } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); const { setIgnoreLayoutChanges, getCurrentZoom, getAdjustedQuads, getFrameOffsets, } = require("resource://devtools/shared/layout/utils.js"); const { AutoRefreshHighlighter, } = require("resource://devtools/server/actors/highlighters/auto-refresh.js"); const { getDistance, clickedOnEllipseEdge, distanceToLine, projection, clickedOnPoint, } = require("resource://devtools/server/actors/utils/shapes-utils.js"); const { identity, apply, translate, multiply, scale, rotate, changeMatrixBase, getBasis, } = require("resource://devtools/shared/layout/dom-matrix-2d.js"); const EventEmitter = require("resource://devtools/shared/event-emitter.js"); const { getCSSStyleRules, } = require("resource://devtools/shared/inspector/css-logic.js"); const BASE_MARKER_SIZE = 5; // the width of the area around highlighter lines that can be clicked, in px const LINE_CLICK_WIDTH = 5; const ROTATE_LINE_LENGTH = 50; const DOM_EVENTS = ["mousedown", "mousemove", "mouseup", "dblclick"]; const _dragging = Symbol("shapes/dragging"); /** * The ShapesHighlighter draws an outline shapes in the page. * The idea is to have something that is able to wrap complex shapes for css properties * such as shape-outside/inside, clip-path but also SVG elements. * * Notes on shape transformation: * * When using transform mode to translate, scale, and rotate shapes, a transformation * matrix keeps track of the transformations done to the original shape. When the * highlighter is toggled on/off or between transform mode and point editing mode, * the transformations applied to the shape become permanent. * * While transformations are being performed on a shape, there is an "original" and * a "transformed" coordinate system. This is used when scaling or rotating a rotated * shape. * * The "original" coordinate system is the one where (0,0) is at the top left corner * of the page, the x axis is horizontal, and the y axis is vertical. * * The "transformed" coordinate system is the one where (0,0) is at the top left * corner of the current shape. The x axis follows the north edge of the shape * (from the northwest corner to the northeast corner) and the y axis follows * the west edge of the shape (from the northwest corner to the southwest corner). * * Because of rotation, the "north" and "west" edges might not actually be at the * top and left of the transformed shape. Imagine that the compass directions are * also rotated along with the shape. * * A refresher for coordinates and change of basis that may be helpful: * https://www.math.ubc.ca/~behrend/math221/Coords.pdf * * @param {String} options.hoverPoint * The point to highlight. * @param {Boolean} options.transformMode * Whether to show the highlighter in transforms mode. * @param {} options.mode */ class ShapesHighlighter extends AutoRefreshHighlighter { constructor(highlighterEnv) { super(highlighterEnv); EventEmitter.decorate(this); this.ID_CLASS_PREFIX = "shapes-"; this.referenceBox = "border"; this.useStrokeBox = false; this.geometryBox = ""; this.hoveredPoint = null; this.fillRule = ""; this.numInsetPoints = 0; this.transformMode = false; this.viewport = {}; this.markup = new CanvasFrameAnonymousContentHelper( this.highlighterEnv, this._buildMarkup.bind(this) ); this.isReady = this.markup.initialize(); this.onPageHide = this.onPageHide.bind(this); const { pageListenerTarget } = this.highlighterEnv; DOM_EVENTS.forEach(event => pageListenerTarget.addEventListener(event, this) ); pageListenerTarget.addEventListener("pagehide", this.onPageHide); } _buildMarkup() { const container = this.markup.createNode({ attributes: { class: "highlighter-container", }, }); // The root wrapper is used to unzoom the highlighter when needed. const rootWrapper = this.markup.createNode({ parent: container, attributes: { id: "root", class: "root", }, prefix: this.ID_CLASS_PREFIX, }); const mainSvg = this.markup.createSVGNode({ nodeType: "svg", parent: rootWrapper, attributes: { id: "shape-container", class: "shape-container", viewBox: "0 0 100 100", preserveAspectRatio: "none", }, prefix: this.ID_CLASS_PREFIX, }); // This clipPath and its children make sure the element quad outline // is only shown when the shape extends past the element quads. const clipSvg = this.markup.createSVGNode({ nodeType: "clipPath", parent: mainSvg, attributes: { id: "clip-path", class: "clip-path", }, prefix: this.ID_CLASS_PREFIX, }); this.markup.createSVGNode({ nodeType: "polygon", parent: clipSvg, attributes: { id: "clip-polygon", class: "clip-polygon", hidden: "true", }, prefix: this.ID_CLASS_PREFIX, }); this.markup.createSVGNode({ nodeType: "ellipse", parent: clipSvg, attributes: { id: "clip-ellipse", class: "clip-ellipse", hidden: true, }, prefix: this.ID_CLASS_PREFIX, }); this.markup.createSVGNode({ nodeType: "rect", parent: clipSvg, attributes: { id: "clip-rect", class: "clip-rect", hidden: true, }, prefix: this.ID_CLASS_PREFIX, }); // Rectangle that displays the element quads. Only shown for shape-outside. // Only the parts of the rectangle's outline that overlap with the shape is shown. this.markup.createSVGNode({ nodeType: "rect", parent: mainSvg, attributes: { id: "quad", class: "quad", hidden: "true", "clip-path": "url(#shapes-clip-path)", x: 0, y: 0, width: 100, height: 100, }, prefix: this.ID_CLASS_PREFIX, }); // clipPath that corresponds to the element's quads. Only applied for shape-outside. // This ensures only the parts of the shape that are within the element's quads are // outlined by a solid line. const shapeClipSvg = this.markup.createSVGNode({ nodeType: "clipPath", parent: mainSvg, attributes: { id: "quad-clip-path", class: "quad-clip-path", }, prefix: this.ID_CLASS_PREFIX, }); this.markup.createSVGNode({ nodeType: "rect", parent: shapeClipSvg, attributes: { id: "quad-clip", class: "quad-clip", x: -1, y: -1, width: 102, height: 102, }, prefix: this.ID_CLASS_PREFIX, }); const mainGroup = this.markup.createSVGNode({ nodeType: "g", parent: mainSvg, attributes: { id: "group", }, prefix: this.ID_CLASS_PREFIX, }); // Append a polygon for polygon shapes. this.markup.createSVGNode({ nodeType: "polygon", parent: mainGroup, attributes: { id: "polygon", class: "polygon", hidden: "true", }, prefix: this.ID_CLASS_PREFIX, }); // Append an ellipse for circle/ellipse shapes. this.markup.createSVGNode({ nodeType: "ellipse", parent: mainGroup, attributes: { id: "ellipse", class: "ellipse", hidden: true, }, prefix: this.ID_CLASS_PREFIX, }); // Append a rect for inset(). this.markup.createSVGNode({ nodeType: "rect", parent: mainGroup, attributes: { id: "rect", class: "rect", hidden: true, }, prefix: this.ID_CLASS_PREFIX, }); // Dashed versions of each shape. Only shown for the parts of the shape // that extends past the element's quads. this.markup.createSVGNode({ nodeType: "polygon", parent: mainGroup, attributes: { id: "dashed-polygon", class: "polygon", hidden: "true", "stroke-dasharray": "5, 5", }, prefix: this.ID_CLASS_PREFIX, }); this.markup.createSVGNode({ nodeType: "ellipse", parent: mainGroup, attributes: { id: "dashed-ellipse", class: "ellipse", hidden: "true", "stroke-dasharray": "5, 5", }, prefix: this.ID_CLASS_PREFIX, }); this.markup.createSVGNode({ nodeType: "rect", parent: mainGroup, attributes: { id: "dashed-rect", class: "rect", hidden: "true", "stroke-dasharray": "5, 5", }, prefix: this.ID_CLASS_PREFIX, }); this.markup.createSVGNode({ nodeType: "path", parent: mainGroup, attributes: { id: "bounding-box", class: "bounding-box", "stroke-dasharray": "5, 5", hidden: true, }, prefix: this.ID_CLASS_PREFIX, }); this.markup.createSVGNode({ nodeType: "path", parent: mainGroup, attributes: { id: "rotate-line", class: "rotate-line", }, prefix: this.ID_CLASS_PREFIX, }); // Append a path to display the markers for the shape. this.markup.createSVGNode({ nodeType: "path", parent: mainGroup, attributes: { id: "markers-outline", class: "markers-outline", }, prefix: this.ID_CLASS_PREFIX, }); this.markup.createSVGNode({ nodeType: "path", parent: mainGroup, attributes: { id: "markers", class: "markers", }, prefix: this.ID_CLASS_PREFIX, }); this.markup.createSVGNode({ nodeType: "path", parent: mainGroup, attributes: { id: "marker-hover", class: "marker-hover", hidden: true, }, prefix: this.ID_CLASS_PREFIX, }); return container; } get currentDimensions() { let dims = this.currentQuads[this.referenceBox][0].bounds; const zoom = getCurrentZoom(this.win); // If an SVG element has a stroke, currentQuads will return the stroke bounding box. // However, clip-path always uses the object bounding box unless "stroke-box" is // specified. So, we must calculate the object bounding box if there is a stroke // and "stroke-box" is not specified. stroke only applies to SVG elements, so use // getBBox, which only exists for SVG, to check if currentNode is an SVG element. if ( this.currentNode.getBBox && getComputedStyle(this.currentNode).stroke !== "none" && !this.useStrokeBox ) { dims = getObjectBoundingBox( dims.top, dims.left, dims.width, dims.height, this.currentNode ); } return { top: dims.top / zoom, left: dims.left / zoom, width: dims.width / zoom, height: dims.height / zoom, }; } get frameDimensions() { // In an iframe, we get the node's quads relative to the frame, instead of the parent // document. let dims = this.highlighterEnv.window.document === this.currentNode.ownerDocument ? this.currentQuads[this.referenceBox][0].bounds : getAdjustedQuads( this.currentNode.ownerGlobal, this.currentNode, this.referenceBox )[0].bounds; const zoom = getCurrentZoom(this.win); // If an SVG element has a stroke, currentQuads will return the stroke bounding box. // However, clip-path always uses the object bounding box unless "stroke-box" is // specified. So, we must calculate the object bounding box if there is a stroke // and "stroke-box" is not specified. stroke only applies to SVG elements, so use // getBBox, which only exists for SVG, to check if currentNode is an SVG element. if ( this.currentNode.getBBox && getComputedStyle(this.currentNode).stroke !== "none" && !this.useStrokeBox ) { dims = getObjectBoundingBox( dims.top, dims.left, dims.width, dims.height, this.currentNode ); } return { top: dims.top / zoom, left: dims.left / zoom, width: dims.width / zoom, height: dims.height / zoom, }; } /** * Changes the appearance of the mouse cursor on the highlighter. * * Because we can't attach event handlers to individual elements in the * highlighter, we determine if the mouse is hovering over a point by seeing if * it's within 5 pixels of it. This creates a square hitbox that doesn't match * perfectly with the circular markers. So if we were to use the :hover * pseudo-class to apply changes to the mouse cursor, the cursor change would not * always accurately reflect whether you can interact with the point. This is * also the reason we have the hidden marker-hover element instead of using CSS * to fill in the marker. * * In addition, the cursor CSS property is applied to .shapes-root because if * it were attached to .shapes-marker, the cursor change no longer applies if * you are for example resizing the shape and your mouse goes off the point. * Also, if you are dragging a polygon point, the marker plays catch up to your * mouse position, resulting in an undesirable visual effect where the cursor * rapidly flickers between "grab" and "auto". * * @param {String} cursorType the name of the cursor to display */ setCursor(cursorType) { const container = this.getElement("root"); let style = container.getAttribute("style"); // remove existing cursor definitions in the style style = style.replace(/cursor:.*?;/g, ""); style = style.replace(/pointer-events:.*?;/g, ""); const pointerEvents = cursorType === "auto" ? "none" : "auto"; container.setAttribute( "style", `${style}pointer-events:${pointerEvents};cursor:${cursorType};` ); } /** * Set the absolute pixel offsets which define the current viewport in relation to * the full page size. * * If a padding value is given, inset the viewport by this value. This is used to define * a virtual viewport which ensures some element remains visible even when at the edges * of the actual viewport. * * @param {Number} padding * Optional. Amount by which to inset the viewport in all directions. */ setViewport(padding = 0) { let xOffset = 0; let yOffset = 0; // If the node exists within an iframe, get offsets for the virtual viewport so that // points can be dragged to the extent of the global window, outside of the iframe // window. if (this.currentNode.ownerGlobal !== this.win) { const win = this.win; const nodeWin = this.currentNode.ownerGlobal; // Get bounding box of iframe document relative to global document. const bounds = nodeWin.document .getBoxQuads({ relativeTo: win.document, createFramesForSuppressedWhitespace: false, })[0] .getBounds(); xOffset = bounds.left - nodeWin.scrollX + win.scrollX; yOffset = bounds.top - nodeWin.scrollY + win.scrollY; } const { pageXOffset, pageYOffset } = this.win; const { clientHeight, clientWidth } = this.win.document.documentElement; const left = pageXOffset + padding - xOffset; const right = clientWidth + pageXOffset - padding - xOffset; const top = pageYOffset + padding - yOffset; const bottom = clientHeight + pageYOffset - padding - yOffset; this.viewport = { left, right, top, bottom, padding }; } // eslint-disable-next-line complexity handleEvent(event, id) { // No event handling if the highlighter is hidden if (this.areShapesHidden()) { return; } let { target, type, pageX, pageY } = event; // For events on highlighted nodes in an iframe, when the event takes place // outside the iframe. Check if event target belongs to the iframe. If it doesn't, // adjust pageX/pageY to be relative to the iframe rather than the parent. const nodeDocument = this.currentNode.ownerDocument; if (target !== nodeDocument && target.ownerDocument !== nodeDocument) { const [xOffset, yOffset] = getFrameOffsets( target.ownerGlobal, this.currentNode ); const zoom = getCurrentZoom(this.win); // xOffset/yOffset are relative to the viewport, so first find the top/left // edges of the viewport relative to the page. const viewportLeft = pageX - event.clientX; const viewportTop = pageY - event.clientY; // Also adjust for scrolling in the iframe. const { scrollTop, scrollLeft } = nodeDocument.documentElement; pageX -= viewportLeft + xOffset / zoom - scrollLeft; pageY -= viewportTop + yOffset / zoom - scrollTop; } switch (type) { case "pagehide": // If a page hide event is triggered for current window's highlighter, hide the // highlighter. if (target.defaultView === this.win) { this.destroy(); } break; case "mousedown": if (this.transformMode) { this._handleTransformClick(pageX, pageY); } else if (this.shapeType === "polygon") { this._handlePolygonClick(pageX, pageY); } else if (this.shapeType === "circle") { this._handleCircleClick(pageX, pageY); } else if (this.shapeType === "ellipse") { this._handleEllipseClick(pageX, pageY); } else if (this.shapeType === "inset") { this._handleInsetClick(pageX, pageY); } event.stopPropagation(); event.preventDefault(); // Calculate constraints for a virtual viewport which ensures that a dragged // marker remains visible even at the edges of the actual viewport. this.setViewport(BASE_MARKER_SIZE); break; case "mouseup": if (this[_dragging]) { this[_dragging] = null; this._handleMarkerHover(this.hoveredPoint); } break; case "mousemove": if (!this[_dragging]) { this._handleMouseMoveNotDragging(pageX, pageY); return; } event.stopPropagation(); event.preventDefault(); // Set constraints for mouse position to ensure dragged marker stays in viewport. const { left, right, top, bottom } = this.viewport; pageX = Math.min(Math.max(left, pageX), right); pageY = Math.min(Math.max(top, pageY), bottom); const { point } = this[_dragging]; if (this.transformMode) { this._handleTransformMove(pageX, pageY); } else if (this.shapeType === "polygon") { this._handlePolygonMove(pageX, pageY); } else if (this.shapeType === "circle") { this._handleCircleMove(point, pageX, pageY); } else if (this.shapeType === "ellipse") { this._handleEllipseMove(point, pageX, pageY); } else if (this.shapeType === "inset") { this._handleInsetMove(point, pageX, pageY); } break; case "dblclick": if (this.shapeType === "polygon" && !this.transformMode) { const { percentX, percentY } = this.convertPageCoordsToPercent( pageX, pageY ); const index = this.getPolygonPointAt(percentX, percentY); if (index === -1) { this.getPolygonClickedLine(percentX, percentY); return; } this._deletePolygonPoint(index); } break; } } /** * Handle a mouse click in transform mode. * @param {Number} pageX the x coordinate of the mouse * @param {Number} pageY the y coordinate of the mouse */ _handleTransformClick(pageX, pageY) { const { percentX, percentY } = this.convertPageCoordsToPercent( pageX, pageY ); const type = this.getTransformPointAt(percentX, percentY); if (!type) { return; } if (this.shapeType === "polygon") { this._handlePolygonTransformClick(pageX, pageY, type); } else if (this.shapeType === "circle") { this._handleCircleTransformClick(pageX, pageY, type); } else if (this.shapeType === "ellipse") { this._handleEllipseTransformClick(pageX, pageY, type); } else if (this.shapeType === "inset") { this._handleInsetTransformClick(pageX, pageY, type); } } /** * Handle a click in transform mode while highlighting a polygon. * @param {Number} pageX the x coordinate of the mouse. * @param {Number} pageY the y coordinate of the mouse. * @param {String} type the type of transform handle that was clicked. */ _handlePolygonTransformClick(pageX, pageY, type) { const { width, height } = this.currentDimensions; const pointsInfo = this.origCoordUnits.map(([x, y], i) => { const xComputed = (this.origCoordinates[i][0] / 100) * width; const yComputed = (this.origCoordinates[i][1] / 100) * height; const unitX = getUnit(x); const unitY = getUnit(y); const valueX = isUnitless(x) ? xComputed : parseFloat(x); const valueY = isUnitless(y) ? yComputed : parseFloat(y); const ratioX = this.getUnitToPixelRatio(unitX, width); const ratioY = this.getUnitToPixelRatio(unitY, height); return { unitX, unitY, valueX, valueY, ratioX, ratioY }; }); this[_dragging] = { type, pointsInfo, x: pageX, y: pageY, bb: this.boundingBox, matrix: this.transformMatrix, transformedBB: this.transformedBoundingBox, }; this._handleMarkerHover(this.hoveredPoint); } /** * Handle a click in transform mode while highlighting a circle. * @param {Number} pageX the x coordinate of the mouse. * @param {Number} pageY the y coordinate of the mouse. * @param {String} type the type of transform handle that was clicked. */ _handleCircleTransformClick(pageX, pageY, type) { const { width, height } = this.currentDimensions; const { cx, cy } = this.origCoordUnits; const cxComputed = (this.origCoordinates.cx / 100) * width; const cyComputed = (this.origCoordinates.cy / 100) * height; const unitX = getUnit(cx); const unitY = getUnit(cy); const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); const ratioX = this.getUnitToPixelRatio(unitX, width); const ratioY = this.getUnitToPixelRatio(unitY, height); let { radius } = this.origCoordinates; const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2); radius = (radius / 100) * computedSize; let valueRad = this.origCoordUnits.radius; const unitRad = getUnit(valueRad); valueRad = isUnitless(valueRad) ? radius : parseFloat(valueRad); const ratioRad = this.getUnitToPixelRatio(unitRad, computedSize); this[_dragging] = { type, unitX, unitY, unitRad, valueX, valueY, ratioX, ratioY, ratioRad, x: pageX, y: pageY, bb: this.boundingBox, matrix: this.transformMatrix, transformedBB: this.transformedBoundingBox, }; } /** * Handle a click in transform mode while highlighting an ellipse. * @param {Number} pageX the x coordinate of the mouse. * @param {Number} pageY the y coordinate of the mouse. * @param {String} type the type of transform handle that was clicked. */ _handleEllipseTransformClick(pageX, pageY, type) { const { width, height } = this.currentDimensions; const { cx, cy } = this.origCoordUnits; const cxComputed = (this.origCoordinates.cx / 100) * width; const cyComputed = (this.origCoordinates.cy / 100) * height; const unitX = getUnit(cx); const unitY = getUnit(cy); const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); const ratioX = this.getUnitToPixelRatio(unitX, width); const ratioY = this.getUnitToPixelRatio(unitY, height); let { rx, ry } = this.origCoordinates; rx = (rx / 100) * width; let valueRX = this.origCoordUnits.rx; const unitRX = getUnit(valueRX); valueRX = isUnitless(valueRX) ? rx : parseFloat(valueRX); const ratioRX = valueRX / rx || 1; ry = (ry / 100) * height; let valueRY = this.origCoordUnits.ry; const unitRY = getUnit(valueRY); valueRY = isUnitless(valueRY) ? ry : parseFloat(valueRY); const ratioRY = valueRY / ry || 1; this[_dragging] = { type, unitX, unitY, unitRX, unitRY, valueX, valueY, ratioX, ratioY, ratioRX, ratioRY, x: pageX, y: pageY, bb: this.boundingBox, matrix: this.transformMatrix, transformedBB: this.transformedBoundingBox, }; } /** * Handle a click in transform mode while highlighting an inset. * @param {Number} pageX the x coordinate of the mouse. * @param {Number} pageY the y coordinate of the mouse. * @param {String} type the type of transform handle that was clicked. */ _handleInsetTransformClick(pageX, pageY, type) { const { width, height } = this.currentDimensions; const pointsInfo = {}; ["top", "right", "bottom", "left"].forEach(point => { let value = this.origCoordUnits[point]; const size = point === "left" || point === "right" ? width : height; const computedValue = (this.origCoordinates[point] / 100) * size; const unit = getUnit(value); value = isUnitless(value) ? computedValue : parseFloat(value); const ratio = this.getUnitToPixelRatio(unit, size); pointsInfo[point] = { value, unit, ratio }; }); this[_dragging] = { type, pointsInfo, x: pageX, y: pageY, bb: this.boundingBox, matrix: this.transformMatrix, transformedBB: this.transformedBoundingBox, }; } /** * Handle mouse movement after a click on a handle in transform mode. * @param {Number} pageX the x coordinate of the mouse * @param {Number} pageY the y coordinate of the mouse */ _handleTransformMove(pageX, pageY) { const { type } = this[_dragging]; if (type === "translate") { this._translateShape(pageX, pageY); } else if (type.includes("scale")) { this._scaleShape(pageX, pageY); } else if (type === "rotate" && this.shapeType === "polygon") { this._rotateShape(pageX, pageY); } this.transformedBoundingBox = this.calculateTransformedBoundingBox(); } /** * Translates a shape based on the current mouse position. * @param {Number} pageX the x coordinate of the mouse. * @param {Number} pageY the y coordinate of the mouse. */ _translateShape(pageX, pageY) { const { x, y, matrix } = this[_dragging]; const deltaX = pageX - x; const deltaY = pageY - y; this.transformMatrix = multiply(translate(deltaX, deltaY), matrix); if (this.shapeType === "polygon") { this._transformPolygon(); } else if (this.shapeType === "circle") { this._transformCircle(); } else if (this.shapeType === "ellipse") { this._transformEllipse(); } else if (this.shapeType === "inset") { this._transformInset(); } } /** * Scales a shape according to the current mouse position. * @param {Number} pageX the x coordinate of the mouse. * @param {Number} pageY the y coordinate of the mouse. */ _scaleShape(pageX, pageY) { /** * To scale a shape: * 1) Get the change of basis matrix corresponding to the current transformation * matrix of the shape. * 2) Convert the mouse x/y deltas to the "transformed" coordinate system, using * the change of base matrix. * 3) Calculate the proportion to which the shape should be scaled to, using the * mouse x/y deltas and the width/height of the transformed shape. * 4) Translate the shape such that the anchor (the point opposite to the one * being dragged) is at the top left of the element. * 5) Scale each point by multiplying by the scaling proportion. * 6) Translate the shape back such that the anchor is in its original position. */ const { type, x, y, matrix } = this[_dragging]; const { width, height } = this.currentDimensions; // The point opposite to the one being dragged const anchor = getAnchorPoint(type); const { ne, nw, sw } = this[_dragging].transformedBB; // u/v are the basis vectors of the transformed coordinate system. const u = [ ((ne[0] - nw[0]) / 100) * width, ((ne[1] - nw[1]) / 100) * height, ]; const v = [ ((sw[0] - nw[0]) / 100) * width, ((sw[1] - nw[1]) / 100) * height, ]; // uLength/vLength represent the width/height of the shape in the // transformed coordinate system. const { basis, invertedBasis, uLength, vLength } = getBasis(u, v); // How much points on each axis should be translated before scaling const transX = (this[_dragging].transformedBB[anchor][0] / 100) * width; const transY = (this[_dragging].transformedBB[anchor][1] / 100) * height; // Distance from original click to current mouse position const distanceX = pageX - x; const distanceY = pageY - y; // Convert from original coordinate system to transformed coordinate system const tDistanceX = invertedBasis[0] * distanceX + invertedBasis[1] * distanceY; const tDistanceY = invertedBasis[3] * distanceX + invertedBasis[4] * distanceY; // Proportion of distance to bounding box width/height of shape const proportionX = tDistanceX / uLength; const proportionY = tDistanceY / vLength; // proportionX is positive for size reductions dragging on w/nw/sw, // negative for e/ne/se. const scaleX = type.includes("w") ? 1 - proportionX : 1 + proportionX; // proportionT is positive for size reductions dragging on n/nw/ne, // negative for s/sw/se. const scaleY = type.includes("n") ? 1 - proportionY : 1 + proportionY; // Take the average of scaleX/scaleY for scaling on two axes const scaleXY = (scaleX + scaleY) / 2; const translateMatrix = translate(-transX, -transY); let scaleMatrix = identity(); // The scale matrices are in the transformed coordinate system. We must convert // them to the original coordinate system before applying it to the transformation // matrix. if (type === "scale-e" || type === "scale-w") { scaleMatrix = changeMatrixBase(scale(scaleX, 1), invertedBasis, basis); } else if (type === "scale-n" || type === "scale-s") { scaleMatrix = changeMatrixBase(scale(1, scaleY), invertedBasis, basis); } else { scaleMatrix = changeMatrixBase( scale(scaleXY, scaleXY), invertedBasis, basis ); } const translateBackMatrix = translate(transX, transY); this.transformMatrix = multiply( translateBackMatrix, multiply(scaleMatrix, multiply(translateMatrix, matrix)) ); if (this.shapeType === "polygon") { this._transformPolygon(); } else if (this.shapeType === "circle") { this._transformCircle(transX); } else if (this.shapeType === "ellipse") { this._transformEllipse(transX, transY); } else if (this.shapeType === "inset") { this._transformInset(); } } /** * Rotates a polygon based on the current mouse position. * @param {Number} pageX the x coordinate of the mouse. * @param {Number} pageY the y coordinate of the mouse. */ _rotateShape(pageX, pageY) { const { matrix } = this[_dragging]; const { center, ne, nw, sw } = this[_dragging].transformedBB; const { width, height } = this.currentDimensions; const centerX = (center[0] / 100) * width; const centerY = (center[1] / 100) * height; const { x: pageCenterX, y: pageCenterY } = this.convertPercentToPageCoords( ...center ); const dx = pageCenterX - pageX; const dy = pageCenterY - pageY; const u = [ ((ne[0] - nw[0]) / 100) * width, ((ne[1] - nw[1]) / 100) * height, ]; const v = [ ((sw[0] - nw[0]) / 100) * width, ((sw[1] - nw[1]) / 100) * height, ]; const { invertedBasis } = getBasis(u, v); const tdx = invertedBasis[0] * dx + invertedBasis[1] * dy; const tdy = invertedBasis[3] * dx + invertedBasis[4] * dy; const angle = Math.atan2(tdx, tdy); const translateMatrix = translate(-centerX, -centerY); const rotateMatrix = rotate(angle); const translateBackMatrix = translate(centerX, centerY); this.transformMatrix = multiply( translateBackMatrix, multiply(rotateMatrix, multiply(translateMatrix, matrix)) ); this._transformPolygon(); } /** * Transform a polygon depending on the current transformation matrix. */ _transformPolygon() { const { pointsInfo } = this[_dragging]; let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; polygonDef += pointsInfo .map(point => { const { unitX, unitY, valueX, valueY, ratioX, ratioY } = point; const vector = [valueX / ratioX, valueY / ratioY]; let [newX, newY] = apply(this.transformMatrix, vector); newX = round(newX * ratioX, unitX); newY = round(newY * ratioY, unitY); return `${newX}${unitX} ${newY}${unitY}`; }) .join(", "); polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); } /** * Transform a circle depending on the current transformation matrix. * @param {Number} transX the number of pixels the shape is translated on the x axis * before scaling */ _transformCircle(transX = null) { const { unitX, unitY, unitRad, valueX, valueY, ratioX, ratioY, ratioRad } = this[_dragging]; let { radius } = this.coordUnits; let [newCx, newCy] = apply(this.transformMatrix, [ valueX / ratioX, valueY / ratioY, ]); if (transX !== null) { // As part of scaling, the shape is translated to be tangent to the line y=0. // To get the new radius, we translate the new cx back to that point and get // the distance to the line y=0. radius = round(Math.abs((newCx - transX) * ratioRad), unitRad); radius = `${radius}${unitRad}`; } newCx = round(newCx * ratioX, unitX); newCy = round(newCy * ratioY, unitY); const circleDef = `circle(${radius} at ${newCx}${unitX} ${newCy}${unitY})` + ` ${this.geometryBox}`.trim(); this.emit("highlighter-event", { type: "shape-change", value: circleDef }); } /** * Transform an ellipse depending on the current transformation matrix. * @param {Number} transX the number of pixels the shape is translated on the x axis * before scaling * @param {Number} transY the number of pixels the shape is translated on the y axis * before scaling */ _transformEllipse(transX = null, transY = null) { const { unitX, unitY, unitRX, unitRY, valueX, valueY, ratioX, ratioY, ratioRX, ratioRY, } = this[_dragging]; let { rx, ry } = this.coordUnits; let [newCx, newCy] = apply(this.transformMatrix, [ valueX / ratioX, valueY / ratioY, ]); if (transX !== null && transY !== null) { // As part of scaling, the shape is translated to be tangent to the lines y=0 & x=0. // To get the new radii, we translate the new center back to that point and get the // distances to the line x=0 and y=0. rx = round(Math.abs((newCx - transX) * ratioRX), unitRX); rx = `${rx}${unitRX}`; ry = round(Math.abs((newCy - transY) * ratioRY), unitRY); ry = `${ry}${unitRY}`; } newCx = round(newCx * ratioX, unitX); newCy = round(newCy * ratioY, unitY); const centerStr = `${newCx}${unitX} ${newCy}${unitY}`; const ellipseDef = `ellipse(${rx} ${ry} at ${centerStr}) ${this.geometryBox}`.trim(); this.emit("highlighter-event", { type: "shape-change", value: ellipseDef }); } /** * Transform an inset depending on the current transformation matrix. */ _transformInset() { const { top, left, right, bottom } = this[_dragging].pointsInfo; const { width, height } = this.currentDimensions; const topLeft = [left.value / left.ratio, top.value / top.ratio]; let [newLeft, newTop] = apply(this.transformMatrix, topLeft); newLeft = round(newLeft * left.ratio, left.unit); newLeft = `${newLeft}${left.unit}`; newTop = round(newTop * top.ratio, top.unit); newTop = `${newTop}${top.unit}`; // Right and bottom values are relative to the right and bottom edges of the // element, so convert to the value relative to the left/top edges before scaling // and convert back. const bottomRight = [ width - right.value / right.ratio, height - bottom.value / bottom.ratio, ]; let [newRight, newBottom] = apply(this.transformMatrix, bottomRight); newRight = round((width - newRight) * right.ratio, right.unit); newRight = `${newRight}${right.unit}`; newBottom = round((height - newBottom) * bottom.ratio, bottom.unit); newBottom = `${newBottom}${bottom.unit}`; let insetDef = this.insetRound ? `inset(${newTop} ${newRight} ${newBottom} ${newLeft} round ${this.insetRound})` : `inset(${newTop} ${newRight} ${newBottom} ${newLeft})`; insetDef += this.geometryBox ? this.geometryBox : ""; this.emit("highlighter-event", { type: "shape-change", value: insetDef }); } /** * Handle a click when highlighting a polygon. * @param {Number} pageX the x coordinate of the click * @param {Number} pageY the y coordinate of the click */ _handlePolygonClick(pageX, pageY) { const { width, height } = this.currentDimensions; const { percentX, percentY } = this.convertPageCoordsToPercent( pageX, pageY ); const point = this.getPolygonPointAt(percentX, percentY); if (point === -1) { return; } const [x, y] = this.coordUnits[point]; const xComputed = (this.coordinates[point][0] / 100) * width; const yComputed = (this.coordinates[point][1] / 100) * height; const unitX = getUnit(x); const unitY = getUnit(y); const valueX = isUnitless(x) ? xComputed : parseFloat(x); const valueY = isUnitless(y) ? yComputed : parseFloat(y); const ratioX = this.getUnitToPixelRatio(unitX, width); const ratioY = this.getUnitToPixelRatio(unitY, height); this.setCursor("grabbing"); this[_dragging] = { point, unitX, unitY, valueX, valueY, ratioX, ratioY, x: pageX, y: pageY, }; } /** * Update the dragged polygon point with the given x/y coords and update * the element style. * @param {Number} pageX the new x coordinate of the point * @param {Number} pageY the new y coordinate of the point */ _handlePolygonMove(pageX, pageY) { const { point, unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } = this[_dragging]; const deltaX = (pageX - x) * ratioX; const deltaY = (pageY - y) * ratioY; const newX = round(valueX + deltaX, unitX); const newY = round(valueY + deltaY, unitY); let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; polygonDef += this.coordUnits .map((coords, i) => { return i === point ? `${newX}${unitX} ${newY}${unitY}` : `${coords[0]} ${coords[1]}`; }) .join(", "); polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); } /** * Add new point to the polygon defintion and update element style. * TODO: Bug 1436054 - Do not default to percentage unit when inserting new point. * https://bugzilla.mozilla.org/show_bug.cgi?id=1436054 * * @param {Number} after the index of the point that the new point should be added after * @param {Number} x the x coordinate of the new point * @param {Number} y the y coordinate of the new point */ _addPolygonPoint(after, x, y) { let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; polygonDef += this.coordUnits .map((coords, i) => { return i === after ? `${coords[0]} ${coords[1]}, ${x}% ${y}%` : `${coords[0]} ${coords[1]}`; }) .join(", "); polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); this.hoveredPoint = after + 1; this._emitHoverEvent(this.hoveredPoint); this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); } /** * Remove point from polygon defintion and update the element style. * @param {Number} point the index of the point to delete */ _deletePolygonPoint(point) { const coordinates = this.coordUnits.slice(); coordinates.splice(point, 1); let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; polygonDef += coordinates .map((coords, i) => { return `${coords[0]} ${coords[1]}`; }) .join(", "); polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); this.hoveredPoint = null; this._emitHoverEvent(this.hoveredPoint); this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); } /** * Handle a click when highlighting a circle. * @param {Number} pageX the x coordinate of the click * @param {Number} pageY the y coordinate of the click */ _handleCircleClick(pageX, pageY) { const { width, height } = this.currentDimensions; const { percentX, percentY } = this.convertPageCoordsToPercent( pageX, pageY ); const point = this.getCirclePointAt(percentX, percentY); if (!point) { return; } this.setCursor("grabbing"); if (point === "center") { const { cx, cy } = this.coordUnits; const cxComputed = (this.coordinates.cx / 100) * width; const cyComputed = (this.coordinates.cy / 100) * height; const unitX = getUnit(cx); const unitY = getUnit(cy); const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); const ratioX = this.getUnitToPixelRatio(unitX, width); const ratioY = this.getUnitToPixelRatio(unitY, height); this[_dragging] = { point, unitX, unitY, valueX, valueY, ratioX, ratioY, x: pageX, y: pageY, }; } else if (point === "radius") { let { radius } = this.coordinates; const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2); radius = (radius / 100) * computedSize; let value = this.coordUnits.radius; const unit = getUnit(value); value = isUnitless(value) ? radius : parseFloat(value); const ratio = this.getUnitToPixelRatio(unit, computedSize); this[_dragging] = { point, value, origRadius: radius, unit, ratio }; } } /** * Set the center/radius of the circle according to the mouse position and * update the element style. * @param {String} point either "center" or "radius" * @param {Number} pageX the x coordinate of the mouse position, in terms of % * relative to the element * @param {Number} pageY the y coordinate of the mouse position, in terms of % * relative to the element */ _handleCircleMove(point, pageX, pageY) { const { radius, cx, cy } = this.coordUnits; if (point === "center") { const { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } = this[_dragging]; const deltaX = (pageX - x) * ratioX; const deltaY = (pageY - y) * ratioY; const newCx = `${round(valueX + deltaX, unitX)}${unitX}`; const newCy = `${round(valueY + deltaY, unitY)}${unitY}`; // if not defined by the user, geometryBox will be an empty string; trim() cleans up const circleDef = `circle(${radius} at ${newCx} ${newCy}) ${this.geometryBox}`.trim(); this.emit("highlighter-event", { type: "shape-change", value: circleDef, }); } else if (point === "radius") { const { value, unit, origRadius, ratio } = this[_dragging]; // convert center point to px, then get distance between center and mouse. const { x: pageCx, y: pageCy } = this.convertPercentToPageCoords( this.coordinates.cx, this.coordinates.cy ); const newRadiusPx = getDistance(pageCx, pageCy, pageX, pageY); const delta = (newRadiusPx - origRadius) * ratio; const newRadius = `${round(value + delta, unit)}${unit}`; const circleDef = `circle(${newRadius} at ${cx} ${cy}) ${this.geometryBox}`.trim(); this.emit("highlighter-event", { type: "shape-change", value: circleDef, }); } } /** * Handle a click when highlighting an ellipse. * @param {Number} pageX the x coordinate of the click * @param {Number} pageY the y coordinate of the click */ _handleEllipseClick(pageX, pageY) { const { width, height } = this.currentDimensions; const { percentX, percentY } = this.convertPageCoordsToPercent( pageX, pageY ); const point = this.getEllipsePointAt(percentX, percentY); if (!point) { return; } this.setCursor("grabbing"); if (point === "center") { const { cx, cy } = this.coordUnits; const cxComputed = (this.coordinates.cx / 100) * width; const cyComputed = (this.coordinates.cy / 100) * height; const unitX = getUnit(cx); const unitY = getUnit(cy); const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); const ratioX = this.getUnitToPixelRatio(unitX, width); const ratioY = this.getUnitToPixelRatio(unitY, height); this[_dragging] = { point, unitX, unitY, valueX, valueY, ratioX, ratioY, x: pageX, y: pageY, }; } else if (point === "rx") { let { rx } = this.coordinates; rx = (rx / 100) * width; let value = this.coordUnits.rx; const unit = getUnit(value); value = isUnitless(value) ? rx : parseFloat(value); const ratio = this.getUnitToPixelRatio(unit, width); this[_dragging] = { point, value, origRadius: rx, unit, ratio }; } else if (point === "ry") { let { ry } = this.coordinates; ry = (ry / 100) * height; let value = this.coordUnits.ry; const unit = getUnit(value); value = isUnitless(value) ? ry : parseFloat(value); const ratio = this.getUnitToPixelRatio(unit, height); this[_dragging] = { point, value, origRadius: ry, unit, ratio }; } } /** * Set center/rx/ry of the ellispe according to the mouse position and update the * element style. * @param {String} point "center", "rx", or "ry" * @param {Number} pageX the x coordinate of the mouse position, in terms of % * relative to the element * @param {Number} pageY the y coordinate of the mouse position, in terms of % * relative to the element */ _handleEllipseMove(point, pageX, pageY) { const { percentX, percentY } = this.convertPageCoordsToPercent( pageX, pageY ); const { rx, ry, cx, cy } = this.coordUnits; if (point === "center") { const { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } = this[_dragging]; const deltaX = (pageX - x) * ratioX; const deltaY = (pageY - y) * ratioY; const newCx = `${round(valueX + deltaX, unitX)}${unitX}`; const newCy = `${round(valueY + deltaY, unitY)}${unitY}`; const ellipseDef = `ellipse(${rx} ${ry} at ${newCx} ${newCy}) ${this.geometryBox}`.trim(); this.emit("highlighter-event", { type: "shape-change", value: ellipseDef, }); } else if (point === "rx") { const { value, unit, origRadius, ratio } = this[_dragging]; const newRadiusPercent = Math.abs(percentX - this.coordinates.cx); const { width } = this.currentDimensions; const delta = ((newRadiusPercent / 100) * width - origRadius) * ratio; const newRadius = `${round(value + delta, unit)}${unit}`; const ellipseDef = `ellipse(${newRadius} ${ry} at ${cx} ${cy}) ${this.geometryBox}`.trim(); this.emit("highlighter-event", { type: "shape-change", value: ellipseDef, }); } else if (point === "ry") { const { value, unit, origRadius, ratio } = this[_dragging]; const newRadiusPercent = Math.abs(percentY - this.coordinates.cy); const { height } = this.currentDimensions; const delta = ((newRadiusPercent / 100) * height - origRadius) * ratio; const newRadius = `${round(value + delta, unit)}${unit}`; const ellipseDef = `ellipse(${rx} ${newRadius} at ${cx} ${cy}) ${this.geometryBox}`.trim(); this.emit("highlighter-event", { type: "shape-change", value: ellipseDef, }); } } /** * Handle a click when highlighting an inset. * @param {Number} pageX the x coordinate of the click * @param {Number} pageY the y coordinate of the click */ _handleInsetClick(pageX, pageY) { const { width, height } = this.currentDimensions; const { percentX, percentY } = this.convertPageCoordsToPercent( pageX, pageY ); const point = this.getInsetPointAt(percentX, percentY); if (!point) { return; } this.setCursor("grabbing"); let value = this.coordUnits[point]; const size = point === "left" || point === "right" ? width : height; const computedValue = (this.coordinates[point] / 100) * size; const unit = getUnit(value); value = isUnitless(value) ? computedValue : parseFloat(value); const ratio = this.getUnitToPixelRatio(unit, size); const origValue = point === "left" || point === "right" ? pageX : pageY; this[_dragging] = { point, value, origValue, unit, ratio }; } /** * Set the top/left/right/bottom of the inset shape according to the mouse position * and update the element style. * @param {String} point "top", "left", "right", or "bottom" * @param {Number} pageX the x coordinate of the mouse position, in terms of % * relative to the element * @param {Number} pageY the y coordinate of the mouse position, in terms of % * relative to the element * @memberof ShapesHighlighter */ _handleInsetMove(point, pageX, pageY) { let { top, left, right, bottom } = this.coordUnits; const { value, origValue, unit, ratio } = this[_dragging]; if (point === "left") { const delta = (pageX - origValue) * ratio; left = `${round(value + delta, unit)}${unit}`; } else if (point === "right") { const delta = (pageX - origValue) * ratio; right = `${round(value - delta, unit)}${unit}`; } else if (point === "top") { const delta = (pageY - origValue) * ratio; top = `${round(value + delta, unit)}${unit}`; } else if (point === "bottom") { const delta = (pageY - origValue) * ratio; bottom = `${round(value - delta, unit)}${unit}`; } let insetDef = this.insetRound ? `inset(${top} ${right} ${bottom} ${left} round ${this.insetRound})` : `inset(${top} ${right} ${bottom} ${left})`; insetDef += this.geometryBox ? this.geometryBox : ""; this.emit("highlighter-event", { type: "shape-change", value: insetDef }); } _handleMouseMoveNotDragging(pageX, pageY) { const { percentX, percentY } = this.convertPageCoordsToPercent( pageX, pageY ); if (this.transformMode) { const point = this.getTransformPointAt(percentX, percentY); this.hoveredPoint = point; this._handleMarkerHover(point); } else if (this.shapeType === "polygon") { const point = this.getPolygonPointAt(percentX, percentY); const oldHoveredPoint = this.hoveredPoint; this.hoveredPoint = point !== -1 ? point : null; if (this.hoveredPoint !== oldHoveredPoint) { this._emitHoverEvent(this.hoveredPoint); } this._handleMarkerHover(point); } else if (this.shapeType === "circle") { const point = this.getCirclePointAt(percentX, percentY); const oldHoveredPoint = this.hoveredPoint; this.hoveredPoint = point ? point : null; if (this.hoveredPoint !== oldHoveredPoint) { this._emitHoverEvent(this.hoveredPoint); } this._handleMarkerHover(point); } else if (this.shapeType === "ellipse") { const point = this.getEllipsePointAt(percentX, percentY); const oldHoveredPoint = this.hoveredPoint; this.hoveredPoint = point ? point : null; if (this.hoveredPoint !== oldHoveredPoint) { this._emitHoverEvent(this.hoveredPoint); } this._handleMarkerHover(point); } else if (this.shapeType === "inset") { const point = this.getInsetPointAt(percentX, percentY); const oldHoveredPoint = this.hoveredPoint; this.hoveredPoint = point ? point : null; if (this.hoveredPoint !== oldHoveredPoint) { this._emitHoverEvent(this.hoveredPoint); } this._handleMarkerHover(point); } } /** * Change the appearance of the given marker when the mouse hovers over it. * @param {String|Number} point if the shape is a polygon, the integer index of the * point being hovered. Otherwise, a string identifying the point being hovered. * Integers < 0 and falsey values excluding 0 indicate no point is being hovered. */ _handleMarkerHover(point) { // Hide hover marker for now, will be shown if point is a valid hover target this.getElement("marker-hover").setAttribute("hidden", true); // Catch all falsey values except when point === 0, as that's a valid point if (!point && point !== 0) { this.setCursor("auto"); return; } const hoverCursor = this[_dragging] ? "grabbing" : "grab"; if (this.transformMode) { if (!point) { this.setCursor("auto"); return; } const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } = this.transformedBoundingBox; const points = [ { pointName: "translate", x: center[0], y: center[1], cursor: hoverCursor, }, { pointName: "scale-se", x: se[0], y: se[1], anchor: "nw" }, { pointName: "scale-ne", x: ne[0], y: ne[1], anchor: "sw" }, { pointName: "scale-sw", x: sw[0], y: sw[1], anchor: "ne" }, { pointName: "scale-nw", x: nw[0], y: nw[1], anchor: "se" }, { pointName: "scale-n", x: n[0], y: n[1], anchor: "s" }, { pointName: "scale-s", x: s[0], y: s[1], anchor: "n" }, { pointName: "scale-e", x: e[0], y: e[1], anchor: "w" }, { pointName: "scale-w", x: w[0], y: w[1], anchor: "e" }, { pointName: "rotate", x: rotatePoint[0], y: rotatePoint[1], cursor: hoverCursor, }, ]; for (const { pointName, x, y, cursor, anchor } of points) { if (point === pointName) { this._drawHoverMarker([[x, y]]); // If the point is a scale handle, we will need to determine the direction // of the resize cursor based on the position of the handle relative to its // "anchor" (the handle opposite to it). if (pointName.includes("scale")) { const direction = this.getRoughDirection(pointName, anchor); this.setCursor(`${direction}-resize`); } else { this.setCursor(cursor); } } } } else if (this.shapeType === "polygon") { if (point === -1) { this.setCursor("auto"); return; } this.setCursor(hoverCursor); this._drawHoverMarker([this.coordinates[point]]); } else if (this.shapeType === "circle") { this.setCursor(hoverCursor); const { cx, cy, rx } = this.coordinates; if (point === "radius") { this._drawHoverMarker([[cx + rx, cy]]); } else if (point === "center") { this._drawHoverMarker([[cx, cy]]); } } else if (this.shapeType === "ellipse") { this.setCursor(hoverCursor); if (point === "center") { const { cx, cy } = this.coordinates; this._drawHoverMarker([[cx, cy]]); } else if (point === "rx") { const { cx, cy, rx } = this.coordinates; this._drawHoverMarker([[cx + rx, cy]]); } else if (point === "ry") { const { cx, cy, ry } = this.coordinates; this._drawHoverMarker([[cx, cy + ry]]); } } else if (this.shapeType === "inset") { this.setCursor(hoverCursor); const { top, right, bottom, left } = this.coordinates; const centerX = (left + (100 - right)) / 2; const centerY = (top + (100 - bottom)) / 2; const points = point.split(","); const coords = points.map(side => { if (side === "top") { return [centerX, top]; } else if (side === "right") { return [100 - right, centerY]; } else if (side === "bottom") { return [centerX, 100 - bottom]; } else if (side === "left") { return [left, centerY]; } return null; }); this._drawHoverMarker(coords); } } _drawHoverMarker(points) { const { width, height } = this.currentDimensions; const zoom = getCurrentZoom(this.win); const path = points .map(([x, y]) => { return getCirclePath(BASE_MARKER_SIZE, x, y, width, height, zoom); }) .join(" "); const markerHover = this.getElement("marker-hover"); markerHover.setAttribute("d", path); markerHover.removeAttribute("hidden"); } _emitHoverEvent(point) { if (point === null || point === undefined) { this.emit("highlighter-event", { type: "shape-hover-off", }); } else { this.emit("highlighter-event", { type: "shape-hover-on", point: point.toString(), }); } } /** * Convert the given coordinates on the page to percentages relative to the current * element. * @param {Number} pageX the x coordinate on the page * @param {Number} pageY the y coordinate on the page * @returns {Object} object of form {percentX, percentY}, which are the x/y coords * in percentages relative to the element. */ convertPageCoordsToPercent(pageX, pageY) { // If the current node is in an iframe, we get dimensions relative to the frame. const dims = this.frameDimensions; const { top, left, width, height } = dims; pageX -= left; pageY -= top; const percentX = (pageX * 100) / width; const percentY = (pageY * 100) / height; return { percentX, percentY }; } /** * Convert the given x/y coordinates, in percentages relative to the current element, * to pixel coordinates relative to the page * @param {Number} x the x coordinate * @param {Number} y the y coordinate * @returns {Object} object of form {x, y}, which are the x/y coords in pixels * relative to the page * * @memberof ShapesHighlighter */ convertPercentToPageCoords(x, y) { const dims = this.frameDimensions; const { top, left, width, height } = dims; x = (x * width) / 100; y = (y * height) / 100; x += left; y += top; return { x, y }; } /** * Get which transformation should be applied based on the mouse position. * @param {Number} pageX the x coordinate of the mouse. * @param {Number} pageY the y coordinate of the mouse. * @returns {String} a string describing the transformation that should be applied * to the shape. */ getTransformPointAt(pageX, pageY) { const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } = this.transformedBoundingBox; const { width, height } = this.currentDimensions; const zoom = getCurrentZoom(this.win); const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; const points = [ { pointName: "translate", x: center[0], y: center[1] }, { pointName: "scale-se", x: se[0], y: se[1] }, { pointName: "scale-ne", x: ne[0], y: ne[1] }, { pointName: "scale-sw", x: sw[0], y: sw[1] }, { pointName: "scale-nw", x: nw[0], y: nw[1] }, ]; if (this.shapeType === "polygon" || this.shapeType === "ellipse") { points.push( { pointName: "scale-n", x: n[0], y: n[1] }, { pointName: "scale-s", x: s[0], y: s[1] }, { pointName: "scale-e", x: e[0], y: e[1] }, { pointName: "scale-w", x: w[0], y: w[1] } ); } if (this.shapeType === "polygon") { const x = rotatePoint[0]; const y = rotatePoint[1]; if ( pageX >= x - clickRadiusX && pageX <= x + clickRadiusX && pageY >= y - clickRadiusY && pageY <= y + clickRadiusY ) { return "rotate"; } } for (const { pointName, x, y } of points) { if ( pageX >= x - clickRadiusX && pageX <= x + clickRadiusX && pageY >= y - clickRadiusY && pageY <= y + clickRadiusY ) { return pointName; } } return ""; } /** * Get the id of the point on the polygon highlighter at the given coordinate. * @param {Number} pageX the x coordinate on the page, in % relative to the element * @param {Number} pageY the y coordinate on the page, in % relative to the element * @returns {Number} the index of the point that was clicked on in this.coordinates, * or -1 if none of the points were clicked on. */ getPolygonPointAt(pageX, pageY) { const { coordinates } = this; const { width, height } = this.currentDimensions; const zoom = getCurrentZoom(this.win); const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; for (const [index, coord] of coordinates.entries()) { const [x, y] = coord; if ( pageX >= x - clickRadiusX && pageX <= x + clickRadiusX && pageY >= y - clickRadiusY && pageY <= y + clickRadiusY ) { return index; } } return -1; } /** * Check if the mouse clicked on a line of the polygon, and if so, add a point near * the click. * @param {Number} pageX the x coordinate on the page, in % relative to the element * @param {Number} pageY the y coordinate on the page, in % relative to the element */ getPolygonClickedLine(pageX, pageY) { const { coordinates } = this; const { width } = this.currentDimensions; const clickWidth = (LINE_CLICK_WIDTH * 100) / width; for (let i = 0; i < coordinates.length; i++) { const [x1, y1] = coordinates[i]; const [x2, y2] = i === coordinates.length - 1 ? coordinates[0] : coordinates[i + 1]; // Get the distance between clicked point and line drawn between points 1 and 2 // to check if the click was on the line between those two points. const distance = distanceToLine(x1, y1, x2, y2, pageX, pageY); if ( distance <= clickWidth && Math.min(x1, x2) - clickWidth <= pageX && pageX <= Math.max(x1, x2) + clickWidth && Math.min(y1, y2) - clickWidth <= pageY && pageY <= Math.max(y1, y2) + clickWidth ) { // Get the point on the line closest to the clicked point. const [newX, newY] = projection(x1, y1, x2, y2, pageX, pageY); // Default unit for new points is percentages this._addPolygonPoint(i, round(newX, "%"), round(newY, "%")); return; } } } /** * Check if the center point or radius of the circle highlighter is at given coords * @param {Number} pageX the x coordinate on the page, in % relative to the element * @param {Number} pageY the y coordinate on the page, in % relative to the element * @returns {String} "center" if the center point was clicked, "radius" if the radius * was clicked, "" if neither was clicked. */ getCirclePointAt(pageX, pageY) { const { cx, cy, rx, ry } = this.coordinates; const { width, height } = this.currentDimensions; const zoom = getCurrentZoom(this.win); const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) { return "center"; } const clickWidthX = (LINE_CLICK_WIDTH * 100) / width; const clickWidthY = (LINE_CLICK_WIDTH * 100) / height; if ( clickedOnEllipseEdge( pageX, pageY, cx, cy, rx, ry, clickWidthX, clickWidthY ) || clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY) ) { return "radius"; } return ""; } /** * Check if the center or rx/ry points of the ellipse highlighter is at given point * @param {Number} pageX the x coordinate on the page, in % relative to the element * @param {Number} pageY the y coordinate on the page, in % relative to the element * @returns {String} "center" if the center point was clicked, "rx" if the x-radius * point was clicked, "ry" if the y-radius point was clicked, * "" if none was clicked. */ getEllipsePointAt(pageX, pageY) { const { cx, cy, rx, ry } = this.coordinates; const { width, height } = this.currentDimensions; const zoom = getCurrentZoom(this.win); const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) { return "center"; } if (clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY)) { return "rx"; } if (clickedOnPoint(pageX, pageY, cx, cy + ry, clickRadiusX, clickRadiusY)) { return "ry"; } return ""; } /** * Check if the edges of the inset highlighter is at given coords * @param {Number} pageX the x coordinate on the page, in % relative to the element * @param {Number} pageY the y coordinate on the page, in % relative to the element * @returns {String} "top", "left", "right", or "bottom" if any of those edges were * clicked. "" if none were clicked. */ // eslint-disable-next-line complexity getInsetPointAt(pageX, pageY) { const { top, left, right, bottom } = this.coordinates; const zoom = getCurrentZoom(this.win); const { width, height } = this.currentDimensions; const clickWidthX = (LINE_CLICK_WIDTH * 100) / width; const clickWidthY = (LINE_CLICK_WIDTH * 100) / height; const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; const centerX = (left + (100 - right)) / 2; const centerY = (top + (100 - bottom)) / 2; if ( (pageX >= left - clickWidthX && pageX <= left + clickWidthX && pageY >= top && pageY <= 100 - bottom) || clickedOnPoint(pageX, pageY, left, centerY, clickRadiusX, clickRadiusY) ) { return "left"; } if ( (pageX >= 100 - right - clickWidthX && pageX <= 100 - right + clickWidthX && pageY >= top && pageY <= 100 - bottom) || clickedOnPoint( pageX, pageY, 100 - right, centerY, clickRadiusX, clickRadiusY ) ) { return "right"; } if ( (pageY >= top - clickWidthY && pageY <= top + clickWidthY && pageX >= left && pageX <= 100 - right) || clickedOnPoint(pageX, pageY, centerX, top, clickRadiusX, clickRadiusY) ) { return "top"; } if ( (pageY >= 100 - bottom - clickWidthY && pageY <= 100 - bottom + clickWidthY && pageX >= left && pageX <= 100 - right) || clickedOnPoint( pageX, pageY, centerX, 100 - bottom, clickRadiusX, clickRadiusY ) ) { return "bottom"; } return ""; } /** * Parses the CSS definition given and returns the shape type associated * with the definition and the coordinates necessary to draw the shape. * @param {String} definition the input CSS definition * @returns {Object} null if the definition is not of a known shape type, * or an object of the type { shapeType, coordinates }, where * shapeType is the name of the shape and coordinates are an array * or object of the coordinates needed to draw the shape. */ _parseCSSShapeValue(definition) { const shapeTypes = [ { name: "polygon", prefix: "polygon(", coordParser: this.polygonPoints.bind(this), }, { name: "circle", prefix: "circle(", coordParser: this.circlePoints.bind(this), }, { name: "ellipse", prefix: "ellipse(", coordParser: this.ellipsePoints.bind(this), }, { name: "inset", prefix: "inset(", coordParser: this.insetPoints.bind(this), }, ]; const geometryTypes = ["margin", "border", "padding", "content"]; // default to border for clip-path, and margin for shape-outside let referenceBox = this.property === "clip-path" ? "border" : "margin"; for (const geometry of geometryTypes) { if (definition.includes(geometry)) { referenceBox = geometry; } } this.referenceBox = referenceBox; this.useStrokeBox = definition.includes("stroke-box"); this.geometryBox = definition .substring(definition.lastIndexOf(")") + 1) .trim(); for (const { name, prefix, coordParser } of shapeTypes) { if (definition.includes(prefix)) { // the closing paren of the shape function is always the last one in definition. definition = definition.substring( prefix.length, definition.lastIndexOf(")") ); return { shapeType: name, coordinates: coordParser(definition), }; } } return null; } /** * Parses the definition of the CSS polygon() function and returns its points, * converted to percentages. * @param {String} definition the arguments of the polygon() function * @returns {Array} an array of the points of the polygon, with all values * evaluated and converted to percentages */ polygonPoints(definition) { this.coordUnits = this.polygonRawPoints(); if (!this.origCoordUnits) { this.origCoordUnits = this.coordUnits; } const splitDef = definition.split(", "); if (splitDef[0] === "evenodd" || splitDef[0] === "nonzero") { splitDef.shift(); } let minX = Number.MAX_SAFE_INTEGER; let minY = Number.MAX_SAFE_INTEGER; let maxX = Number.MIN_SAFE_INTEGER; let maxY = Number.MIN_SAFE_INTEGER; const coordinates = splitDef.map(coords => { const [x, y] = splitCoords(coords).map( this.convertCoordsToPercent.bind(this) ); if (x < minX) { minX = x; } if (y < minY) { minY = y; } if (x > maxX) { maxX = x; } if (y > maxY) { maxY = y; } return [x, y]; }); this.boundingBox = { minX, minY, maxX, maxY }; if (!this.origBoundingBox) { this.origBoundingBox = this.boundingBox; } return coordinates; } /** * Parse the raw (non-computed) definition of the CSS polygon. * @returns {Array} an array of the points of the polygon, with units preserved. */ polygonRawPoints() { let definition = getDefinedShapeProperties(this.currentNode, this.property); if (definition === this.rawDefinition && this.coordUnits) { return this.coordUnits; } this.rawDefinition = definition; definition = definition.substring(8, definition.lastIndexOf(")")); const splitDef = definition.split(", "); if (splitDef[0].includes("evenodd") || splitDef[0].includes("nonzero")) { this.fillRule = splitDef[0].trim(); splitDef.shift(); } else { this.fillRule = ""; } return splitDef.map(coords => { return splitCoords(coords).map(coord => { // Undo the insertion of   that was done in splitCoords. return coord.replace(/\u00a0/g, " "); }); }); } /** * Parses the definition of the CSS circle() function and returns the x/y radiuses and * center coordinates, converted to percentages. * @param {String} definition the arguments of the circle() function * @returns {Object} an object of the form { rx, ry, cx, cy }, where rx and ry are the * radiuses for the x and y axes, and cx and cy are the x/y coordinates for the * center of the circle. All values are evaluated and converted to percentages. */ circlePoints(definition) { this.coordUnits = this.circleRawPoints(); if (!this.origCoordUnits) { this.origCoordUnits = this.coordUnits; } const values = definition.split("at"); let radius = values[0] ? values[0].trim() : "closest-side"; const { width, height } = this.currentDimensions; const center = splitCoords(values[1]).map( this.convertCoordsToPercent.bind(this) ); // Percentage values for circle() are resolved from the // used width and height of the reference box as sqrt(width^2+height^2)/sqrt(2). const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2); // Position coordinates for circle center in pixels. const cxPx = (width * center[0]) / 100; const cyPx = (height * center[1]) / 100; if (radius === "closest-side") { // radius is the distance from center to closest side of reference box radius = Math.min(cxPx, cyPx, width - cxPx, height - cyPx); radius = coordToPercent(`${radius}px`, computedSize); } else if (radius === "farthest-side") { // radius is the distance from center to farthest side of reference box radius = Math.max(cxPx, cyPx, width - cxPx, height - cyPx); radius = coordToPercent(`${radius}px`, computedSize); } else if (radius.includes("calc(")) { radius = evalCalcExpression( radius.substring(5, radius.length - 1), computedSize ); } else { radius = coordToPercent(radius, computedSize); } // Scale both radiusX and radiusY to match the radius computed // using the above equation. const ratioX = width / computedSize; const ratioY = height / computedSize; const radiusX = radius / ratioX; const radiusY = radius / ratioY; this.boundingBox = { minX: center[0] - radiusX, maxX: center[0] + radiusX, minY: center[1] - radiusY, maxY: center[1] + radiusY, }; if (!this.origBoundingBox) { this.origBoundingBox = this.boundingBox; } return { radius, rx: radiusX, ry: radiusY, cx: center[0], cy: center[1] }; } /** * Parse the raw (non-computed) definition of the CSS circle. * @returns {Object} an object of the points of the circle (cx, cy, radius), * with units preserved. */ circleRawPoints() { let definition = getDefinedShapeProperties(this.currentNode, this.property); if (definition === this.rawDefinition && this.coordUnits) { return this.coordUnits; } this.rawDefinition = definition; definition = definition.substring(7, definition.lastIndexOf(")")); const values = definition.split("at"); const [cx = "", cy = ""] = values[1] ? splitCoords(values[1]).map(coord => { // Undo the insertion of   that was done in splitCoords. return coord.replace(/\u00a0/g, " "); }) : []; const radius = values[0] ? values[0].trim() : "closest-side"; return { cx, cy, radius }; } /** * Parses the computed style definition of the CSS ellipse() function and returns the * x/y radii and center coordinates, converted to percentages. * @param {String} definition the arguments of the ellipse() function * @returns {Object} an object of the form { rx, ry, cx, cy }, where rx and ry are the * radiuses for the x and y axes, and cx and cy are the x/y coordinates for the * center of the ellipse. All values are evaluated and converted to percentages */ ellipsePoints(definition) { this.coordUnits = this.ellipseRawPoints(); if (!this.origCoordUnits) { this.origCoordUnits = this.coordUnits; } const values = definition.split("at"); const center = splitCoords(values[1]).map( this.convertCoordsToPercent.bind(this) ); let radii = values[0] ? values[0].trim() : "closest-side closest-side"; radii = splitCoords(radii).map((radius, i) => { if (radius === "closest-side") { // radius is the distance from center to closest x/y side of reference box return i % 2 === 0 ? Math.min(center[0], 100 - center[0]) : Math.min(center[1], 100 - center[1]); } else if (radius === "farthest-side") { // radius is the distance from center to farthest x/y side of reference box return i % 2 === 0 ? Math.max(center[0], 100 - center[0]) : Math.max(center[1], 100 - center[1]); } return this.convertCoordsToPercent(radius, i); }); this.boundingBox = { minX: center[0] - radii[0], maxX: center[0] + radii[0], minY: center[1] - radii[1], maxY: center[1] + radii[1], }; if (!this.origBoundingBox) { this.origBoundingBox = this.boundingBox; } return { rx: radii[0], ry: radii[1], cx: center[0], cy: center[1] }; } /** * Parse the raw (non-computed) definition of the CSS ellipse. * @returns {Object} an object of the points of the ellipse (cx, cy, rx, ry), * with units preserved. */ ellipseRawPoints() { let definition = getDefinedShapeProperties(this.currentNode, this.property); if (definition === this.rawDefinition && this.coordUnits) { return this.coordUnits; } this.rawDefinition = definition; definition = definition.substring(8, definition.lastIndexOf(")")); const values = definition.split("at"); const [rx = "closest-side", ry = "closest-side"] = values[0] ? splitCoords(values[0]).map(coord => { // Undo the insertion of   that was done in splitCoords. return coord.replace(/\u00a0/g, " "); }) : []; const [cx = "", cy = ""] = values[1] ? splitCoords(values[1]).map(coord => { return coord.replace(/\u00a0/g, " "); }) : []; return { rx, ry, cx, cy }; } /** * Parses the definition of the CSS inset() function and returns the x/y offsets and * width/height of the shape, converted to percentages. Border radiuses (given after * "round" in the definition) are currently ignored. * @param {String} definition the arguments of the inset() function * @returns {Object} an object of the form { x, y, width, height }, which are the top/ * left positions and width/height of the shape. */ insetPoints(definition) { this.coordUnits = this.insetRawPoints(); if (!this.origCoordUnits) { this.origCoordUnits = this.coordUnits; } const values = definition.split(" round "); const offsets = splitCoords(values[0]).map( this.convertCoordsToPercent.bind(this) ); let top, left, right, bottom; // The offsets, like margin/padding/border, are in order: top, right, bottom, left. if (offsets.length === 1) { top = left = right = bottom = offsets[0]; } else if (offsets.length === 2) { top = bottom = offsets[0]; left = right = offsets[1]; } else if (offsets.length === 3) { top = offsets[0]; left = right = offsets[1]; bottom = offsets[2]; } else if (offsets.length === 4) { top = offsets[0]; right = offsets[1]; bottom = offsets[2]; left = offsets[3]; } // maxX/maxY are found by subtracting the right/bottom edges from 100 // (the width/height of the element in %) this.boundingBox = { minX: left, maxX: 100 - right, minY: top, maxY: 100 - bottom, }; if (!this.origBoundingBox) { this.origBoundingBox = this.boundingBox; } return { top, left, right, bottom }; } /** * Parse the raw (non-computed) definition of the CSS inset. * @returns {Object} an object of the points of the inset (top, right, bottom, left), * with units preserved. */ insetRawPoints() { let definition = getDefinedShapeProperties(this.currentNode, this.property); if (definition === this.rawDefinition && this.coordUnits) { return this.coordUnits; } this.rawDefinition = definition; definition = definition.substring(6, definition.lastIndexOf(")")); const values = definition.split(" round "); this.insetRound = values[1]; const offsets = splitCoords(values[0]).map(coord => { // Undo the insertion of   that was done in splitCoords. return coord.replace(/\u00a0/g, " "); }); let top, left, right, bottom = 0; if (offsets.length === 1) { top = left = right = bottom = offsets[0]; } else if (offsets.length === 2) { top = bottom = offsets[0]; left = right = offsets[1]; } else if (offsets.length === 3) { top = offsets[0]; left = right = offsets[1]; bottom = offsets[2]; } else if (offsets.length === 4) { top = offsets[0]; right = offsets[1]; bottom = offsets[2]; left = offsets[3]; } return { top, left, right, bottom }; } convertCoordsToPercent(coord, i) { const { width, height } = this.currentDimensions; const size = i % 2 === 0 ? width : height; if (coord.includes("calc(")) { return evalCalcExpression(coord.substring(5, coord.length - 1), size); } return coordToPercent(coord, size); } /** * Destroy the nodes. Remove listeners. */ destroy() { const { pageListenerTarget } = this.highlighterEnv; if (pageListenerTarget) { DOM_EVENTS.forEach(type => pageListenerTarget.removeEventListener(type, this) ); } super.destroy(this); this.markup.destroy(); } /** * Get the element in the highlighter markup with the given id * @param {String} id * @returns {Object} the element with the given id */ getElement(id) { return this.markup.getElement(this.ID_CLASS_PREFIX + id); } /** * Return whether all the elements used to draw shapes are hidden. * @returns {Boolean} */ areShapesHidden() { return ( this.getElement("ellipse").hasAttribute("hidden") && this.getElement("polygon").hasAttribute("hidden") && this.getElement("rect").hasAttribute("hidden") && this.getElement("bounding-box").hasAttribute("hidden") ); } /** * Show the highlighter on a given node */ _show() { this.hoveredPoint = this.options.hoverPoint; this.transformMode = this.options.transformMode; this.coordinates = null; this.coordUnits = null; this.origBoundingBox = null; this.origCoordUnits = null; this.origCoordinates = null; this.transformedBoundingBox = null; if (this.transformMode) { this.transformMatrix = identity(); } if (this._hasMoved() && this.transformMode) { this.transformedBoundingBox = this.calculateTransformedBoundingBox(); } return this._update(); } /** * 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 shape has * changed (which can happen when you change a CSS properties for instance). */ _hasMoved() { let hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this); if (hasMoved) { this.origBoundingBox = null; this.origCoordUnits = null; this.origCoordinates = null; if (this.transformMode) { this.transformMatrix = identity(); } } const oldShapeCoordinates = JSON.stringify(this.coordinates); // TODO: need other modes too. if (this.options.mode.startsWith("css")) { const property = shapeModeToCssPropertyName(this.options.mode); // change camelCase to kebab-case this.property = property.replace(/([a-z][A-Z])/g, g => { return g[0] + "-" + g[1].toLowerCase(); }); const style = getComputedStyle(this.currentNode)[property]; if (!style || style === "none") { this.coordinates = []; this.shapeType = "none"; } else { const { coordinates, shapeType } = this._parseCSSShapeValue(style); this.coordinates = coordinates; if (!this.origCoordinates) { this.origCoordinates = coordinates; } this.shapeType = shapeType; } } const newShapeCoordinates = JSON.stringify(this.coordinates); hasMoved = hasMoved || oldShapeCoordinates !== newShapeCoordinates; if (this.transformMode && hasMoved) { this.transformedBoundingBox = this.calculateTransformedBoundingBox(); } return hasMoved; } /** * Hide all elements used to highlight CSS different shapes. */ _hideShapes() { this.getElement("ellipse").setAttribute("hidden", true); this.getElement("polygon").setAttribute("hidden", true); this.getElement("rect").setAttribute("hidden", true); this.getElement("bounding-box").setAttribute("hidden", true); this.getElement("markers").setAttribute("d", ""); this.getElement("markers-outline").setAttribute("d", ""); this.getElement("rotate-line").setAttribute("d", ""); this.getElement("quad").setAttribute("hidden", true); this.getElement("clip-ellipse").setAttribute("hidden", true); this.getElement("clip-polygon").setAttribute("hidden", true); this.getElement("clip-rect").setAttribute("hidden", true); this.getElement("dashed-polygon").setAttribute("hidden", true); this.getElement("dashed-ellipse").setAttribute("hidden", true); this.getElement("dashed-rect").setAttribute("hidden", true); } /** * Update the highlighter for the current node. Called whenever the element's quads * or CSS shape has changed. * @returns {Boolean} whether the highlighter was successfully updated */ _update() { setIgnoreLayoutChanges(true); this.getElement("group").setAttribute("transform", ""); const root = this.getElement("root"); root.setAttribute("hidden", true); const { top, left, width, height } = this.currentDimensions; const zoom = getCurrentZoom(this.win); // Size the SVG like the current node. this.getElement("shape-container").setAttribute( "style", `top:${top}px;left:${left}px;width:${width}px;height:${height}px;` ); this._hideShapes(); this._updateShapes(width, height, zoom); // For both shape-outside and clip-path the element's quads are displayed for the // parts that overlap with the shape. The parts of the shape that extend past the // element's quads are shown with a dashed line. const quadRect = this.getElement("quad"); quadRect.removeAttribute("hidden"); this.getElement("polygon").setAttribute( "clip-path", "url(#shapes-quad-clip-path)" ); this.getElement("ellipse").setAttribute( "clip-path", "url(#shapes-quad-clip-path)" ); this.getElement("rect").setAttribute( "clip-path", "url(#shapes-quad-clip-path)" ); const { width: winWidth, height: winHeight } = this._winDimensions; root.removeAttribute("hidden"); root.setAttribute( "style", `position:absolute; width:${winWidth}px;height:${winHeight}px; overflow:hidden;` ); this._handleMarkerHover(this.hoveredPoint); setIgnoreLayoutChanges( false, this.highlighterEnv.window.document.documentElement ); return true; } /** * Update the SVGs to render the current CSS shape and add markers depending on shape * type and transform mode. * @param {Number} width the width of the element quads * @param {Number} height the height of the element quads * @param {Number} zoom the zoom level of the window */ _updateShapes(width, height, zoom) { if (this.transformMode && this.shapeType !== "none") { this._updateTransformMode(width, height, zoom); } else if (this.shapeType === "polygon") { this._updatePolygonShape(width, height, zoom); // Draw markers for each of the polygon's points. this._drawMarkers(this.coordinates, width, height, zoom); } else if (this.shapeType === "circle") { const { rx, cx, cy } = this.coordinates; // Shape renders for "circle()" and "ellipse()" use the same SVG nodes. this._updateEllipseShape(width, height, zoom); // Draw markers for center and radius points. this._drawMarkers( [ [cx, cy], [cx + rx, cy], ], width, height, zoom ); } else if (this.shapeType === "ellipse") { const { rx, ry, cx, cy } = this.coordinates; this._updateEllipseShape(width, height, zoom); // Draw markers for center, horizontal radius and vertical radius points. this._drawMarkers( [ [cx, cy], [cx + rx, cy], [cx, cy + ry], ], width, height, zoom ); } else if (this.shapeType === "inset") { const { top, left, right, bottom } = this.coordinates; const centerX = (left + (100 - right)) / 2; const centerY = (top + (100 - bottom)) / 2; const markerCoords = [ [centerX, top], [100 - right, centerY], [centerX, 100 - bottom], [left, centerY], ]; this._updateInsetShape(width, height, zoom); // Draw markers for each of the inset's sides. this._drawMarkers(markerCoords, width, height, zoom); } } /** * Update the SVGs for transform mode to fit the new shape. * @param {Number} width the width of the element quads * @param {Number} height the height of the element quads * @param {Number} zoom the zoom level of the window */ _updateTransformMode(width, height, zoom) { const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } = this.transformedBoundingBox; const boundingBox = this.getElement("bounding-box"); const path = `M${nw.join(" ")} L${ne.join(" ")} L${se.join(" ")} L${sw.join( " " )} Z`; boundingBox.setAttribute("d", path); boundingBox.removeAttribute("hidden"); const markerPoints = [center, nw, ne, se, sw]; if (this.shapeType === "polygon" || this.shapeType === "ellipse") { markerPoints.push(n, s, w, e); } if (this.shapeType === "polygon") { this._updatePolygonShape(width, height, zoom); markerPoints.push(rotatePoint); const rotateLine = `M ${center.join(" ")} L ${rotatePoint.join(" ")}`; this.getElement("rotate-line").setAttribute("d", rotateLine); } else if (this.shapeType === "circle" || this.shapeType === "ellipse") { // Shape renders for "circle()" and "ellipse()" use the same SVG nodes. this._updateEllipseShape(width, height, zoom); } else if (this.shapeType === "inset") { this._updateInsetShape(width, height, zoom); } this._drawMarkers(markerPoints, width, height, zoom); } /** * Update the SVG polygon to fit the CSS polygon. * @param {Number} width the width of the element quads * @param {Number} height the height of the element quads * @param {Number} zoom the zoom level of the window */ _updatePolygonShape(width, height, zoom) { // Draw and show the polygon. const points = this.coordinates.map(point => point.join(",")).join(" "); const polygonEl = this.getElement("polygon"); polygonEl.setAttribute("points", points); polygonEl.removeAttribute("hidden"); const clipPolygon = this.getElement("clip-polygon"); clipPolygon.setAttribute("points", points); clipPolygon.removeAttribute("hidden"); const dashedPolygon = this.getElement("dashed-polygon"); dashedPolygon.setAttribute("points", points); dashedPolygon.removeAttribute("hidden"); } /** * Update the SVG ellipse to fit the CSS circle or ellipse. * @param {Number} width the width of the element quads * @param {Number} height the height of the element quads * @param {Number} zoom the zoom level of the window */ _updateEllipseShape(width, height, zoom) { const { rx, ry, cx, cy } = this.coordinates; const ellipseEl = this.getElement("ellipse"); ellipseEl.setAttribute("rx", rx); ellipseEl.setAttribute("ry", ry); ellipseEl.setAttribute("cx", cx); ellipseEl.setAttribute("cy", cy); ellipseEl.removeAttribute("hidden"); const clipEllipse = this.getElement("clip-ellipse"); clipEllipse.setAttribute("rx", rx); clipEllipse.setAttribute("ry", ry); clipEllipse.setAttribute("cx", cx); clipEllipse.setAttribute("cy", cy); clipEllipse.removeAttribute("hidden"); const dashedEllipse = this.getElement("dashed-ellipse"); dashedEllipse.setAttribute("rx", rx); dashedEllipse.setAttribute("ry", ry); dashedEllipse.setAttribute("cx", cx); dashedEllipse.setAttribute("cy", cy); dashedEllipse.removeAttribute("hidden"); } /** * Update the SVG rect to fit the CSS inset. * @param {Number} width the width of the element quads * @param {Number} height the height of the element quads * @param {Number} zoom the zoom level of the window */ _updateInsetShape(width, height, zoom) { const { top, left, right, bottom } = this.coordinates; const rectEl = this.getElement("rect"); rectEl.setAttribute("x", left); rectEl.setAttribute("y", top); rectEl.setAttribute("width", 100 - left - right); rectEl.setAttribute("height", 100 - top - bottom); rectEl.removeAttribute("hidden"); const clipRect = this.getElement("clip-rect"); clipRect.setAttribute("x", left); clipRect.setAttribute("y", top); clipRect.setAttribute("width", 100 - left - right); clipRect.setAttribute("height", 100 - top - bottom); clipRect.removeAttribute("hidden"); const dashedRect = this.getElement("dashed-rect"); dashedRect.setAttribute("x", left); dashedRect.setAttribute("y", top); dashedRect.setAttribute("width", 100 - left - right); dashedRect.setAttribute("height", 100 - top - bottom); dashedRect.removeAttribute("hidden"); } /** * Draw markers for the given coordinates. * @param {Array} coords an array of coordinate arrays, of form [[x, y] ...] * @param {Number} width the width of the element markers are being drawn for * @param {Number} height the height of the element markers are being drawn for * @param {Number} zoom the zoom level of the window */ _drawMarkers(coords, width, height, zoom) { const markers = coords .map(([x, y]) => { return getCirclePath(BASE_MARKER_SIZE, x, y, width, height, zoom); }) .join(" "); const outline = coords .map(([x, y]) => { return getCirclePath(BASE_MARKER_SIZE + 2, x, y, width, height, zoom); }) .join(" "); this.getElement("markers").setAttribute("d", markers); this.getElement("markers-outline").setAttribute("d", outline); } /** * Calculate the bounding box of the shape after it is transformed according to * the transformation matrix. * @returns {Object} of form { nw, ne, sw, se, n, s, w, e, rotatePoint, center }. * Each element in the object is an array of form [x,y], denoting the x/y * coordinates of the given point. */ calculateTransformedBoundingBox() { const { minX, minY, maxX, maxY } = this.origBoundingBox; const { width, height } = this.currentDimensions; const toPixel = scale(width / 100, height / 100); const toPercent = scale(100 / width, 100 / height); const matrix = multiply(toPercent, multiply(this.transformMatrix, toPixel)); const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; const nw = apply(matrix, [minX, minY]); const ne = apply(matrix, [maxX, minY]); const sw = apply(matrix, [minX, maxY]); const se = apply(matrix, [maxX, maxY]); const n = apply(matrix, [centerX, minY]); const s = apply(matrix, [centerX, maxY]); const w = apply(matrix, [minX, centerY]); const e = apply(matrix, [maxX, centerY]); const center = apply(matrix, [centerX, centerY]); const u = [ ((ne[0] - nw[0]) / 100) * width, ((ne[1] - nw[1]) / 100) * height, ]; const v = [ ((sw[0] - nw[0]) / 100) * width, ((sw[1] - nw[1]) / 100) * height, ]; const { basis, invertedBasis } = getBasis(u, v); let rotatePointMatrix = changeMatrixBase( translate(0, -ROTATE_LINE_LENGTH), invertedBasis, basis ); rotatePointMatrix = multiply( toPercent, multiply(rotatePointMatrix, multiply(this.transformMatrix, toPixel)) ); const rotatePoint = apply(rotatePointMatrix, [centerX, centerY]); return { nw, ne, sw, se, n, s, w, e, rotatePoint, center }; } /** * Hide the highlighter, the outline and the infobar. */ _hide() { setIgnoreLayoutChanges(true); this._hideShapes(); this.getElement("markers").setAttribute("d", ""); this.getElement("root").setAttribute("style", ""); setIgnoreLayoutChanges( false, this.highlighterEnv.window.document.documentElement ); } onPageHide({ target }) { // If a page hide event is triggered for current window's highlighter, hide the // highlighter. if (target.defaultView === this.win) { this.hide(); } } /** * Get the rough direction of the point relative to the anchor. * If the handle is roughly horizontal relative to the anchor, return "ew". * If the handle is roughly vertical relative to the anchor, return "ns" * If the handle is roughly above/right or below/left, return "nesw" * If the handle is roughly above/left or below/right, return "nwse" * @param {String} pointName the name of the point being hovered * @param {String} anchor the name of the anchor point * @returns {String} The rough direction of the point relative to the anchor */ getRoughDirection(pointName, anchor) { const scalePoint = pointName.split("-")[1]; const anchorPos = this.transformedBoundingBox[anchor]; const scalePos = this.transformedBoundingBox[scalePoint]; const { minX, minY, maxX, maxY } = this.boundingBox; const width = maxX - minX; const height = maxY - minY; const dx = (scalePos[0] - anchorPos[0]) / width; const dy = (scalePos[1] - anchorPos[1]) / height; if (dx >= -0.33 && dx <= 0.33) { return "ns"; } else if (dy >= -0.33 && dy <= 0.33) { return "ew"; } else if ((dx > 0.33 && dy < -0.33) || (dx < -0.33 && dy > 0.33)) { return "nesw"; } return "nwse"; } /** * Given a unit type, get the ratio by which to multiply a pixel value in order to * convert pixels to that unit. * * Percentage units (%) are relative to a size. This must be provided when requesting * a ratio for converting from pixels to percentages. * * @param {String} unit * One of: %, em, rem, vw, vh * @param {Number} size * Size to which percentage values are relative to. * @return {Number} */ getUnitToPixelRatio(unit, size) { let ratio; const windowHeight = this.currentNode.ownerGlobal.innerHeight; const windowWidth = this.currentNode.ownerGlobal.innerWidth; switch (unit) { case "%": ratio = 100 / size; break; case "em": ratio = 1 / parseFloat(getComputedStyle(this.currentNode).fontSize); break; case "rem": const root = this.currentNode.ownerDocument.documentElement; ratio = 1 / parseFloat(getComputedStyle(root).fontSize); break; case "vw": ratio = 100 / windowWidth; break; case "vh": ratio = 100 / windowHeight; break; case "vmin": ratio = 100 / Math.min(windowHeight, windowWidth); break; case "vmax": ratio = 100 / Math.max(windowHeight, windowWidth); break; default: // If unit is not recognized, peg ratio 1:1 to pixels. ratio = 1; } return ratio; } } /** * Get the "raw" (i.e. non-computed) shape definition on the given node. * @param {Node} node the node to analyze * @param {String} property the CSS property for which a value should be retrieved. * @returns {String} the value of the given CSS property on the given node. */ function getDefinedShapeProperties(node, property) { let prop = ""; if (!node) { return prop; } const cssRules = getCSSStyleRules(node); for (let i = 0; i < cssRules.length; i++) { const rule = cssRules[i]; const value = rule.style.getPropertyValue(property); if (value && value !== "auto") { prop = value; } } if (node.style) { const value = node.style.getPropertyValue(property); if (value && value !== "auto") { prop = value; } } return prop.trim(); } /** * Split coordinate pairs separated by a space and return an array. * @param {String} coords the coordinate pair, where each coord is separated by a space. * @returns {Array} a 2 element array containing the coordinates. */ function splitCoords(coords) { // All coordinate pairs are of the form "x y" where x and y are values or // calc() expressions. calc() expressions have spaces around operators, so // replace those spaces with \u00a0 (non-breaking space) so they will not be // split later. return coords .trim() .replace(/ [\+\-\*\/] /g, match => { return `\u00a0${match.trim()}\u00a0`; }) .split(" "); } exports.splitCoords = splitCoords; /** * Convert a coordinate to a percentage value. * @param {String} coord a single coordinate * @param {Number} size the size of the element (width or height) that the percentages * are relative to * @returns {Number} the coordinate as a percentage value */ function coordToPercent(coord, size) { if (coord.includes("%")) { // Just remove the % sign, nothing else to do, we're in a viewBox that's 100% // worth. return parseFloat(coord.replace("%", "")); } else if (coord.includes("px")) { // Convert the px value to a % value. const px = parseFloat(coord.replace("px", "")); return (px * 100) / size; } // Unit-less value, so 0. return 0; } exports.coordToPercent = coordToPercent; /** * Evaluates a CSS calc() expression (only handles addition) * @param {String} expression the arguments to the calc() function * @param {Number} size the size of the element (width or height) that percentage values * are relative to * @returns {Number} the result of the expression as a percentage value */ function evalCalcExpression(expression, size) { // the calc() values returned by getComputedStyle only have addition, as it // computes calc() expressions as much as possible without resolving percentages, // leaving only addition. const values = expression.split("+").map(v => v.trim()); return values.reduce((prev, curr) => { return prev + coordToPercent(curr, size); }, 0); } exports.evalCalcExpression = evalCalcExpression; /** * Converts a shape mode to the proper CSS property name. * @param {String} mode the mode of the CSS shape * @returns the equivalent CSS property name */ const shapeModeToCssPropertyName = mode => { const property = mode.substring(3); return property.substring(0, 1).toLowerCase() + property.substring(1); }; exports.shapeModeToCssPropertyName = shapeModeToCssPropertyName; /** * Get the SVG path definition for a circle with given attributes. * @param {Number} size the radius of the circle in pixels * @param {Number} cx the x coordinate of the centre of the circle * @param {Number} cy the y coordinate of the centre of the circle * @param {Number} width the width of the element the circle is being drawn for * @param {Number} height the height of the element the circle is being drawn for * @param {Number} zoom the zoom level of the window the circle is drawn in * @returns {String} the definition of the circle in SVG path description format. */ const getCirclePath = (size, cx, cy, width, height, zoom) => { // We use a viewBox of 100x100 for shape-container so it's easy to position things // based on their percentage, but this makes it more difficult to create circles. // Therefor, 100px is the base size of shape-container. In order to make the markers' // size scale properly, we must adjust the radius based on zoom and the width/height of // the element being highlighted, then calculate a radius for both x/y axes based // on the aspect ratio of the element. const radius = (size * (100 / Math.max(width, height))) / zoom; const ratio = width / height; const rx = ratio > 1 ? radius : radius / ratio; const ry = ratio > 1 ? radius * ratio : radius; // a circle is drawn as two arc lines, starting at the leftmost point of the circle. return ( `M${cx - rx},${cy}a${rx},${ry} 0 1,0 ${rx * 2},0` + `a${rx},${ry} 0 1,0 ${rx * -2},0` ); }; exports.getCirclePath = getCirclePath; /** * Calculates the object bounding box for a node given its stroke bounding box. * @param {Number} top the y coord of the top edge of the stroke bounding box * @param {Number} left the x coord of the left edge of the stroke bounding box * @param {Number} width the width of the stroke bounding box * @param {Number} height the height of the stroke bounding box * @param {Object} node the node object * @returns {Object} an object of the form { top, left, width, height }, which * are the top/left/width/height of the object bounding box for the node. */ const getObjectBoundingBox = (top, left, width, height, node) => { // See https://drafts.fxtf.org/css-masking-1/#stroke-bounding-box for details // on this algorithm. Note that we intentionally do not check "stroke-linecap". const strokeWidth = parseFloat(getComputedStyle(node).strokeWidth); let delta = strokeWidth / 2; const tagName = node.tagName; if ( tagName !== "rect" && tagName !== "ellipse" && tagName !== "circle" && tagName !== "image" ) { if (getComputedStyle(node).strokeLinejoin === "miter") { const miter = getComputedStyle(node).strokeMiterlimit; if (miter < Math.SQRT2) { delta *= Math.SQRT2; } else { delta *= miter; } } else { delta *= Math.SQRT2; } } return { top: top + delta, left: left + delta, width: width - 2 * delta, height: height - 2 * delta, }; }; /** * Get the unit (e.g. px, %, em) for the given point value. * @param {any} point a point value for which a unit should be retrieved. * @returns {String} the unit. */ const getUnit = point => { // If the point has no unit, default to px. if (isUnitless(point)) { return "px"; } const [unit] = point.match(/[^\d]+$/) || ["px"]; return unit; }; exports.getUnit = getUnit; /** * Check if the given point value has a unit. * @param {any} point a point value. * @returns {Boolean} whether the given value has a unit. */ const isUnitless = point => { return ( !point || !point.match(/[^\d]+$/) || // If zero doesn't have a unit, its numeric and string forms should be equal. (parseFloat(point) === 0 && parseFloat(point).toString() === point) || point.includes("(") || point === "center" || point === "closest-side" || point === "farthest-side" ); }; /** * Return the anchor corresponding to the given scale type. * @param {String} type a scale type, of form "scale-[direction]" * @returns {String} a string describing the anchor, one of the 8 cardinal directions. */ const getAnchorPoint = type => { let anchor = type.split("-")[1]; if (anchor.includes("n")) { anchor = anchor.replace("n", "s"); } else if (anchor.includes("s")) { anchor = anchor.replace("s", "n"); } if (anchor.includes("w")) { anchor = anchor.replace("w", "e"); } else if (anchor.includes("e")) { anchor = anchor.replace("e", "w"); } if (anchor === "e" || anchor === "w") { anchor = "n" + anchor; } else if (anchor === "n" || anchor === "s") { anchor = anchor + "w"; } return anchor; }; /** * Get the decimal point precision for values depending on unit type. * Only handle pixels and falsy values for now. Round them to the nearest integer value. * All other unit types round to two decimal points. * * @param {String|undefined} unitType any one of the accepted CSS unit types for position. * @return {Number} decimal precision when rounding a value */ function getDecimalPrecision(unitType) { switch (unitType) { case "px": case "": case undefined: return 0; default: return 2; } } exports.getDecimalPrecision = getDecimalPrecision; /** * Round up a numeric value to a fixed number of decimals depending on CSS unit type. * Used when generating output shape values when: * - transforming shapes * - inserting new points on a polygon. * * @param {Number} number * Value to round up. * @param {String} unitType * CSS unit type, like "px", "%", "em", "vh", etc. * @return {Number} * Rounded value */ function round(number, unitType) { return number.toFixed(getDecimalPrecision(unitType)); } exports.ShapesHighlighter = ShapesHighlighter;