/* 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 {
getMatchingCSSRules,
} = 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.drawingNode.getBBox &&
getComputedStyle(
this.drawingNode).stroke !==
"none" &&
!
this.useStrokeBox
) {
dims = getObjectBoundingBox(
dims.top,
dims.left,
dims.width,
dims.height,
this.drawingNode
);
}
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.drawingNode.ownerDocument
?
this.currentQuads[
this.referenceBox][0].bounds
: getAdjustedQuads(
this.drawingNode.ownerGlobal,
this.drawingNode,
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.drawingNode.getBBox &&
getComputedStyle(
this.drawingNode).stroke !==
"none" &&
!
this.useStrokeBox
) {
dims = getObjectBoundingBox(
dims.top,
dims.left,
dims.width,
dims.height,
this.drawingNode
);
}
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) {
// 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 => {
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 position = cx !==
"" ? ` at ${cx} ${cy}` :
"";
const circleDef =
`circle(${newRadius}${position}) ${
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;
const position = cx !==
"" ? ` at ${cx} ${cy}` :
"";
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}${position}) ${
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}${position}) ${
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,
--> --------------------
--> maximum size reached
--> --------------------