shapes.js (112007B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { 8 CanvasFrameAnonymousContentHelper, 9 getComputedStyle, 10 } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); 11 const { 12 setIgnoreLayoutChanges, 13 getCurrentZoom, 14 getAdjustedQuads, 15 getFrameOffsets, 16 } = require("resource://devtools/shared/layout/utils.js"); 17 const { 18 AutoRefreshHighlighter, 19 } = require("resource://devtools/server/actors/highlighters/auto-refresh.js"); 20 const { 21 getDistance, 22 clickedOnEllipseEdge, 23 distanceToLine, 24 projection, 25 clickedOnPoint, 26 } = require("resource://devtools/server/actors/utils/shapes-utils.js"); 27 const { 28 identity, 29 apply, 30 translate, 31 multiply, 32 scale, 33 rotate, 34 changeMatrixBase, 35 getBasis, 36 } = require("resource://devtools/shared/layout/dom-matrix-2d.js"); 37 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 38 const { 39 getMatchingCSSRules, 40 } = require("resource://devtools/shared/inspector/css-logic.js"); 41 42 const BASE_MARKER_SIZE = 5; 43 // the width of the area around highlighter lines that can be clicked, in px 44 const LINE_CLICK_WIDTH = 5; 45 const ROTATE_LINE_LENGTH = 50; 46 const DOM_EVENTS = ["mousedown", "mousemove", "mouseup", "dblclick"]; 47 const _dragging = Symbol("shapes/dragging"); 48 49 /** 50 * The ShapesHighlighter draws an outline shapes in the page. 51 * The idea is to have something that is able to wrap complex shapes for css properties 52 * such as shape-outside/inside, clip-path but also SVG elements. 53 * 54 * Notes on shape transformation: 55 * 56 * When using transform mode to translate, scale, and rotate shapes, a transformation 57 * matrix keeps track of the transformations done to the original shape. When the 58 * highlighter is toggled on/off or between transform mode and point editing mode, 59 * the transformations applied to the shape become permanent. 60 * 61 * While transformations are being performed on a shape, there is an "original" and 62 * a "transformed" coordinate system. This is used when scaling or rotating a rotated 63 * shape. 64 * 65 * The "original" coordinate system is the one where (0,0) is at the top left corner 66 * of the page, the x axis is horizontal, and the y axis is vertical. 67 * 68 * The "transformed" coordinate system is the one where (0,0) is at the top left 69 * corner of the current shape. The x axis follows the north edge of the shape 70 * (from the northwest corner to the northeast corner) and the y axis follows 71 * the west edge of the shape (from the northwest corner to the southwest corner). 72 * 73 * Because of rotation, the "north" and "west" edges might not actually be at the 74 * top and left of the transformed shape. Imagine that the compass directions are 75 * also rotated along with the shape. 76 * 77 * A refresher for coordinates and change of basis that may be helpful: 78 * https://www.math.ubc.ca/~behrend/math221/Coords.pdf 79 * 80 * @param {string} options.hoverPoint 81 * The point to highlight. 82 * @param {boolean} options.transformMode 83 * Whether to show the highlighter in transforms mode. 84 * @param {} options.mode 85 */ 86 class ShapesHighlighter extends AutoRefreshHighlighter { 87 constructor(highlighterEnv) { 88 super(highlighterEnv); 89 EventEmitter.decorate(this); 90 91 this.referenceBox = "border"; 92 this.useStrokeBox = false; 93 this.geometryBox = ""; 94 this.hoveredPoint = null; 95 this.fillRule = ""; 96 this.numInsetPoints = 0; 97 this.transformMode = false; 98 this.viewport = {}; 99 100 this.markup = new CanvasFrameAnonymousContentHelper( 101 this.highlighterEnv, 102 this._buildMarkup.bind(this), 103 { 104 contentRootHostClassName: "devtools-highlighter-shapes", 105 } 106 ); 107 this.isReady = this.markup.initialize(); 108 this.onPageHide = this.onPageHide.bind(this); 109 110 const { pageListenerTarget } = this.highlighterEnv; 111 DOM_EVENTS.forEach(event => 112 pageListenerTarget.addEventListener(event, this) 113 ); 114 pageListenerTarget.addEventListener("pagehide", this.onPageHide); 115 } 116 117 _buildMarkup() { 118 const container = this.markup.createNode({ 119 attributes: { 120 class: "highlighter-container", 121 }, 122 }); 123 124 // The root wrapper is used to unzoom the highlighter when needed. 125 this.rootEl = this.markup.createNode({ 126 parent: container, 127 attributes: { 128 id: "shapes-root", 129 class: "shapes-root", 130 }, 131 }); 132 133 const mainSvg = this.markup.createSVGNode({ 134 nodeType: "svg", 135 parent: this.rootEl, 136 attributes: { 137 id: "shapes-shape-container", 138 class: "shapes-shape-container", 139 viewBox: "0 0 100 100", 140 preserveAspectRatio: "none", 141 }, 142 }); 143 144 // This clipPath and its children make sure the element quad outline 145 // is only shown when the shape extends past the element quads. 146 const clipSvg = this.markup.createSVGNode({ 147 nodeType: "clipPath", 148 parent: mainSvg, 149 attributes: { 150 id: "shapes-clip-path", 151 class: "shapes-clip-path", 152 }, 153 }); 154 155 this.markup.createSVGNode({ 156 nodeType: "polygon", 157 parent: clipSvg, 158 attributes: { 159 id: "shapes-clip-polygon", 160 class: "shapes-clip-polygon", 161 hidden: "true", 162 }, 163 }); 164 165 this.markup.createSVGNode({ 166 nodeType: "ellipse", 167 parent: clipSvg, 168 attributes: { 169 id: "shapes-clip-ellipse", 170 class: "shapes-clip-ellipse", 171 hidden: true, 172 }, 173 }); 174 175 this.markup.createSVGNode({ 176 nodeType: "rect", 177 parent: clipSvg, 178 attributes: { 179 id: "shapes-clip-rect", 180 class: "shapes-clip-rect", 181 hidden: true, 182 }, 183 }); 184 185 // Rectangle that displays the element quads. Only shown for shape-outside. 186 // Only the parts of the rectangle's outline that overlap with the shape is shown. 187 this.markup.createSVGNode({ 188 nodeType: "rect", 189 parent: mainSvg, 190 attributes: { 191 id: "shapes-quad", 192 class: "shapes-quad", 193 hidden: "true", 194 "clip-path": "url(#shapes-clip-path)", 195 x: 0, 196 y: 0, 197 width: 100, 198 height: 100, 199 }, 200 }); 201 202 // clipPath that corresponds to the element's quads. Only applied for shape-outside. 203 // This ensures only the parts of the shape that are within the element's quads are 204 // outlined by a solid line. 205 const shapeClipSvg = this.markup.createSVGNode({ 206 nodeType: "clipPath", 207 parent: mainSvg, 208 attributes: { 209 id: "shapes-quad-clip-path", 210 class: "shapes-quad-clip-path", 211 }, 212 }); 213 214 this.markup.createSVGNode({ 215 nodeType: "rect", 216 parent: shapeClipSvg, 217 attributes: { 218 id: "shapes-quad-clip", 219 class: "shapes-quad-clip", 220 x: -1, 221 y: -1, 222 width: 102, 223 height: 102, 224 }, 225 }); 226 227 const mainGroup = this.markup.createSVGNode({ 228 nodeType: "g", 229 parent: mainSvg, 230 attributes: { 231 id: "shapes-group", 232 }, 233 }); 234 235 // Append a polygon for polygon shapes. 236 this.markup.createSVGNode({ 237 nodeType: "polygon", 238 parent: mainGroup, 239 attributes: { 240 id: "shapes-polygon", 241 class: "shapes-polygon", 242 hidden: "true", 243 }, 244 }); 245 246 // Append an ellipse for circle/ellipse shapes. 247 this.markup.createSVGNode({ 248 nodeType: "ellipse", 249 parent: mainGroup, 250 attributes: { 251 id: "shapes-ellipse", 252 class: "shapes-ellipse", 253 hidden: true, 254 }, 255 }); 256 257 // Append a rect for inset(). 258 this.markup.createSVGNode({ 259 nodeType: "rect", 260 parent: mainGroup, 261 attributes: { 262 id: "shapes-rect", 263 class: "shapes-rect", 264 hidden: true, 265 }, 266 }); 267 268 // Dashed versions of each shape. Only shown for the parts of the shape 269 // that extends past the element's quads. 270 this.markup.createSVGNode({ 271 nodeType: "polygon", 272 parent: mainGroup, 273 attributes: { 274 id: "shapes-dashed-polygon", 275 class: "shapes-polygon", 276 hidden: "true", 277 "stroke-dasharray": "5, 5", 278 }, 279 }); 280 281 this.markup.createSVGNode({ 282 nodeType: "ellipse", 283 parent: mainGroup, 284 attributes: { 285 id: "shapes-dashed-ellipse", 286 class: "shapes-ellipse", 287 hidden: "true", 288 "stroke-dasharray": "5, 5", 289 }, 290 }); 291 292 this.markup.createSVGNode({ 293 nodeType: "rect", 294 parent: mainGroup, 295 attributes: { 296 id: "shapes-dashed-rect", 297 class: "shapes-rect", 298 hidden: "true", 299 "stroke-dasharray": "5, 5", 300 }, 301 }); 302 303 this.markup.createSVGNode({ 304 nodeType: "path", 305 parent: mainGroup, 306 attributes: { 307 id: "shapes-bounding-box", 308 class: "shapes-bounding-box", 309 "stroke-dasharray": "5, 5", 310 hidden: true, 311 }, 312 }); 313 314 this.markup.createSVGNode({ 315 nodeType: "path", 316 parent: mainGroup, 317 attributes: { 318 id: "shapes-rotate-line", 319 class: "shapes-rotate-line", 320 }, 321 }); 322 323 // Append a path to display the markers for the shape. 324 this.markup.createSVGNode({ 325 nodeType: "path", 326 parent: mainGroup, 327 attributes: { 328 id: "shapes-markers-outline", 329 class: "shapes-markers-outline", 330 }, 331 }); 332 333 this.markup.createSVGNode({ 334 nodeType: "path", 335 parent: mainGroup, 336 attributes: { 337 id: "shapes-markers", 338 class: "shapes-markers", 339 }, 340 }); 341 342 this.markup.createSVGNode({ 343 nodeType: "path", 344 parent: mainGroup, 345 attributes: { 346 id: "shapes-marker-hover", 347 class: "shapes-marker-hover", 348 hidden: true, 349 }, 350 }); 351 352 return container; 353 } 354 355 get currentDimensions() { 356 let dims = this.currentQuads[this.referenceBox][0].bounds; 357 const zoom = getCurrentZoom(this.win); 358 359 // If an SVG element has a stroke, currentQuads will return the stroke bounding box. 360 // However, clip-path always uses the object bounding box unless "stroke-box" is 361 // specified. So, we must calculate the object bounding box if there is a stroke 362 // and "stroke-box" is not specified. stroke only applies to SVG elements, so use 363 // getBBox, which only exists for SVG, to check if currentNode is an SVG element. 364 if ( 365 this.drawingNode.getBBox && 366 getComputedStyle(this.drawingNode).stroke !== "none" && 367 !this.useStrokeBox 368 ) { 369 dims = getObjectBoundingBox( 370 dims.top, 371 dims.left, 372 dims.width, 373 dims.height, 374 this.drawingNode 375 ); 376 } 377 378 return { 379 top: dims.top / zoom, 380 left: dims.left / zoom, 381 width: dims.width / zoom, 382 height: dims.height / zoom, 383 }; 384 } 385 386 get frameDimensions() { 387 // In an iframe, we get the node's quads relative to the frame, instead of the parent 388 // document. 389 let dims = 390 this.highlighterEnv.window.document === this.drawingNode.ownerDocument 391 ? this.currentQuads[this.referenceBox][0].bounds 392 : getAdjustedQuads( 393 this.drawingNode.ownerGlobal, 394 this.drawingNode, 395 this.referenceBox 396 )[0].bounds; 397 const zoom = getCurrentZoom(this.win); 398 399 // If an SVG element has a stroke, currentQuads will return the stroke bounding box. 400 // However, clip-path always uses the object bounding box unless "stroke-box" is 401 // specified. So, we must calculate the object bounding box if there is a stroke 402 // and "stroke-box" is not specified. stroke only applies to SVG elements, so use 403 // getBBox, which only exists for SVG, to check if currentNode is an SVG element. 404 if ( 405 this.drawingNode.getBBox && 406 getComputedStyle(this.drawingNode).stroke !== "none" && 407 !this.useStrokeBox 408 ) { 409 dims = getObjectBoundingBox( 410 dims.top, 411 dims.left, 412 dims.width, 413 dims.height, 414 this.drawingNode 415 ); 416 } 417 418 return { 419 top: dims.top / zoom, 420 left: dims.left / zoom, 421 width: dims.width / zoom, 422 height: dims.height / zoom, 423 }; 424 } 425 426 /** 427 * Changes the appearance of the mouse cursor on the highlighter. 428 * 429 * Because we can't attach event handlers to individual elements in the 430 * highlighter, we determine if the mouse is hovering over a point by seeing if 431 * it's within 5 pixels of it. This creates a square hitbox that doesn't match 432 * perfectly with the circular markers. So if we were to use the :hover 433 * pseudo-class to apply changes to the mouse cursor, the cursor change would not 434 * always accurately reflect whether you can interact with the point. This is 435 * also the reason we have the hidden marker-hover element instead of using CSS 436 * to fill in the marker. 437 * 438 * In addition, the cursor CSS property is applied to .shapes-root because if 439 * it were attached to .shapes-marker, the cursor change no longer applies if 440 * you are for example resizing the shape and your mouse goes off the point. 441 * Also, if you are dragging a polygon point, the marker plays catch up to your 442 * mouse position, resulting in an undesirable visual effect where the cursor 443 * rapidly flickers between "grab" and "auto". 444 * 445 * @param {string} cursorType the name of the cursor to display 446 */ 447 setCursor(cursorType) { 448 const container = this.getElement("shapes-root"); 449 let style = container.getAttribute("style"); 450 // remove existing cursor definitions in the style 451 style = style.replace(/cursor:.*?;/g, ""); 452 style = style.replace(/pointer-events:.*?;/g, ""); 453 const pointerEvents = cursorType === "auto" ? "none" : "auto"; 454 container.setAttribute( 455 "style", 456 `${style}pointer-events:${pointerEvents};cursor:${cursorType};` 457 ); 458 } 459 460 /** 461 * Set the absolute pixel offsets which define the current viewport in relation to 462 * the full page size. 463 * 464 * If a padding value is given, inset the viewport by this value. This is used to define 465 * a virtual viewport which ensures some element remains visible even when at the edges 466 * of the actual viewport. 467 * 468 * @param {number} padding 469 * Optional. Amount by which to inset the viewport in all directions. 470 */ 471 setViewport(padding = 0) { 472 let xOffset = 0; 473 let yOffset = 0; 474 475 // If the node exists within an iframe, get offsets for the virtual viewport so that 476 // points can be dragged to the extent of the global window, outside of the iframe 477 // window. 478 if (this.currentNode.ownerGlobal !== this.win) { 479 const win = this.win; 480 const nodeWin = this.currentNode.ownerGlobal; 481 // Get bounding box of iframe document relative to global document. 482 const bounds = nodeWin.document 483 .getBoxQuads({ 484 relativeTo: win.document, 485 createFramesForSuppressedWhitespace: false, 486 })[0] 487 .getBounds(); 488 xOffset = bounds.left - nodeWin.scrollX + win.scrollX; 489 yOffset = bounds.top - nodeWin.scrollY + win.scrollY; 490 } 491 492 const { pageXOffset, pageYOffset } = this.win; 493 const { clientHeight, clientWidth } = this.win.document.documentElement; 494 const left = pageXOffset + padding - xOffset; 495 const right = clientWidth + pageXOffset - padding - xOffset; 496 const top = pageYOffset + padding - yOffset; 497 const bottom = clientHeight + pageYOffset - padding - yOffset; 498 this.viewport = { left, right, top, bottom, padding }; 499 } 500 501 // eslint-disable-next-line complexity 502 handleEvent(event) { 503 // No event handling if the highlighter is hidden 504 if (this.areShapesHidden()) { 505 return; 506 } 507 508 let { target, type, pageX, pageY } = event; 509 510 // For events on highlighted nodes in an iframe, when the event takes place 511 // outside the iframe. Check if event target belongs to the iframe. If it doesn't, 512 // adjust pageX/pageY to be relative to the iframe rather than the parent. 513 const nodeDocument = this.currentNode.ownerDocument; 514 if (target !== nodeDocument && target.ownerDocument !== nodeDocument) { 515 const [xOffset, yOffset] = getFrameOffsets( 516 target.ownerGlobal, 517 this.currentNode 518 ); 519 const zoom = getCurrentZoom(this.win); 520 // xOffset/yOffset are relative to the viewport, so first find the top/left 521 // edges of the viewport relative to the page. 522 const viewportLeft = pageX - event.clientX; 523 const viewportTop = pageY - event.clientY; 524 // Also adjust for scrolling in the iframe. 525 const { scrollTop, scrollLeft } = nodeDocument.documentElement; 526 pageX -= viewportLeft + xOffset / zoom - scrollLeft; 527 pageY -= viewportTop + yOffset / zoom - scrollTop; 528 } 529 530 switch (type) { 531 case "pagehide": 532 // If a page hide event is triggered for current window's highlighter, hide the 533 // highlighter. 534 if (target.defaultView === this.win) { 535 this.destroy(); 536 } 537 538 break; 539 case "mousedown": 540 if (this.transformMode) { 541 this._handleTransformClick(pageX, pageY); 542 } else if (this.shapeType === "polygon") { 543 this._handlePolygonClick(pageX, pageY); 544 } else if (this.shapeType === "circle") { 545 this._handleCircleClick(pageX, pageY); 546 } else if (this.shapeType === "ellipse") { 547 this._handleEllipseClick(pageX, pageY); 548 } else if (this.shapeType === "inset") { 549 this._handleInsetClick(pageX, pageY); 550 } 551 event.stopPropagation(); 552 event.preventDefault(); 553 554 // Calculate constraints for a virtual viewport which ensures that a dragged 555 // marker remains visible even at the edges of the actual viewport. 556 this.setViewport(BASE_MARKER_SIZE); 557 break; 558 case "mouseup": 559 if (this[_dragging]) { 560 this[_dragging] = null; 561 this._handleMarkerHover(this.hoveredPoint); 562 } 563 break; 564 case "mousemove": { 565 if (!this[_dragging]) { 566 this._handleMouseMoveNotDragging(pageX, pageY); 567 return; 568 } 569 event.stopPropagation(); 570 event.preventDefault(); 571 572 // Set constraints for mouse position to ensure dragged marker stays in viewport. 573 const { left, right, top, bottom } = this.viewport; 574 pageX = Math.min(Math.max(left, pageX), right); 575 pageY = Math.min(Math.max(top, pageY), bottom); 576 577 const { point } = this[_dragging]; 578 if (this.transformMode) { 579 this._handleTransformMove(pageX, pageY); 580 } else if (this.shapeType === "polygon") { 581 this._handlePolygonMove(pageX, pageY); 582 } else if (this.shapeType === "circle") { 583 this._handleCircleMove(point, pageX, pageY); 584 } else if (this.shapeType === "ellipse") { 585 this._handleEllipseMove(point, pageX, pageY); 586 } else if (this.shapeType === "inset") { 587 this._handleInsetMove(point, pageX, pageY); 588 } 589 break; 590 } 591 case "dblclick": 592 if (this.shapeType === "polygon" && !this.transformMode) { 593 const { percentX, percentY } = this.convertPageCoordsToPercent( 594 pageX, 595 pageY 596 ); 597 const index = this.getPolygonPointAt(percentX, percentY); 598 if (index === -1) { 599 this.getPolygonClickedLine(percentX, percentY); 600 return; 601 } 602 603 this._deletePolygonPoint(index); 604 } 605 break; 606 } 607 } 608 609 /** 610 * Handle a mouse click in transform mode. 611 * 612 * @param {number} pageX the x coordinate of the mouse 613 * @param {number} pageY the y coordinate of the mouse 614 */ 615 _handleTransformClick(pageX, pageY) { 616 const { percentX, percentY } = this.convertPageCoordsToPercent( 617 pageX, 618 pageY 619 ); 620 const type = this.getTransformPointAt(percentX, percentY); 621 if (!type) { 622 return; 623 } 624 625 if (this.shapeType === "polygon") { 626 this._handlePolygonTransformClick(pageX, pageY, type); 627 } else if (this.shapeType === "circle") { 628 this._handleCircleTransformClick(pageX, pageY, type); 629 } else if (this.shapeType === "ellipse") { 630 this._handleEllipseTransformClick(pageX, pageY, type); 631 } else if (this.shapeType === "inset") { 632 this._handleInsetTransformClick(pageX, pageY, type); 633 } 634 } 635 636 /** 637 * Handle a click in transform mode while highlighting a polygon. 638 * 639 * @param {number} pageX the x coordinate of the mouse. 640 * @param {number} pageY the y coordinate of the mouse. 641 * @param {string} type the type of transform handle that was clicked. 642 */ 643 _handlePolygonTransformClick(pageX, pageY, type) { 644 const { width, height } = this.currentDimensions; 645 const pointsInfo = this.origCoordUnits.map(([x, y], i) => { 646 const xComputed = (this.origCoordinates[i][0] / 100) * width; 647 const yComputed = (this.origCoordinates[i][1] / 100) * height; 648 const unitX = getUnit(x); 649 const unitY = getUnit(y); 650 const valueX = isUnitless(x) ? xComputed : parseFloat(x); 651 const valueY = isUnitless(y) ? yComputed : parseFloat(y); 652 653 const ratioX = this.getUnitToPixelRatio(unitX, width); 654 const ratioY = this.getUnitToPixelRatio(unitY, height); 655 return { unitX, unitY, valueX, valueY, ratioX, ratioY }; 656 }); 657 this[_dragging] = { 658 type, 659 pointsInfo, 660 x: pageX, 661 y: pageY, 662 bb: this.boundingBox, 663 matrix: this.transformMatrix, 664 transformedBB: this.transformedBoundingBox, 665 }; 666 this._handleMarkerHover(this.hoveredPoint); 667 } 668 669 /** 670 * Handle a click in transform mode while highlighting a circle. 671 * 672 * @param {number} pageX the x coordinate of the mouse. 673 * @param {number} pageY the y coordinate of the mouse. 674 * @param {string} type the type of transform handle that was clicked. 675 */ 676 _handleCircleTransformClick(pageX, pageY, type) { 677 const { width, height } = this.currentDimensions; 678 const { cx, cy } = this.origCoordUnits; 679 const cxComputed = (this.origCoordinates.cx / 100) * width; 680 const cyComputed = (this.origCoordinates.cy / 100) * height; 681 const unitX = getUnit(cx); 682 const unitY = getUnit(cy); 683 const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); 684 const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); 685 686 const ratioX = this.getUnitToPixelRatio(unitX, width); 687 const ratioY = this.getUnitToPixelRatio(unitY, height); 688 689 let { radius } = this.origCoordinates; 690 const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2); 691 radius = (radius / 100) * computedSize; 692 let valueRad = this.origCoordUnits.radius; 693 const unitRad = getUnit(valueRad); 694 valueRad = isUnitless(valueRad) ? radius : parseFloat(valueRad); 695 const ratioRad = this.getUnitToPixelRatio(unitRad, computedSize); 696 697 this[_dragging] = { 698 type, 699 unitX, 700 unitY, 701 unitRad, 702 valueX, 703 valueY, 704 ratioX, 705 ratioY, 706 ratioRad, 707 x: pageX, 708 y: pageY, 709 bb: this.boundingBox, 710 matrix: this.transformMatrix, 711 transformedBB: this.transformedBoundingBox, 712 }; 713 } 714 715 /** 716 * Handle a click in transform mode while highlighting an ellipse. 717 * 718 * @param {number} pageX the x coordinate of the mouse. 719 * @param {number} pageY the y coordinate of the mouse. 720 * @param {string} type the type of transform handle that was clicked. 721 */ 722 _handleEllipseTransformClick(pageX, pageY, type) { 723 const { width, height } = this.currentDimensions; 724 const { cx, cy } = this.origCoordUnits; 725 const cxComputed = (this.origCoordinates.cx / 100) * width; 726 const cyComputed = (this.origCoordinates.cy / 100) * height; 727 const unitX = getUnit(cx); 728 const unitY = getUnit(cy); 729 const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); 730 const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); 731 732 const ratioX = this.getUnitToPixelRatio(unitX, width); 733 const ratioY = this.getUnitToPixelRatio(unitY, height); 734 735 let { rx, ry } = this.origCoordinates; 736 rx = (rx / 100) * width; 737 let valueRX = this.origCoordUnits.rx; 738 const unitRX = getUnit(valueRX); 739 valueRX = isUnitless(valueRX) ? rx : parseFloat(valueRX); 740 const ratioRX = valueRX / rx || 1; 741 ry = (ry / 100) * height; 742 let valueRY = this.origCoordUnits.ry; 743 const unitRY = getUnit(valueRY); 744 valueRY = isUnitless(valueRY) ? ry : parseFloat(valueRY); 745 const ratioRY = valueRY / ry || 1; 746 747 this[_dragging] = { 748 type, 749 unitX, 750 unitY, 751 unitRX, 752 unitRY, 753 valueX, 754 valueY, 755 ratioX, 756 ratioY, 757 ratioRX, 758 ratioRY, 759 x: pageX, 760 y: pageY, 761 bb: this.boundingBox, 762 matrix: this.transformMatrix, 763 transformedBB: this.transformedBoundingBox, 764 }; 765 } 766 767 /** 768 * Handle a click in transform mode while highlighting an inset. 769 * 770 * @param {number} pageX the x coordinate of the mouse. 771 * @param {number} pageY the y coordinate of the mouse. 772 * @param {string} type the type of transform handle that was clicked. 773 */ 774 _handleInsetTransformClick(pageX, pageY, type) { 775 const { width, height } = this.currentDimensions; 776 const pointsInfo = {}; 777 ["top", "right", "bottom", "left"].forEach(point => { 778 let value = this.origCoordUnits[point]; 779 const size = point === "left" || point === "right" ? width : height; 780 const computedValue = (this.origCoordinates[point] / 100) * size; 781 const unit = getUnit(value); 782 value = isUnitless(value) ? computedValue : parseFloat(value); 783 const ratio = this.getUnitToPixelRatio(unit, size); 784 785 pointsInfo[point] = { value, unit, ratio }; 786 }); 787 this[_dragging] = { 788 type, 789 pointsInfo, 790 x: pageX, 791 y: pageY, 792 bb: this.boundingBox, 793 matrix: this.transformMatrix, 794 transformedBB: this.transformedBoundingBox, 795 }; 796 } 797 798 /** 799 * Handle mouse movement after a click on a handle in transform mode. 800 * 801 * @param {number} pageX the x coordinate of the mouse 802 * @param {number} pageY the y coordinate of the mouse 803 */ 804 _handleTransformMove(pageX, pageY) { 805 const { type } = this[_dragging]; 806 if (type === "translate") { 807 this._translateShape(pageX, pageY); 808 } else if (type.includes("scale")) { 809 this._scaleShape(pageX, pageY); 810 } else if (type === "rotate" && this.shapeType === "polygon") { 811 this._rotateShape(pageX, pageY); 812 } 813 814 this.transformedBoundingBox = this.calculateTransformedBoundingBox(); 815 } 816 817 /** 818 * Translates a shape based on the current mouse position. 819 * 820 * @param {number} pageX the x coordinate of the mouse. 821 * @param {number} pageY the y coordinate of the mouse. 822 */ 823 _translateShape(pageX, pageY) { 824 const { x, y, matrix } = this[_dragging]; 825 const deltaX = pageX - x; 826 const deltaY = pageY - y; 827 this.transformMatrix = multiply(translate(deltaX, deltaY), matrix); 828 829 if (this.shapeType === "polygon") { 830 this._transformPolygon(); 831 } else if (this.shapeType === "circle") { 832 this._transformCircle(); 833 } else if (this.shapeType === "ellipse") { 834 this._transformEllipse(); 835 } else if (this.shapeType === "inset") { 836 this._transformInset(); 837 } 838 } 839 840 /** 841 * Scales a shape according to the current mouse position. 842 * 843 * @param {number} pageX the x coordinate of the mouse. 844 * @param {number} pageY the y coordinate of the mouse. 845 */ 846 _scaleShape(pageX, pageY) { 847 /** 848 * To scale a shape: 849 * 1) Get the change of basis matrix corresponding to the current transformation 850 * matrix of the shape. 851 * 2) Convert the mouse x/y deltas to the "transformed" coordinate system, using 852 * the change of base matrix. 853 * 3) Calculate the proportion to which the shape should be scaled to, using the 854 * mouse x/y deltas and the width/height of the transformed shape. 855 * 4) Translate the shape such that the anchor (the point opposite to the one 856 * being dragged) is at the top left of the element. 857 * 5) Scale each point by multiplying by the scaling proportion. 858 * 6) Translate the shape back such that the anchor is in its original position. 859 */ 860 const { type, x, y, matrix } = this[_dragging]; 861 const { width, height } = this.currentDimensions; 862 // The point opposite to the one being dragged 863 const anchor = getAnchorPoint(type); 864 865 const { ne, nw, sw } = this[_dragging].transformedBB; 866 // u/v are the basis vectors of the transformed coordinate system. 867 const u = [ 868 ((ne[0] - nw[0]) / 100) * width, 869 ((ne[1] - nw[1]) / 100) * height, 870 ]; 871 const v = [ 872 ((sw[0] - nw[0]) / 100) * width, 873 ((sw[1] - nw[1]) / 100) * height, 874 ]; 875 // uLength/vLength represent the width/height of the shape in the 876 // transformed coordinate system. 877 const { basis, invertedBasis, uLength, vLength } = getBasis(u, v); 878 879 // How much points on each axis should be translated before scaling 880 const transX = (this[_dragging].transformedBB[anchor][0] / 100) * width; 881 const transY = (this[_dragging].transformedBB[anchor][1] / 100) * height; 882 883 // Distance from original click to current mouse position 884 const distanceX = pageX - x; 885 const distanceY = pageY - y; 886 // Convert from original coordinate system to transformed coordinate system 887 const tDistanceX = 888 invertedBasis[0] * distanceX + invertedBasis[1] * distanceY; 889 const tDistanceY = 890 invertedBasis[3] * distanceX + invertedBasis[4] * distanceY; 891 892 // Proportion of distance to bounding box width/height of shape 893 const proportionX = tDistanceX / uLength; 894 const proportionY = tDistanceY / vLength; 895 // proportionX is positive for size reductions dragging on w/nw/sw, 896 // negative for e/ne/se. 897 const scaleX = type.includes("w") ? 1 - proportionX : 1 + proportionX; 898 // proportionT is positive for size reductions dragging on n/nw/ne, 899 // negative for s/sw/se. 900 const scaleY = type.includes("n") ? 1 - proportionY : 1 + proportionY; 901 // Take the average of scaleX/scaleY for scaling on two axes 902 const scaleXY = (scaleX + scaleY) / 2; 903 904 const translateMatrix = translate(-transX, -transY); 905 let scaleMatrix = identity(); 906 // The scale matrices are in the transformed coordinate system. We must convert 907 // them to the original coordinate system before applying it to the transformation 908 // matrix. 909 if (type === "scale-e" || type === "scale-w") { 910 scaleMatrix = changeMatrixBase(scale(scaleX, 1), invertedBasis, basis); 911 } else if (type === "scale-n" || type === "scale-s") { 912 scaleMatrix = changeMatrixBase(scale(1, scaleY), invertedBasis, basis); 913 } else { 914 scaleMatrix = changeMatrixBase( 915 scale(scaleXY, scaleXY), 916 invertedBasis, 917 basis 918 ); 919 } 920 const translateBackMatrix = translate(transX, transY); 921 this.transformMatrix = multiply( 922 translateBackMatrix, 923 multiply(scaleMatrix, multiply(translateMatrix, matrix)) 924 ); 925 926 if (this.shapeType === "polygon") { 927 this._transformPolygon(); 928 } else if (this.shapeType === "circle") { 929 this._transformCircle(transX); 930 } else if (this.shapeType === "ellipse") { 931 this._transformEllipse(transX, transY); 932 } else if (this.shapeType === "inset") { 933 this._transformInset(); 934 } 935 } 936 937 /** 938 * Rotates a polygon based on the current mouse position. 939 * 940 * @param {number} pageX the x coordinate of the mouse. 941 * @param {number} pageY the y coordinate of the mouse. 942 */ 943 _rotateShape(pageX, pageY) { 944 const { matrix } = this[_dragging]; 945 const { center, ne, nw, sw } = this[_dragging].transformedBB; 946 const { width, height } = this.currentDimensions; 947 const centerX = (center[0] / 100) * width; 948 const centerY = (center[1] / 100) * height; 949 const { x: pageCenterX, y: pageCenterY } = this.convertPercentToPageCoords( 950 ...center 951 ); 952 953 const dx = pageCenterX - pageX; 954 const dy = pageCenterY - pageY; 955 956 const u = [ 957 ((ne[0] - nw[0]) / 100) * width, 958 ((ne[1] - nw[1]) / 100) * height, 959 ]; 960 const v = [ 961 ((sw[0] - nw[0]) / 100) * width, 962 ((sw[1] - nw[1]) / 100) * height, 963 ]; 964 const { invertedBasis } = getBasis(u, v); 965 966 const tdx = invertedBasis[0] * dx + invertedBasis[1] * dy; 967 const tdy = invertedBasis[3] * dx + invertedBasis[4] * dy; 968 const angle = Math.atan2(tdx, tdy); 969 const translateMatrix = translate(-centerX, -centerY); 970 const rotateMatrix = rotate(angle); 971 const translateBackMatrix = translate(centerX, centerY); 972 this.transformMatrix = multiply( 973 translateBackMatrix, 974 multiply(rotateMatrix, multiply(translateMatrix, matrix)) 975 ); 976 977 this._transformPolygon(); 978 } 979 980 /** 981 * Transform a polygon depending on the current transformation matrix. 982 */ 983 _transformPolygon() { 984 const { pointsInfo } = this[_dragging]; 985 986 let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; 987 polygonDef += pointsInfo 988 .map(point => { 989 const { unitX, unitY, valueX, valueY, ratioX, ratioY } = point; 990 const vector = [valueX / ratioX, valueY / ratioY]; 991 let [newX, newY] = apply(this.transformMatrix, vector); 992 newX = round(newX * ratioX, unitX); 993 newY = round(newY * ratioY, unitY); 994 995 return `${newX}${unitX} ${newY}${unitY}`; 996 }) 997 .join(", "); 998 polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); 999 1000 this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); 1001 } 1002 1003 /** 1004 * Transform a circle depending on the current transformation matrix. 1005 * 1006 * @param {number} transX the number of pixels the shape is translated on the x axis 1007 * before scaling 1008 */ 1009 _transformCircle(transX = null) { 1010 const { unitX, unitY, unitRad, valueX, valueY, ratioX, ratioY, ratioRad } = 1011 this[_dragging]; 1012 let { radius } = this.coordUnits; 1013 1014 let [newCx, newCy] = apply(this.transformMatrix, [ 1015 valueX / ratioX, 1016 valueY / ratioY, 1017 ]); 1018 if (transX !== null) { 1019 // As part of scaling, the shape is translated to be tangent to the line y=0. 1020 // To get the new radius, we translate the new cx back to that point and get 1021 // the distance to the line y=0. 1022 radius = round(Math.abs((newCx - transX) * ratioRad), unitRad); 1023 radius = `${radius}${unitRad}`; 1024 } 1025 1026 newCx = round(newCx * ratioX, unitX); 1027 newCy = round(newCy * ratioY, unitY); 1028 const circleDef = 1029 `circle(${radius} at ${newCx}${unitX} ${newCy}${unitY})` + 1030 ` ${this.geometryBox}`.trim(); 1031 this.emit("highlighter-event", { type: "shape-change", value: circleDef }); 1032 } 1033 1034 /** 1035 * Transform an ellipse depending on the current transformation matrix. 1036 * 1037 * @param {number} transX the number of pixels the shape is translated on the x axis 1038 * before scaling 1039 * @param {number} transY the number of pixels the shape is translated on the y axis 1040 * before scaling 1041 */ 1042 _transformEllipse(transX = null, transY = null) { 1043 const { 1044 unitX, 1045 unitY, 1046 unitRX, 1047 unitRY, 1048 valueX, 1049 valueY, 1050 ratioX, 1051 ratioY, 1052 ratioRX, 1053 ratioRY, 1054 } = this[_dragging]; 1055 let { rx, ry } = this.coordUnits; 1056 1057 let [newCx, newCy] = apply(this.transformMatrix, [ 1058 valueX / ratioX, 1059 valueY / ratioY, 1060 ]); 1061 if (transX !== null && transY !== null) { 1062 // As part of scaling, the shape is translated to be tangent to the lines y=0 & x=0. 1063 // To get the new radii, we translate the new center back to that point and get the 1064 // distances to the line x=0 and y=0. 1065 rx = round(Math.abs((newCx - transX) * ratioRX), unitRX); 1066 rx = `${rx}${unitRX}`; 1067 ry = round(Math.abs((newCy - transY) * ratioRY), unitRY); 1068 ry = `${ry}${unitRY}`; 1069 } 1070 1071 newCx = round(newCx * ratioX, unitX); 1072 newCy = round(newCy * ratioY, unitY); 1073 1074 const centerStr = `${newCx}${unitX} ${newCy}${unitY}`; 1075 const ellipseDef = 1076 `ellipse(${rx} ${ry} at ${centerStr}) ${this.geometryBox}`.trim(); 1077 this.emit("highlighter-event", { type: "shape-change", value: ellipseDef }); 1078 } 1079 1080 /** 1081 * Transform an inset depending on the current transformation matrix. 1082 */ 1083 _transformInset() { 1084 const { top, left, right, bottom } = this[_dragging].pointsInfo; 1085 const { width, height } = this.currentDimensions; 1086 1087 const topLeft = [left.value / left.ratio, top.value / top.ratio]; 1088 let [newLeft, newTop] = apply(this.transformMatrix, topLeft); 1089 newLeft = round(newLeft * left.ratio, left.unit); 1090 newLeft = `${newLeft}${left.unit}`; 1091 newTop = round(newTop * top.ratio, top.unit); 1092 newTop = `${newTop}${top.unit}`; 1093 1094 // Right and bottom values are relative to the right and bottom edges of the 1095 // element, so convert to the value relative to the left/top edges before scaling 1096 // and convert back. 1097 const bottomRight = [ 1098 width - right.value / right.ratio, 1099 height - bottom.value / bottom.ratio, 1100 ]; 1101 let [newRight, newBottom] = apply(this.transformMatrix, bottomRight); 1102 newRight = round((width - newRight) * right.ratio, right.unit); 1103 newRight = `${newRight}${right.unit}`; 1104 newBottom = round((height - newBottom) * bottom.ratio, bottom.unit); 1105 newBottom = `${newBottom}${bottom.unit}`; 1106 1107 let insetDef = this.insetRound 1108 ? `inset(${newTop} ${newRight} ${newBottom} ${newLeft} round ${this.insetRound})` 1109 : `inset(${newTop} ${newRight} ${newBottom} ${newLeft})`; 1110 insetDef += this.geometryBox ? this.geometryBox : ""; 1111 1112 this.emit("highlighter-event", { type: "shape-change", value: insetDef }); 1113 } 1114 1115 /** 1116 * Handle a click when highlighting a polygon. 1117 * 1118 * @param {number} pageX the x coordinate of the click 1119 * @param {number} pageY the y coordinate of the click 1120 */ 1121 _handlePolygonClick(pageX, pageY) { 1122 const { width, height } = this.currentDimensions; 1123 const { percentX, percentY } = this.convertPageCoordsToPercent( 1124 pageX, 1125 pageY 1126 ); 1127 const point = this.getPolygonPointAt(percentX, percentY); 1128 if (point === -1) { 1129 return; 1130 } 1131 1132 const [x, y] = this.coordUnits[point]; 1133 const xComputed = (this.coordinates[point][0] / 100) * width; 1134 const yComputed = (this.coordinates[point][1] / 100) * height; 1135 const unitX = getUnit(x); 1136 const unitY = getUnit(y); 1137 const valueX = isUnitless(x) ? xComputed : parseFloat(x); 1138 const valueY = isUnitless(y) ? yComputed : parseFloat(y); 1139 1140 const ratioX = this.getUnitToPixelRatio(unitX, width); 1141 const ratioY = this.getUnitToPixelRatio(unitY, height); 1142 1143 this.setCursor("grabbing"); 1144 this[_dragging] = { 1145 point, 1146 unitX, 1147 unitY, 1148 valueX, 1149 valueY, 1150 ratioX, 1151 ratioY, 1152 x: pageX, 1153 y: pageY, 1154 }; 1155 } 1156 1157 /** 1158 * Update the dragged polygon point with the given x/y coords and update 1159 * the element style. 1160 * 1161 * @param {number} pageX the new x coordinate of the point 1162 * @param {number} pageY the new y coordinate of the point 1163 */ 1164 _handlePolygonMove(pageX, pageY) { 1165 const { point, unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } = 1166 this[_dragging]; 1167 const deltaX = (pageX - x) * ratioX; 1168 const deltaY = (pageY - y) * ratioY; 1169 const newX = round(valueX + deltaX, unitX); 1170 const newY = round(valueY + deltaY, unitY); 1171 1172 let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; 1173 polygonDef += this.coordUnits 1174 .map((coords, i) => { 1175 return i === point 1176 ? `${newX}${unitX} ${newY}${unitY}` 1177 : `${coords[0]} ${coords[1]}`; 1178 }) 1179 .join(", "); 1180 polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); 1181 1182 this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); 1183 } 1184 1185 /** 1186 * Add new point to the polygon defintion and update element style. 1187 * TODO: Bug 1436054 - Do not default to percentage unit when inserting new point. 1188 * https://bugzilla.mozilla.org/show_bug.cgi?id=1436054 1189 * 1190 * @param {number} after the index of the point that the new point should be added after 1191 * @param {number} x the x coordinate of the new point 1192 * @param {number} y the y coordinate of the new point 1193 */ 1194 _addPolygonPoint(after, x, y) { 1195 let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; 1196 polygonDef += this.coordUnits 1197 .map((coords, i) => { 1198 return i === after 1199 ? `${coords[0]} ${coords[1]}, ${x}% ${y}%` 1200 : `${coords[0]} ${coords[1]}`; 1201 }) 1202 .join(", "); 1203 polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); 1204 1205 this.hoveredPoint = after + 1; 1206 this._emitHoverEvent(this.hoveredPoint); 1207 this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); 1208 } 1209 1210 /** 1211 * Remove point from polygon defintion and update the element style. 1212 * 1213 * @param {number} point the index of the point to delete 1214 */ 1215 _deletePolygonPoint(point) { 1216 const coordinates = this.coordUnits.slice(); 1217 coordinates.splice(point, 1); 1218 let polygonDef = this.fillRule ? `${this.fillRule}, ` : ""; 1219 polygonDef += coordinates 1220 .map(coords => { 1221 return `${coords[0]} ${coords[1]}`; 1222 }) 1223 .join(", "); 1224 polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim(); 1225 1226 this.hoveredPoint = null; 1227 this._emitHoverEvent(this.hoveredPoint); 1228 this.emit("highlighter-event", { type: "shape-change", value: polygonDef }); 1229 } 1230 /** 1231 * Handle a click when highlighting a circle. 1232 * 1233 * @param {number} pageX the x coordinate of the click 1234 * @param {number} pageY the y coordinate of the click 1235 */ 1236 _handleCircleClick(pageX, pageY) { 1237 const { width, height } = this.currentDimensions; 1238 const { percentX, percentY } = this.convertPageCoordsToPercent( 1239 pageX, 1240 pageY 1241 ); 1242 const point = this.getCirclePointAt(percentX, percentY); 1243 if (!point) { 1244 return; 1245 } 1246 1247 this.setCursor("grabbing"); 1248 if (point === "center") { 1249 const { cx, cy } = this.coordUnits; 1250 const cxComputed = (this.coordinates.cx / 100) * width; 1251 const cyComputed = (this.coordinates.cy / 100) * height; 1252 const unitX = getUnit(cx); 1253 const unitY = getUnit(cy); 1254 const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); 1255 const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); 1256 1257 const ratioX = this.getUnitToPixelRatio(unitX, width); 1258 const ratioY = this.getUnitToPixelRatio(unitY, height); 1259 1260 this[_dragging] = { 1261 point, 1262 unitX, 1263 unitY, 1264 valueX, 1265 valueY, 1266 ratioX, 1267 ratioY, 1268 x: pageX, 1269 y: pageY, 1270 }; 1271 } else if (point === "radius") { 1272 let { radius } = this.coordinates; 1273 const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2); 1274 radius = (radius / 100) * computedSize; 1275 let value = this.coordUnits.radius; 1276 const unit = getUnit(value); 1277 value = isUnitless(value) ? radius : parseFloat(value); 1278 const ratio = this.getUnitToPixelRatio(unit, computedSize); 1279 1280 this[_dragging] = { point, value, origRadius: radius, unit, ratio }; 1281 } 1282 } 1283 1284 /** 1285 * Set the center/radius of the circle according to the mouse position and 1286 * update the element style. 1287 * 1288 * @param {string} point either "center" or "radius" 1289 * @param {number} pageX the x coordinate of the mouse position, in terms of % 1290 * relative to the element 1291 * @param {number} pageY the y coordinate of the mouse position, in terms of % 1292 * relative to the element 1293 */ 1294 _handleCircleMove(point, pageX, pageY) { 1295 const { radius, cx, cy } = this.coordUnits; 1296 1297 if (point === "center") { 1298 const { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } = 1299 this[_dragging]; 1300 const deltaX = (pageX - x) * ratioX; 1301 const deltaY = (pageY - y) * ratioY; 1302 const newCx = `${round(valueX + deltaX, unitX)}${unitX}`; 1303 const newCy = `${round(valueY + deltaY, unitY)}${unitY}`; 1304 // if not defined by the user, geometryBox will be an empty string; trim() cleans up 1305 const circleDef = 1306 `circle(${radius} at ${newCx} ${newCy}) ${this.geometryBox}`.trim(); 1307 1308 this.emit("highlighter-event", { 1309 type: "shape-change", 1310 value: circleDef, 1311 }); 1312 } else if (point === "radius") { 1313 const { value, unit, origRadius, ratio } = this[_dragging]; 1314 // convert center point to px, then get distance between center and mouse. 1315 const { x: pageCx, y: pageCy } = this.convertPercentToPageCoords( 1316 this.coordinates.cx, 1317 this.coordinates.cy 1318 ); 1319 const newRadiusPx = getDistance(pageCx, pageCy, pageX, pageY); 1320 1321 const delta = (newRadiusPx - origRadius) * ratio; 1322 const newRadius = `${round(value + delta, unit)}${unit}`; 1323 1324 const position = cx !== "" ? ` at ${cx} ${cy}` : ""; 1325 const circleDef = 1326 `circle(${newRadius}${position}) ${this.geometryBox}`.trim(); 1327 1328 this.emit("highlighter-event", { 1329 type: "shape-change", 1330 value: circleDef, 1331 }); 1332 } 1333 } 1334 1335 /** 1336 * Handle a click when highlighting an ellipse. 1337 * 1338 * @param {number} pageX the x coordinate of the click 1339 * @param {number} pageY the y coordinate of the click 1340 */ 1341 _handleEllipseClick(pageX, pageY) { 1342 const { width, height } = this.currentDimensions; 1343 const { percentX, percentY } = this.convertPageCoordsToPercent( 1344 pageX, 1345 pageY 1346 ); 1347 const point = this.getEllipsePointAt(percentX, percentY); 1348 if (!point) { 1349 return; 1350 } 1351 1352 this.setCursor("grabbing"); 1353 if (point === "center") { 1354 const { cx, cy } = this.coordUnits; 1355 const cxComputed = (this.coordinates.cx / 100) * width; 1356 const cyComputed = (this.coordinates.cy / 100) * height; 1357 const unitX = getUnit(cx); 1358 const unitY = getUnit(cy); 1359 const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx); 1360 const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy); 1361 1362 const ratioX = this.getUnitToPixelRatio(unitX, width); 1363 const ratioY = this.getUnitToPixelRatio(unitY, height); 1364 1365 this[_dragging] = { 1366 point, 1367 unitX, 1368 unitY, 1369 valueX, 1370 valueY, 1371 ratioX, 1372 ratioY, 1373 x: pageX, 1374 y: pageY, 1375 }; 1376 } else if (point === "rx") { 1377 let { rx } = this.coordinates; 1378 rx = (rx / 100) * width; 1379 let value = this.coordUnits.rx; 1380 const unit = getUnit(value); 1381 value = isUnitless(value) ? rx : parseFloat(value); 1382 const ratio = this.getUnitToPixelRatio(unit, width); 1383 1384 this[_dragging] = { point, value, origRadius: rx, unit, ratio }; 1385 } else if (point === "ry") { 1386 let { ry } = this.coordinates; 1387 ry = (ry / 100) * height; 1388 let value = this.coordUnits.ry; 1389 const unit = getUnit(value); 1390 value = isUnitless(value) ? ry : parseFloat(value); 1391 const ratio = this.getUnitToPixelRatio(unit, height); 1392 1393 this[_dragging] = { point, value, origRadius: ry, unit, ratio }; 1394 } 1395 } 1396 1397 /** 1398 * Set center/rx/ry of the ellispe according to the mouse position and update the 1399 * element style. 1400 * 1401 * @param {string} point "center", "rx", or "ry" 1402 * @param {number} pageX the x coordinate of the mouse position, in terms of % 1403 * relative to the element 1404 * @param {number} pageY the y coordinate of the mouse position, in terms of % 1405 * relative to the element 1406 */ 1407 _handleEllipseMove(point, pageX, pageY) { 1408 const { percentX, percentY } = this.convertPageCoordsToPercent( 1409 pageX, 1410 pageY 1411 ); 1412 const { rx, ry, cx, cy } = this.coordUnits; 1413 const position = cx !== "" ? ` at ${cx} ${cy}` : ""; 1414 1415 if (point === "center") { 1416 const { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } = 1417 this[_dragging]; 1418 const deltaX = (pageX - x) * ratioX; 1419 const deltaY = (pageY - y) * ratioY; 1420 const newCx = `${round(valueX + deltaX, unitX)}${unitX}`; 1421 const newCy = `${round(valueY + deltaY, unitY)}${unitY}`; 1422 const ellipseDef = 1423 `ellipse(${rx} ${ry} at ${newCx} ${newCy}) ${this.geometryBox}`.trim(); 1424 1425 this.emit("highlighter-event", { 1426 type: "shape-change", 1427 value: ellipseDef, 1428 }); 1429 } else if (point === "rx") { 1430 const { value, unit, origRadius, ratio } = this[_dragging]; 1431 const newRadiusPercent = Math.abs(percentX - this.coordinates.cx); 1432 const { width } = this.currentDimensions; 1433 const delta = ((newRadiusPercent / 100) * width - origRadius) * ratio; 1434 const newRadius = `${round(value + delta, unit)}${unit}`; 1435 1436 const ellipseDef = 1437 `ellipse(${newRadius} ${ry}${position}) ${this.geometryBox}`.trim(); 1438 1439 this.emit("highlighter-event", { 1440 type: "shape-change", 1441 value: ellipseDef, 1442 }); 1443 } else if (point === "ry") { 1444 const { value, unit, origRadius, ratio } = this[_dragging]; 1445 const newRadiusPercent = Math.abs(percentY - this.coordinates.cy); 1446 const { height } = this.currentDimensions; 1447 const delta = ((newRadiusPercent / 100) * height - origRadius) * ratio; 1448 const newRadius = `${round(value + delta, unit)}${unit}`; 1449 1450 const ellipseDef = 1451 `ellipse(${rx} ${newRadius}${position}) ${this.geometryBox}`.trim(); 1452 1453 this.emit("highlighter-event", { 1454 type: "shape-change", 1455 value: ellipseDef, 1456 }); 1457 } 1458 } 1459 1460 /** 1461 * Handle a click when highlighting an inset. 1462 * 1463 * @param {number} pageX the x coordinate of the click 1464 * @param {number} pageY the y coordinate of the click 1465 */ 1466 _handleInsetClick(pageX, pageY) { 1467 const { width, height } = this.currentDimensions; 1468 const { percentX, percentY } = this.convertPageCoordsToPercent( 1469 pageX, 1470 pageY 1471 ); 1472 const point = this.getInsetPointAt(percentX, percentY); 1473 if (!point) { 1474 return; 1475 } 1476 1477 this.setCursor("grabbing"); 1478 let value = this.coordUnits[point]; 1479 const size = point === "left" || point === "right" ? width : height; 1480 const computedValue = (this.coordinates[point] / 100) * size; 1481 const unit = getUnit(value); 1482 value = isUnitless(value) ? computedValue : parseFloat(value); 1483 const ratio = this.getUnitToPixelRatio(unit, size); 1484 const origValue = point === "left" || point === "right" ? pageX : pageY; 1485 1486 this[_dragging] = { point, value, origValue, unit, ratio }; 1487 } 1488 1489 /** 1490 * Set the top/left/right/bottom of the inset shape according to the mouse position 1491 * and update the element style. 1492 * 1493 * @param {string} point "top", "left", "right", or "bottom" 1494 * @param {number} pageX the x coordinate of the mouse position, in terms of % 1495 * relative to the element 1496 * @param {number} pageY the y coordinate of the mouse position, in terms of % 1497 * relative to the element 1498 * @memberof ShapesHighlighter 1499 */ 1500 _handleInsetMove(point, pageX, pageY) { 1501 let { top, left, right, bottom } = this.coordUnits; 1502 const { value, origValue, unit, ratio } = this[_dragging]; 1503 1504 if (point === "left") { 1505 const delta = (pageX - origValue) * ratio; 1506 left = `${round(value + delta, unit)}${unit}`; 1507 } else if (point === "right") { 1508 const delta = (pageX - origValue) * ratio; 1509 right = `${round(value - delta, unit)}${unit}`; 1510 } else if (point === "top") { 1511 const delta = (pageY - origValue) * ratio; 1512 top = `${round(value + delta, unit)}${unit}`; 1513 } else if (point === "bottom") { 1514 const delta = (pageY - origValue) * ratio; 1515 bottom = `${round(value - delta, unit)}${unit}`; 1516 } 1517 1518 let insetDef = this.insetRound 1519 ? `inset(${top} ${right} ${bottom} ${left} round ${this.insetRound})` 1520 : `inset(${top} ${right} ${bottom} ${left})`; 1521 1522 insetDef += this.geometryBox ? this.geometryBox : ""; 1523 1524 this.emit("highlighter-event", { type: "shape-change", value: insetDef }); 1525 } 1526 1527 _handleMouseMoveNotDragging(pageX, pageY) { 1528 const { percentX, percentY } = this.convertPageCoordsToPercent( 1529 pageX, 1530 pageY 1531 ); 1532 if (this.transformMode) { 1533 const point = this.getTransformPointAt(percentX, percentY); 1534 this.hoveredPoint = point; 1535 this._handleMarkerHover(point); 1536 } else if (this.shapeType === "polygon") { 1537 const point = this.getPolygonPointAt(percentX, percentY); 1538 const oldHoveredPoint = this.hoveredPoint; 1539 this.hoveredPoint = point !== -1 ? point : null; 1540 if (this.hoveredPoint !== oldHoveredPoint) { 1541 this._emitHoverEvent(this.hoveredPoint); 1542 } 1543 this._handleMarkerHover(point); 1544 } else if (this.shapeType === "circle") { 1545 const point = this.getCirclePointAt(percentX, percentY); 1546 const oldHoveredPoint = this.hoveredPoint; 1547 this.hoveredPoint = point ? point : null; 1548 if (this.hoveredPoint !== oldHoveredPoint) { 1549 this._emitHoverEvent(this.hoveredPoint); 1550 } 1551 this._handleMarkerHover(point); 1552 } else if (this.shapeType === "ellipse") { 1553 const point = this.getEllipsePointAt(percentX, percentY); 1554 const oldHoveredPoint = this.hoveredPoint; 1555 this.hoveredPoint = point ? point : null; 1556 if (this.hoveredPoint !== oldHoveredPoint) { 1557 this._emitHoverEvent(this.hoveredPoint); 1558 } 1559 this._handleMarkerHover(point); 1560 } else if (this.shapeType === "inset") { 1561 const point = this.getInsetPointAt(percentX, percentY); 1562 const oldHoveredPoint = this.hoveredPoint; 1563 this.hoveredPoint = point ? point : null; 1564 if (this.hoveredPoint !== oldHoveredPoint) { 1565 this._emitHoverEvent(this.hoveredPoint); 1566 } 1567 this._handleMarkerHover(point); 1568 } 1569 } 1570 1571 /** 1572 * Change the appearance of the given marker when the mouse hovers over it. 1573 * 1574 * @param {string | number} point if the shape is a polygon, the integer index of the 1575 * point being hovered. Otherwise, a string identifying the point being hovered. 1576 * Integers < 0 and falsey values excluding 0 indicate no point is being hovered. 1577 */ 1578 _handleMarkerHover(point) { 1579 // Hide hover marker for now, will be shown if point is a valid hover target 1580 this.getElement("shapes-marker-hover").setAttribute("hidden", true); 1581 // Catch all falsey values except when point === 0, as that's a valid point 1582 if (!point && point !== 0) { 1583 this.setCursor("auto"); 1584 return; 1585 } 1586 const hoverCursor = this[_dragging] ? "grabbing" : "grab"; 1587 1588 if (this.transformMode) { 1589 if (!point) { 1590 this.setCursor("auto"); 1591 return; 1592 } 1593 const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } = 1594 this.transformedBoundingBox; 1595 1596 const points = [ 1597 { 1598 pointName: "translate", 1599 x: center[0], 1600 y: center[1], 1601 cursor: hoverCursor, 1602 }, 1603 { pointName: "scale-se", x: se[0], y: se[1], anchor: "nw" }, 1604 { pointName: "scale-ne", x: ne[0], y: ne[1], anchor: "sw" }, 1605 { pointName: "scale-sw", x: sw[0], y: sw[1], anchor: "ne" }, 1606 { pointName: "scale-nw", x: nw[0], y: nw[1], anchor: "se" }, 1607 { pointName: "scale-n", x: n[0], y: n[1], anchor: "s" }, 1608 { pointName: "scale-s", x: s[0], y: s[1], anchor: "n" }, 1609 { pointName: "scale-e", x: e[0], y: e[1], anchor: "w" }, 1610 { pointName: "scale-w", x: w[0], y: w[1], anchor: "e" }, 1611 { 1612 pointName: "rotate", 1613 x: rotatePoint[0], 1614 y: rotatePoint[1], 1615 cursor: hoverCursor, 1616 }, 1617 ]; 1618 1619 for (const { pointName, x, y, cursor, anchor } of points) { 1620 if (point === pointName) { 1621 this._drawHoverMarker([[x, y]]); 1622 1623 // If the point is a scale handle, we will need to determine the direction 1624 // of the resize cursor based on the position of the handle relative to its 1625 // "anchor" (the handle opposite to it). 1626 if (pointName.includes("scale")) { 1627 const direction = this.getRoughDirection(pointName, anchor); 1628 this.setCursor(`${direction}-resize`); 1629 } else { 1630 this.setCursor(cursor); 1631 } 1632 } 1633 } 1634 } else if (this.shapeType === "polygon") { 1635 if (point === -1) { 1636 this.setCursor("auto"); 1637 return; 1638 } 1639 this.setCursor(hoverCursor); 1640 this._drawHoverMarker([this.coordinates[point]]); 1641 } else if (this.shapeType === "circle") { 1642 this.setCursor(hoverCursor); 1643 1644 const { cx, cy, rx } = this.coordinates; 1645 if (point === "radius") { 1646 this._drawHoverMarker([[cx + rx, cy]]); 1647 } else if (point === "center") { 1648 this._drawHoverMarker([[cx, cy]]); 1649 } 1650 } else if (this.shapeType === "ellipse") { 1651 this.setCursor(hoverCursor); 1652 1653 if (point === "center") { 1654 const { cx, cy } = this.coordinates; 1655 this._drawHoverMarker([[cx, cy]]); 1656 } else if (point === "rx") { 1657 const { cx, cy, rx } = this.coordinates; 1658 this._drawHoverMarker([[cx + rx, cy]]); 1659 } else if (point === "ry") { 1660 const { cx, cy, ry } = this.coordinates; 1661 this._drawHoverMarker([[cx, cy + ry]]); 1662 } 1663 } else if (this.shapeType === "inset") { 1664 this.setCursor(hoverCursor); 1665 1666 const { top, right, bottom, left } = this.coordinates; 1667 const centerX = (left + (100 - right)) / 2; 1668 const centerY = (top + (100 - bottom)) / 2; 1669 const points = point.split(","); 1670 const coords = points.map(side => { 1671 if (side === "top") { 1672 return [centerX, top]; 1673 } else if (side === "right") { 1674 return [100 - right, centerY]; 1675 } else if (side === "bottom") { 1676 return [centerX, 100 - bottom]; 1677 } else if (side === "left") { 1678 return [left, centerY]; 1679 } 1680 return null; 1681 }); 1682 1683 this._drawHoverMarker(coords); 1684 } 1685 } 1686 1687 _drawHoverMarker(points) { 1688 const { width, height } = this.currentDimensions; 1689 const zoom = getCurrentZoom(this.win); 1690 const path = points 1691 .map(([x, y]) => { 1692 return getCirclePath(BASE_MARKER_SIZE, x, y, width, height, zoom); 1693 }) 1694 .join(" "); 1695 1696 const markerHover = this.getElement("shapes-marker-hover"); 1697 markerHover.setAttribute("d", path); 1698 markerHover.removeAttribute("hidden"); 1699 } 1700 1701 _emitHoverEvent(point) { 1702 if (point === null || point === undefined) { 1703 this.emit("highlighter-event", { 1704 type: "shape-hover-off", 1705 }); 1706 } else { 1707 this.emit("highlighter-event", { 1708 type: "shape-hover-on", 1709 point: point.toString(), 1710 }); 1711 } 1712 } 1713 1714 /** 1715 * Convert the given coordinates on the page to percentages relative to the current 1716 * element. 1717 * 1718 * @param {number} pageX the x coordinate on the page 1719 * @param {number} pageY the y coordinate on the page 1720 * @returns {object} object of form {percentX, percentY}, which are the x/y coords 1721 * in percentages relative to the element. 1722 */ 1723 convertPageCoordsToPercent(pageX, pageY) { 1724 // If the current node is in an iframe, we get dimensions relative to the frame. 1725 const dims = this.frameDimensions; 1726 const { top, left, width, height } = dims; 1727 pageX -= left; 1728 pageY -= top; 1729 const percentX = (pageX * 100) / width; 1730 const percentY = (pageY * 100) / height; 1731 return { percentX, percentY }; 1732 } 1733 1734 /** 1735 * Convert the given x/y coordinates, in percentages relative to the current element, 1736 * to pixel coordinates relative to the page 1737 * 1738 * @param {number} x the x coordinate 1739 * @param {number} y the y coordinate 1740 * @returns {object} object of form {x, y}, which are the x/y coords in pixels 1741 * relative to the page 1742 * 1743 * @memberof ShapesHighlighter 1744 */ 1745 convertPercentToPageCoords(x, y) { 1746 const dims = this.frameDimensions; 1747 const { top, left, width, height } = dims; 1748 x = (x * width) / 100; 1749 y = (y * height) / 100; 1750 x += left; 1751 y += top; 1752 return { x, y }; 1753 } 1754 1755 /** 1756 * Get which transformation should be applied based on the mouse position. 1757 * 1758 * @param {number} pageX the x coordinate of the mouse. 1759 * @param {number} pageY the y coordinate of the mouse. 1760 * @returns {string} a string describing the transformation that should be applied 1761 * to the shape. 1762 */ 1763 getTransformPointAt(pageX, pageY) { 1764 const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } = 1765 this.transformedBoundingBox; 1766 const { width, height } = this.currentDimensions; 1767 const zoom = getCurrentZoom(this.win); 1768 const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; 1769 const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; 1770 1771 const points = [ 1772 { pointName: "translate", x: center[0], y: center[1] }, 1773 { pointName: "scale-se", x: se[0], y: se[1] }, 1774 { pointName: "scale-ne", x: ne[0], y: ne[1] }, 1775 { pointName: "scale-sw", x: sw[0], y: sw[1] }, 1776 { pointName: "scale-nw", x: nw[0], y: nw[1] }, 1777 ]; 1778 1779 if (this.shapeType === "polygon" || this.shapeType === "ellipse") { 1780 points.push( 1781 { pointName: "scale-n", x: n[0], y: n[1] }, 1782 { pointName: "scale-s", x: s[0], y: s[1] }, 1783 { pointName: "scale-e", x: e[0], y: e[1] }, 1784 { pointName: "scale-w", x: w[0], y: w[1] } 1785 ); 1786 } 1787 1788 if (this.shapeType === "polygon") { 1789 const x = rotatePoint[0]; 1790 const y = rotatePoint[1]; 1791 if ( 1792 pageX >= x - clickRadiusX && 1793 pageX <= x + clickRadiusX && 1794 pageY >= y - clickRadiusY && 1795 pageY <= y + clickRadiusY 1796 ) { 1797 return "rotate"; 1798 } 1799 } 1800 1801 for (const { pointName, x, y } of points) { 1802 if ( 1803 pageX >= x - clickRadiusX && 1804 pageX <= x + clickRadiusX && 1805 pageY >= y - clickRadiusY && 1806 pageY <= y + clickRadiusY 1807 ) { 1808 return pointName; 1809 } 1810 } 1811 1812 return ""; 1813 } 1814 1815 /** 1816 * Get the id of the point on the polygon highlighter at the given coordinate. 1817 * 1818 * @param {number} pageX the x coordinate on the page, in % relative to the element 1819 * @param {number} pageY the y coordinate on the page, in % relative to the element 1820 * @returns {number} the index of the point that was clicked on in this.coordinates, 1821 * or -1 if none of the points were clicked on. 1822 */ 1823 getPolygonPointAt(pageX, pageY) { 1824 const { coordinates } = this; 1825 const { width, height } = this.currentDimensions; 1826 const zoom = getCurrentZoom(this.win); 1827 const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; 1828 const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; 1829 1830 for (const [index, coord] of coordinates.entries()) { 1831 const [x, y] = coord; 1832 if ( 1833 pageX >= x - clickRadiusX && 1834 pageX <= x + clickRadiusX && 1835 pageY >= y - clickRadiusY && 1836 pageY <= y + clickRadiusY 1837 ) { 1838 return index; 1839 } 1840 } 1841 1842 return -1; 1843 } 1844 1845 /** 1846 * Check if the mouse clicked on a line of the polygon, and if so, add a point near 1847 * the click. 1848 * 1849 * @param {number} pageX the x coordinate on the page, in % relative to the element 1850 * @param {number} pageY the y coordinate on the page, in % relative to the element 1851 */ 1852 getPolygonClickedLine(pageX, pageY) { 1853 const { coordinates } = this; 1854 const { width } = this.currentDimensions; 1855 const clickWidth = (LINE_CLICK_WIDTH * 100) / width; 1856 1857 for (let i = 0; i < coordinates.length; i++) { 1858 const [x1, y1] = coordinates[i]; 1859 const [x2, y2] = 1860 i === coordinates.length - 1 ? coordinates[0] : coordinates[i + 1]; 1861 // Get the distance between clicked point and line drawn between points 1 and 2 1862 // to check if the click was on the line between those two points. 1863 const distance = distanceToLine(x1, y1, x2, y2, pageX, pageY); 1864 if ( 1865 distance <= clickWidth && 1866 Math.min(x1, x2) - clickWidth <= pageX && 1867 pageX <= Math.max(x1, x2) + clickWidth && 1868 Math.min(y1, y2) - clickWidth <= pageY && 1869 pageY <= Math.max(y1, y2) + clickWidth 1870 ) { 1871 // Get the point on the line closest to the clicked point. 1872 const [newX, newY] = projection(x1, y1, x2, y2, pageX, pageY); 1873 // Default unit for new points is percentages 1874 this._addPolygonPoint(i, round(newX, "%"), round(newY, "%")); 1875 return; 1876 } 1877 } 1878 } 1879 1880 /** 1881 * Check if the center point or radius of the circle highlighter is at given coords 1882 * 1883 * @param {number} pageX the x coordinate on the page, in % relative to the element 1884 * @param {number} pageY the y coordinate on the page, in % relative to the element 1885 * @returns {string} "center" if the center point was clicked, "radius" if the radius 1886 * was clicked, "" if neither was clicked. 1887 */ 1888 getCirclePointAt(pageX, pageY) { 1889 const { cx, cy, rx, ry } = this.coordinates; 1890 const { width, height } = this.currentDimensions; 1891 const zoom = getCurrentZoom(this.win); 1892 const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; 1893 const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; 1894 1895 if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) { 1896 return "center"; 1897 } 1898 1899 const clickWidthX = (LINE_CLICK_WIDTH * 100) / width; 1900 const clickWidthY = (LINE_CLICK_WIDTH * 100) / height; 1901 if ( 1902 clickedOnEllipseEdge( 1903 pageX, 1904 pageY, 1905 cx, 1906 cy, 1907 rx, 1908 ry, 1909 clickWidthX, 1910 clickWidthY 1911 ) || 1912 clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY) 1913 ) { 1914 return "radius"; 1915 } 1916 1917 return ""; 1918 } 1919 1920 /** 1921 * Check if the center or rx/ry points of the ellipse highlighter is at given point 1922 * 1923 * @param {number} pageX the x coordinate on the page, in % relative to the element 1924 * @param {number} pageY the y coordinate on the page, in % relative to the element 1925 * @returns {string} "center" if the center point was clicked, "rx" if the x-radius 1926 * point was clicked, "ry" if the y-radius point was clicked, 1927 * "" if none was clicked. 1928 */ 1929 getEllipsePointAt(pageX, pageY) { 1930 const { cx, cy, rx, ry } = this.coordinates; 1931 const { width, height } = this.currentDimensions; 1932 const zoom = getCurrentZoom(this.win); 1933 const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; 1934 const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; 1935 1936 if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) { 1937 return "center"; 1938 } 1939 1940 if (clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY)) { 1941 return "rx"; 1942 } 1943 1944 if (clickedOnPoint(pageX, pageY, cx, cy + ry, clickRadiusX, clickRadiusY)) { 1945 return "ry"; 1946 } 1947 1948 return ""; 1949 } 1950 1951 /** 1952 * Check if the edges of the inset highlighter is at given coords 1953 * 1954 * @param {number} pageX the x coordinate on the page, in % relative to the element 1955 * @param {number} pageY the y coordinate on the page, in % relative to the element 1956 * @returns {string} "top", "left", "right", or "bottom" if any of those edges were 1957 * clicked. "" if none were clicked. 1958 */ 1959 // eslint-disable-next-line complexity 1960 getInsetPointAt(pageX, pageY) { 1961 const { top, left, right, bottom } = this.coordinates; 1962 const zoom = getCurrentZoom(this.win); 1963 const { width, height } = this.currentDimensions; 1964 const clickWidthX = (LINE_CLICK_WIDTH * 100) / width; 1965 const clickWidthY = (LINE_CLICK_WIDTH * 100) / height; 1966 const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width; 1967 const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height; 1968 const centerX = (left + (100 - right)) / 2; 1969 const centerY = (top + (100 - bottom)) / 2; 1970 1971 if ( 1972 (pageX >= left - clickWidthX && 1973 pageX <= left + clickWidthX && 1974 pageY >= top && 1975 pageY <= 100 - bottom) || 1976 clickedOnPoint(pageX, pageY, left, centerY, clickRadiusX, clickRadiusY) 1977 ) { 1978 return "left"; 1979 } 1980 1981 if ( 1982 (pageX >= 100 - right - clickWidthX && 1983 pageX <= 100 - right + clickWidthX && 1984 pageY >= top && 1985 pageY <= 100 - bottom) || 1986 clickedOnPoint( 1987 pageX, 1988 pageY, 1989 100 - right, 1990 centerY, 1991 clickRadiusX, 1992 clickRadiusY 1993 ) 1994 ) { 1995 return "right"; 1996 } 1997 1998 if ( 1999 (pageY >= top - clickWidthY && 2000 pageY <= top + clickWidthY && 2001 pageX >= left && 2002 pageX <= 100 - right) || 2003 clickedOnPoint(pageX, pageY, centerX, top, clickRadiusX, clickRadiusY) 2004 ) { 2005 return "top"; 2006 } 2007 2008 if ( 2009 (pageY >= 100 - bottom - clickWidthY && 2010 pageY <= 100 - bottom + clickWidthY && 2011 pageX >= left && 2012 pageX <= 100 - right) || 2013 clickedOnPoint( 2014 pageX, 2015 pageY, 2016 centerX, 2017 100 - bottom, 2018 clickRadiusX, 2019 clickRadiusY 2020 ) 2021 ) { 2022 return "bottom"; 2023 } 2024 2025 return ""; 2026 } 2027 2028 /** 2029 * Parses the CSS definition given and returns the shape type associated 2030 * with the definition and the coordinates necessary to draw the shape. 2031 * 2032 * @param {string} definition the input CSS definition 2033 * @returns {object} null if the definition is not of a known shape type, 2034 * or an object of the type { shapeType, coordinates }, where 2035 * shapeType is the name of the shape and coordinates are an array 2036 * or object of the coordinates needed to draw the shape. 2037 */ 2038 _parseCSSShapeValue(definition) { 2039 const shapeTypes = [ 2040 { 2041 name: "polygon", 2042 prefix: "polygon(", 2043 coordParser: this.polygonPoints.bind(this), 2044 }, 2045 { 2046 name: "circle", 2047 prefix: "circle(", 2048 coordParser: this.circlePoints.bind(this), 2049 }, 2050 { 2051 name: "ellipse", 2052 prefix: "ellipse(", 2053 coordParser: this.ellipsePoints.bind(this), 2054 }, 2055 { 2056 name: "inset", 2057 prefix: "inset(", 2058 coordParser: this.insetPoints.bind(this), 2059 }, 2060 ]; 2061 const geometryTypes = ["margin", "border", "padding", "content"]; 2062 // default to border for clip-path and offset-path, and margin for shape-outside 2063 const defaultGeometryTypesByProperty = new Map([ 2064 ["clip-path", "border"], 2065 ["offset-path", "border"], 2066 ["shape-outside", "margin"], 2067 ]); 2068 2069 let referenceBox = defaultGeometryTypesByProperty.get(this.property); 2070 for (const geometry of geometryTypes) { 2071 if (definition.includes(geometry)) { 2072 referenceBox = geometry; 2073 } 2074 } 2075 this.referenceBox = referenceBox; 2076 2077 this.useStrokeBox = definition.includes("stroke-box"); 2078 this.geometryBox = definition 2079 .substring(definition.lastIndexOf(")") + 1) 2080 .trim(); 2081 2082 for (const { name, prefix, coordParser } of shapeTypes) { 2083 if (definition.includes(prefix)) { 2084 // the closing paren of the shape function is always the last one in definition. 2085 definition = definition.substring( 2086 prefix.length, 2087 definition.lastIndexOf(")") 2088 ); 2089 return { 2090 shapeType: name, 2091 coordinates: coordParser(definition), 2092 }; 2093 } 2094 } 2095 2096 return null; 2097 } 2098 2099 /** 2100 * Parses the definition of the CSS polygon() function and returns its points, 2101 * converted to percentages. 2102 * 2103 * @param {string} definition the arguments of the polygon() function 2104 * @returns {Array} an array of the points of the polygon, with all values 2105 * evaluated and converted to percentages 2106 */ 2107 polygonPoints(definition) { 2108 this.coordUnits = this.polygonRawPoints(); 2109 if (!this.origCoordUnits) { 2110 this.origCoordUnits = this.coordUnits; 2111 } 2112 const splitDef = definition.split(", "); 2113 if (splitDef[0] === "evenodd" || splitDef[0] === "nonzero") { 2114 splitDef.shift(); 2115 } 2116 let minX = Number.MAX_SAFE_INTEGER; 2117 let minY = Number.MAX_SAFE_INTEGER; 2118 let maxX = Number.MIN_SAFE_INTEGER; 2119 let maxY = Number.MIN_SAFE_INTEGER; 2120 const coordinates = splitDef.map(coords => { 2121 const [x, y] = splitCoords(coords).map( 2122 this.convertCoordsToPercent.bind(this) 2123 ); 2124 if (x < minX) { 2125 minX = x; 2126 } 2127 if (y < minY) { 2128 minY = y; 2129 } 2130 if (x > maxX) { 2131 maxX = x; 2132 } 2133 if (y > maxY) { 2134 maxY = y; 2135 } 2136 return [x, y]; 2137 }); 2138 this.boundingBox = { minX, minY, maxX, maxY }; 2139 if (!this.origBoundingBox) { 2140 this.origBoundingBox = this.boundingBox; 2141 } 2142 return coordinates; 2143 } 2144 2145 /** 2146 * Parse the raw (non-computed) definition of the CSS polygon. 2147 * 2148 * @returns {Array} an array of the points of the polygon, with units preserved. 2149 */ 2150 polygonRawPoints() { 2151 let definition = getDefinedShapeProperties(this.currentNode, this.property); 2152 if (definition === this.rawDefinition && this.coordUnits) { 2153 return this.coordUnits; 2154 } 2155 this.rawDefinition = definition; 2156 definition = definition.substring(8, definition.lastIndexOf(")")); 2157 const splitDef = definition.split(", "); 2158 if (splitDef[0].includes("evenodd") || splitDef[0].includes("nonzero")) { 2159 this.fillRule = splitDef[0].trim(); 2160 splitDef.shift(); 2161 } else { 2162 this.fillRule = ""; 2163 } 2164 return splitDef.map(coords => { 2165 return splitCoords(coords).map(coord => { 2166 // Undo the insertion of that was done in splitCoords. 2167 return coord.replace(/\u00a0/g, " "); 2168 }); 2169 }); 2170 } 2171 2172 /** 2173 * Parses the definition of the CSS circle() function and returns the x/y radiuses and 2174 * center coordinates, converted to percentages. 2175 * 2176 * @param {string} definition the arguments of the circle() function 2177 * @returns {object} an object of the form { rx, ry, cx, cy }, where rx and ry are the 2178 * radiuses for the x and y axes, and cx and cy are the x/y coordinates for the 2179 * center of the circle. All values are evaluated and converted to percentages. 2180 */ 2181 circlePoints(definition) { 2182 this.coordUnits = this.circleRawPoints(); 2183 if (!this.origCoordUnits) { 2184 this.origCoordUnits = this.coordUnits; 2185 } 2186 2187 const values = definition.split("at"); 2188 let radius = values[0] ? values[0].trim() : "closest-side"; 2189 const { width, height } = this.currentDimensions; 2190 // This defaults to center if omitted. 2191 const position = values[1] || "50% 50%"; 2192 const center = splitCoords(position).map( 2193 this.convertCoordsToPercent.bind(this) 2194 ); 2195 2196 // Percentage values for circle() are resolved from the 2197 // used width and height of the reference box as sqrt(width^2+height^2)/sqrt(2). 2198 const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2); 2199 2200 // Position coordinates for circle center in pixels. 2201 const cxPx = (width * center[0]) / 100; 2202 const cyPx = (height * center[1]) / 100; 2203 2204 if (radius === "closest-side") { 2205 // radius is the distance from center to closest side of reference box 2206 radius = Math.min(cxPx, cyPx, width - cxPx, height - cyPx); 2207 radius = coordToPercent(`${radius}px`, computedSize); 2208 } else if (radius === "farthest-side") { 2209 // radius is the distance from center to farthest side of reference box 2210 radius = Math.max(cxPx, cyPx, width - cxPx, height - cyPx); 2211 radius = coordToPercent(`${radius}px`, computedSize); 2212 } else if (radius.includes("calc(")) { 2213 radius = evalCalcExpression( 2214 radius.substring(5, radius.length - 1), 2215 computedSize 2216 ); 2217 } else { 2218 radius = coordToPercent(radius, computedSize); 2219 } 2220 2221 // Scale both radiusX and radiusY to match the radius computed 2222 // using the above equation. 2223 const ratioX = width / computedSize; 2224 const ratioY = height / computedSize; 2225 const radiusX = radius / ratioX; 2226 const radiusY = radius / ratioY; 2227 2228 this.boundingBox = { 2229 minX: center[0] - radiusX, 2230 maxX: center[0] + radiusX, 2231 minY: center[1] - radiusY, 2232 maxY: center[1] + radiusY, 2233 }; 2234 if (!this.origBoundingBox) { 2235 this.origBoundingBox = this.boundingBox; 2236 } 2237 return { radius, rx: radiusX, ry: radiusY, cx: center[0], cy: center[1] }; 2238 } 2239 2240 /** 2241 * Parse the raw (non-computed) definition of the CSS circle. 2242 * 2243 * @returns {object} an object of the points of the circle (cx, cy, radius), 2244 * with units preserved. 2245 */ 2246 circleRawPoints() { 2247 let definition = getDefinedShapeProperties(this.currentNode, this.property); 2248 if (definition === this.rawDefinition && this.coordUnits) { 2249 return this.coordUnits; 2250 } 2251 this.rawDefinition = definition; 2252 definition = definition.substring(7, definition.lastIndexOf(")")); 2253 2254 const values = definition.split("at"); 2255 const [cx = "", cy = ""] = values[1] 2256 ? splitCoords(values[1]).map(coord => { 2257 // Undo the insertion of that was done in splitCoords. 2258 return coord.replace(/\u00a0/g, " "); 2259 }) 2260 : []; 2261 const radius = values[0] ? values[0].trim() : "closest-side"; 2262 return { cx, cy, radius }; 2263 } 2264 2265 /** 2266 * Parses the computed style definition of the CSS ellipse() function and returns the 2267 * x/y radii and center coordinates, converted to percentages. 2268 * 2269 * @param {string} definition the arguments of the ellipse() function 2270 * @returns {object} an object of the form { rx, ry, cx, cy }, where rx and ry are the 2271 * radiuses for the x and y axes, and cx and cy are the x/y coordinates for the 2272 * center of the ellipse. All values are evaluated and converted to percentages 2273 */ 2274 ellipsePoints(definition) { 2275 this.coordUnits = this.ellipseRawPoints(); 2276 if (!this.origCoordUnits) { 2277 this.origCoordUnits = this.coordUnits; 2278 } 2279 2280 const values = definition.split("at"); 2281 // This defaults to center if omitted. 2282 const position = values[1] || "50% 50%"; 2283 const center = splitCoords(position).map( 2284 this.convertCoordsToPercent.bind(this) 2285 ); 2286 2287 let radii = values[0] ? values[0].trim() : "closest-side closest-side"; 2288 radii = splitCoords(radii).map((radius, i) => { 2289 if (radius === "closest-side") { 2290 // radius is the distance from center to closest x/y side of reference box 2291 return i % 2 === 0 2292 ? Math.min(center[0], 100 - center[0]) 2293 : Math.min(center[1], 100 - center[1]); 2294 } else if (radius === "farthest-side") { 2295 // radius is the distance from center to farthest x/y side of reference box 2296 return i % 2 === 0 2297 ? Math.max(center[0], 100 - center[0]) 2298 : Math.max(center[1], 100 - center[1]); 2299 } 2300 return this.convertCoordsToPercent(radius, i); 2301 }); 2302 2303 this.boundingBox = { 2304 minX: center[0] - radii[0], 2305 maxX: center[0] + radii[0], 2306 minY: center[1] - radii[1], 2307 maxY: center[1] + radii[1], 2308 }; 2309 if (!this.origBoundingBox) { 2310 this.origBoundingBox = this.boundingBox; 2311 } 2312 return { rx: radii[0], ry: radii[1], cx: center[0], cy: center[1] }; 2313 } 2314 2315 /** 2316 * Parse the raw (non-computed) definition of the CSS ellipse. 2317 * 2318 * @returns {object} an object of the points of the ellipse (cx, cy, rx, ry), 2319 * with units preserved. 2320 */ 2321 ellipseRawPoints() { 2322 let definition = getDefinedShapeProperties(this.currentNode, this.property); 2323 if (definition === this.rawDefinition && this.coordUnits) { 2324 return this.coordUnits; 2325 } 2326 this.rawDefinition = definition; 2327 definition = definition.substring(8, definition.lastIndexOf(")")); 2328 2329 const values = definition.split("at"); 2330 const [rx = "closest-side", ry = "closest-side"] = values[0] 2331 ? splitCoords(values[0]).map(coord => { 2332 // Undo the insertion of that was done in splitCoords. 2333 return coord.replace(/\u00a0/g, " "); 2334 }) 2335 : []; 2336 const [cx = "", cy = ""] = values[1] 2337 ? splitCoords(values[1]).map(coord => { 2338 return coord.replace(/\u00a0/g, " "); 2339 }) 2340 : []; 2341 return { rx, ry, cx, cy }; 2342 } 2343 2344 /** 2345 * Parses the definition of the CSS inset() function and returns the x/y offsets and 2346 * width/height of the shape, converted to percentages. Border radiuses (given after 2347 * "round" in the definition) are currently ignored. 2348 * 2349 * @param {string} definition the arguments of the inset() function 2350 * @returns {object} an object of the form { x, y, width, height }, which are the top/ 2351 * left positions and width/height of the shape. 2352 */ 2353 insetPoints(definition) { 2354 this.coordUnits = this.insetRawPoints(); 2355 if (!this.origCoordUnits) { 2356 this.origCoordUnits = this.coordUnits; 2357 } 2358 const values = definition.split(" round "); 2359 const offsets = splitCoords(values[0]); 2360 2361 let top, left, right, bottom; 2362 // The offsets, like margin/padding/border, are in order: top, right, bottom, left. 2363 if (offsets.length === 1) { 2364 top = left = right = bottom = offsets[0]; 2365 } else if (offsets.length === 2) { 2366 top = bottom = offsets[0]; 2367 left = right = offsets[1]; 2368 } else if (offsets.length === 3) { 2369 top = offsets[0]; 2370 left = right = offsets[1]; 2371 bottom = offsets[2]; 2372 } else if (offsets.length === 4) { 2373 top = offsets[0]; 2374 right = offsets[1]; 2375 bottom = offsets[2]; 2376 left = offsets[3]; 2377 } 2378 2379 top = this.convertCoordsToPercentFromCurrentDimension(top, "height"); 2380 bottom = this.convertCoordsToPercentFromCurrentDimension(bottom, "height"); 2381 left = this.convertCoordsToPercentFromCurrentDimension(left, "width"); 2382 right = this.convertCoordsToPercentFromCurrentDimension(right, "width"); 2383 2384 // maxX/maxY are found by subtracting the right/bottom edges from 100 2385 // (the width/height of the element in %) 2386 this.boundingBox = { 2387 minX: left, 2388 maxX: 100 - right, 2389 minY: top, 2390 maxY: 100 - bottom, 2391 }; 2392 if (!this.origBoundingBox) { 2393 this.origBoundingBox = this.boundingBox; 2394 } 2395 return { top, left, right, bottom }; 2396 } 2397 2398 /** 2399 * Parse the raw (non-computed) definition of the CSS inset. 2400 * 2401 * @returns {object} an object of the points of the inset (top, right, bottom, left), 2402 * with units preserved. 2403 */ 2404 insetRawPoints() { 2405 let definition = getDefinedShapeProperties(this.currentNode, this.property); 2406 if (definition === this.rawDefinition && this.coordUnits) { 2407 return this.coordUnits; 2408 } 2409 this.rawDefinition = definition; 2410 definition = definition.substring(6, definition.lastIndexOf(")")); 2411 2412 const values = definition.split(" round "); 2413 this.insetRound = values[1]; 2414 const offsets = splitCoords(values[0]).map(coord => { 2415 // Undo the insertion of that was done in splitCoords. 2416 return coord.replace(/\u00a0/g, " "); 2417 }); 2418 2419 let top, 2420 left, 2421 right, 2422 bottom = 0; 2423 2424 if (offsets.length === 1) { 2425 top = left = right = bottom = offsets[0]; 2426 } else if (offsets.length === 2) { 2427 top = bottom = offsets[0]; 2428 left = right = offsets[1]; 2429 } else if (offsets.length === 3) { 2430 top = offsets[0]; 2431 left = right = offsets[1]; 2432 bottom = offsets[2]; 2433 } else if (offsets.length === 4) { 2434 top = offsets[0]; 2435 right = offsets[1]; 2436 bottom = offsets[2]; 2437 left = offsets[3]; 2438 } 2439 2440 return { top, left, right, bottom }; 2441 } 2442 2443 /** 2444 * This uses the index to decide whether to use width or height for the 2445 * computation. See `convertCoordsToPercentFromCurrentDimension()` if you 2446 * need to specify width or height. 2447 * 2448 * @param {number} coord a single coordinate 2449 * @param {number} i the index of its position in the function 2450 * @returns {number} the coordinate as a percentage value 2451 */ 2452 convertCoordsToPercent(coord, i) { 2453 const { width, height } = this.currentDimensions; 2454 const size = i % 2 === 0 ? width : height; 2455 if (coord.includes("calc(")) { 2456 return evalCalcExpression(coord.substring(5, coord.length - 1), size); 2457 } 2458 return coordToPercent(coord, size); 2459 } 2460 2461 /** 2462 * Converts a value to percent based on the specified dimension. 2463 * 2464 * @param {number} coord a single coordinate 2465 * @param {number} currentDimensionProperty the dimension ("width" or 2466 * "height") to base the calculation off of 2467 * @returns {number} the coordinate as a percentage value 2468 */ 2469 convertCoordsToPercentFromCurrentDimension(coord, currentDimensionProperty) { 2470 const size = this.currentDimensions[currentDimensionProperty]; 2471 if (coord.includes("calc(")) { 2472 return evalCalcExpression(coord.substring(5, coord.length - 1), size); 2473 } 2474 return coordToPercent(coord, size); 2475 } 2476 2477 /** 2478 * Destroy the nodes. Remove listeners. 2479 */ 2480 destroy() { 2481 const { pageListenerTarget } = this.highlighterEnv; 2482 if (pageListenerTarget) { 2483 DOM_EVENTS.forEach(type => 2484 pageListenerTarget.removeEventListener(type, this) 2485 ); 2486 } 2487 super.destroy(this); 2488 this.markup.destroy(); 2489 this.rootEl = null; 2490 } 2491 2492 /** 2493 * Get the element in the highlighter markup with the given id 2494 * 2495 * @param {string} id 2496 * @returns {object} the element with the given id 2497 */ 2498 getElement(id) { 2499 return this.markup.getElement(id); 2500 } 2501 2502 /** 2503 * Return whether all the elements used to draw shapes are hidden. 2504 * 2505 * @returns {boolean} 2506 */ 2507 areShapesHidden() { 2508 return ( 2509 this.getElement("shapes-ellipse").hasAttribute("hidden") && 2510 this.getElement("shapes-polygon").hasAttribute("hidden") && 2511 this.getElement("shapes-rect").hasAttribute("hidden") && 2512 this.getElement("shapes-bounding-box").hasAttribute("hidden") 2513 ); 2514 } 2515 2516 /** 2517 * Show the highlighter on a given node 2518 */ 2519 _show() { 2520 this.hoveredPoint = this.options.hoverPoint; 2521 this.transformMode = this.options.transformMode; 2522 this.coordinates = null; 2523 this.coordUnits = null; 2524 this.origBoundingBox = null; 2525 this.origCoordUnits = null; 2526 this.origCoordinates = null; 2527 this.transformedBoundingBox = null; 2528 if (this.transformMode) { 2529 this.transformMatrix = identity(); 2530 } 2531 if (this._hasMoved() && this.transformMode) { 2532 this.transformedBoundingBox = this.calculateTransformedBoundingBox(); 2533 } 2534 return this._update(); 2535 } 2536 2537 /** 2538 * The AutoRefreshHighlighter's _hasMoved method returns true only if the element's 2539 * quads have changed. Override it so it also returns true if the element's shape has 2540 * changed (which can happen when you change a CSS properties for instance). 2541 */ 2542 _hasMoved() { 2543 let hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this); 2544 2545 if (hasMoved) { 2546 this.origBoundingBox = null; 2547 this.origCoordUnits = null; 2548 this.origCoordinates = null; 2549 if (this.transformMode) { 2550 this.transformMatrix = identity(); 2551 } 2552 } 2553 2554 const oldShapeCoordinates = JSON.stringify(this.coordinates); 2555 2556 // TODO: need other modes too. 2557 if (this.options.mode.startsWith("css")) { 2558 const property = shapeModeToCssPropertyName(this.options.mode); 2559 // change camelCase to kebab-case 2560 this.property = property.replace(/([a-z][A-Z])/g, g => { 2561 return g[0] + "-" + g[1].toLowerCase(); 2562 }); 2563 const style = getComputedStyle(this.currentNode)[property]; 2564 2565 if (!style || style === "none") { 2566 this.coordinates = []; 2567 this.shapeType = "none"; 2568 } else { 2569 const { coordinates, shapeType } = this._parseCSSShapeValue(style); 2570 this.coordinates = coordinates; 2571 if (!this.origCoordinates) { 2572 this.origCoordinates = coordinates; 2573 } 2574 this.shapeType = shapeType; 2575 } 2576 } 2577 2578 const newShapeCoordinates = JSON.stringify(this.coordinates); 2579 hasMoved = hasMoved || oldShapeCoordinates !== newShapeCoordinates; 2580 if (this.transformMode && hasMoved) { 2581 this.transformedBoundingBox = this.calculateTransformedBoundingBox(); 2582 } 2583 2584 return hasMoved; 2585 } 2586 2587 /** 2588 * Hide all elements used to highlight CSS different shapes. 2589 */ 2590 _hideShapes() { 2591 this.getElement("shapes-ellipse").setAttribute("hidden", true); 2592 this.getElement("shapes-polygon").setAttribute("hidden", true); 2593 this.getElement("shapes-rect").setAttribute("hidden", true); 2594 this.getElement("shapes-bounding-box").setAttribute("hidden", true); 2595 this.getElement("shapes-markers").setAttribute("d", ""); 2596 this.getElement("shapes-markers-outline").setAttribute("d", ""); 2597 this.getElement("shapes-rotate-line").setAttribute("d", ""); 2598 this.getElement("shapes-quad").setAttribute("hidden", true); 2599 this.getElement("shapes-clip-ellipse").setAttribute("hidden", true); 2600 this.getElement("shapes-clip-polygon").setAttribute("hidden", true); 2601 this.getElement("shapes-clip-rect").setAttribute("hidden", true); 2602 this.getElement("shapes-dashed-polygon").setAttribute("hidden", true); 2603 this.getElement("shapes-dashed-ellipse").setAttribute("hidden", true); 2604 this.getElement("shapes-dashed-rect").setAttribute("hidden", true); 2605 } 2606 2607 /** 2608 * Update the highlighter for the current node. Called whenever the element's quads 2609 * or CSS shape has changed. 2610 * 2611 * @returns {boolean} whether the highlighter was successfully updated 2612 */ 2613 _update() { 2614 setIgnoreLayoutChanges(true); 2615 this.getElement("shapes-group").setAttribute("transform", ""); 2616 const root = this.getElement("shapes-root"); 2617 root.setAttribute("hidden", true); 2618 2619 const { top, left, width, height } = this.currentDimensions; 2620 const zoom = getCurrentZoom(this.win); 2621 2622 // Size the SVG like the current node. 2623 this.getElement("shapes-shape-container").setAttribute( 2624 "style", 2625 `top:${top}px;left:${left}px;width:${width}px;height:${height}px;` 2626 ); 2627 2628 this._hideShapes(); 2629 this._updateShapes(width, height, zoom); 2630 2631 // For both shape-outside and clip-path the element's quads are displayed for the 2632 // parts that overlap with the shape. The parts of the shape that extend past the 2633 // element's quads are shown with a dashed line. 2634 const quadRect = this.getElement("shapes-quad"); 2635 quadRect.removeAttribute("hidden"); 2636 2637 this.getElement("shapes-polygon").setAttribute( 2638 "clip-path", 2639 "url(#shapes-quad-clip-path)" 2640 ); 2641 this.getElement("shapes-ellipse").setAttribute( 2642 "clip-path", 2643 "url(#shapes-quad-clip-path)" 2644 ); 2645 this.getElement("shapes-rect").setAttribute( 2646 "clip-path", 2647 "url(#shapes-quad-clip-path)" 2648 ); 2649 2650 const { width: winWidth, height: winHeight } = this._winDimensions; 2651 root.removeAttribute("hidden"); 2652 root.setAttribute( 2653 "style", 2654 `position:absolute; width:${winWidth}px;height:${winHeight}px; overflow:hidden;` 2655 ); 2656 2657 this._handleMarkerHover(this.hoveredPoint); 2658 2659 setIgnoreLayoutChanges( 2660 false, 2661 this.highlighterEnv.window.document.documentElement 2662 ); 2663 2664 return true; 2665 } 2666 2667 /** 2668 * Update the SVGs to render the current CSS shape and add markers depending on shape 2669 * type and transform mode. 2670 * 2671 * @param {number} width the width of the element quads 2672 * @param {number} height the height of the element quads 2673 * @param {number} zoom the zoom level of the window 2674 */ 2675 _updateShapes(width, height, zoom) { 2676 if (this.transformMode && this.shapeType !== "none") { 2677 this._updateTransformMode(width, height, zoom); 2678 } else if (this.shapeType === "polygon") { 2679 this._updatePolygonShape(width, height, zoom); 2680 // Draw markers for each of the polygon's points. 2681 this._drawMarkers(this.coordinates, width, height, zoom); 2682 } else if (this.shapeType === "circle") { 2683 const { rx, cx, cy } = this.coordinates; 2684 // Shape renders for "circle()" and "ellipse()" use the same SVG nodes. 2685 this._updateEllipseShape(width, height, zoom); 2686 // Draw markers for center and radius points. 2687 this._drawMarkers( 2688 [ 2689 [cx, cy], 2690 [cx + rx, cy], 2691 ], 2692 width, 2693 height, 2694 zoom 2695 ); 2696 } else if (this.shapeType === "ellipse") { 2697 const { rx, ry, cx, cy } = this.coordinates; 2698 this._updateEllipseShape(width, height, zoom); 2699 // Draw markers for center, horizontal radius and vertical radius points. 2700 this._drawMarkers( 2701 [ 2702 [cx, cy], 2703 [cx + rx, cy], 2704 [cx, cy + ry], 2705 ], 2706 width, 2707 height, 2708 zoom 2709 ); 2710 } else if (this.shapeType === "inset") { 2711 const { top, left, right, bottom } = this.coordinates; 2712 const centerX = (left + (100 - right)) / 2; 2713 const centerY = (top + (100 - bottom)) / 2; 2714 const markerCoords = [ 2715 [centerX, top], 2716 [100 - right, centerY], 2717 [centerX, 100 - bottom], 2718 [left, centerY], 2719 ]; 2720 this._updateInsetShape(width, height, zoom); 2721 // Draw markers for each of the inset's sides. 2722 this._drawMarkers(markerCoords, width, height, zoom); 2723 } 2724 } 2725 2726 /** 2727 * Update the SVGs for transform mode to fit the new shape. 2728 * 2729 * @param {number} width the width of the element quads 2730 * @param {number} height the height of the element quads 2731 * @param {number} zoom the zoom level of the window 2732 */ 2733 _updateTransformMode(width, height, zoom) { 2734 const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } = 2735 this.transformedBoundingBox; 2736 const boundingBox = this.getElement("shapes-bounding-box"); 2737 const path = `M${nw.join(" ")} L${ne.join(" ")} L${se.join(" ")} L${sw.join( 2738 " " 2739 )} Z`; 2740 boundingBox.setAttribute("d", path); 2741 boundingBox.removeAttribute("hidden"); 2742 2743 const markerPoints = [center, nw, ne, se, sw]; 2744 if (this.shapeType === "polygon" || this.shapeType === "ellipse") { 2745 markerPoints.push(n, s, w, e); 2746 } 2747 2748 if (this.shapeType === "polygon") { 2749 this._updatePolygonShape(width, height, zoom); 2750 markerPoints.push(rotatePoint); 2751 const rotateLine = `M ${center.join(" ")} L ${rotatePoint.join(" ")}`; 2752 this.getElement("shapes-rotate-line").setAttribute("d", rotateLine); 2753 } else if (this.shapeType === "circle" || this.shapeType === "ellipse") { 2754 // Shape renders for "circle()" and "ellipse()" use the same SVG nodes. 2755 this._updateEllipseShape(width, height, zoom); 2756 } else if (this.shapeType === "inset") { 2757 this._updateInsetShape(width, height, zoom); 2758 } 2759 2760 this._drawMarkers(markerPoints, width, height, zoom); 2761 } 2762 2763 /** 2764 * Update the SVG polygon to fit the CSS polygon. 2765 */ 2766 _updatePolygonShape() { 2767 // Draw and show the polygon. 2768 const points = this.coordinates.map(point => point.join(",")).join(" "); 2769 2770 const polygonEl = this.getElement("shapes-polygon"); 2771 polygonEl.setAttribute("points", points); 2772 polygonEl.removeAttribute("hidden"); 2773 2774 const clipPolygon = this.getElement("shapes-clip-polygon"); 2775 clipPolygon.setAttribute("points", points); 2776 clipPolygon.removeAttribute("hidden"); 2777 2778 const dashedPolygon = this.getElement("shapes-dashed-polygon"); 2779 dashedPolygon.setAttribute("points", points); 2780 dashedPolygon.removeAttribute("hidden"); 2781 } 2782 2783 /** 2784 * Update the SVG ellipse to fit the CSS circle or ellipse. 2785 */ 2786 _updateEllipseShape() { 2787 const { rx, ry, cx, cy } = this.coordinates; 2788 const ellipseEl = this.getElement("shapes-ellipse"); 2789 ellipseEl.setAttribute("rx", rx); 2790 ellipseEl.setAttribute("ry", ry); 2791 ellipseEl.setAttribute("cx", cx); 2792 ellipseEl.setAttribute("cy", cy); 2793 ellipseEl.removeAttribute("hidden"); 2794 2795 const clipEllipse = this.getElement("shapes-clip-ellipse"); 2796 clipEllipse.setAttribute("rx", rx); 2797 clipEllipse.setAttribute("ry", ry); 2798 clipEllipse.setAttribute("cx", cx); 2799 clipEllipse.setAttribute("cy", cy); 2800 clipEllipse.removeAttribute("hidden"); 2801 2802 const dashedEllipse = this.getElement("shapes-dashed-ellipse"); 2803 dashedEllipse.setAttribute("rx", rx); 2804 dashedEllipse.setAttribute("ry", ry); 2805 dashedEllipse.setAttribute("cx", cx); 2806 dashedEllipse.setAttribute("cy", cy); 2807 dashedEllipse.removeAttribute("hidden"); 2808 } 2809 2810 /** 2811 * Update the SVG rect to fit the CSS inset. 2812 */ 2813 _updateInsetShape() { 2814 const { top, left, right, bottom } = this.coordinates; 2815 const rectEl = this.getElement("shapes-rect"); 2816 rectEl.setAttribute("x", left); 2817 rectEl.setAttribute("y", top); 2818 rectEl.setAttribute("width", 100 - left - right); 2819 rectEl.setAttribute("height", 100 - top - bottom); 2820 rectEl.removeAttribute("hidden"); 2821 2822 const clipRect = this.getElement("shapes-clip-rect"); 2823 clipRect.setAttribute("x", left); 2824 clipRect.setAttribute("y", top); 2825 clipRect.setAttribute("width", 100 - left - right); 2826 clipRect.setAttribute("height", 100 - top - bottom); 2827 clipRect.removeAttribute("hidden"); 2828 2829 const dashedRect = this.getElement("shapes-dashed-rect"); 2830 dashedRect.setAttribute("x", left); 2831 dashedRect.setAttribute("y", top); 2832 dashedRect.setAttribute("width", 100 - left - right); 2833 dashedRect.setAttribute("height", 100 - top - bottom); 2834 dashedRect.removeAttribute("hidden"); 2835 } 2836 2837 /** 2838 * Draw markers for the given coordinates. 2839 * 2840 * @param {Array} coords an array of coordinate arrays, of form [[x, y] ...] 2841 * @param {number} width the width of the element markers are being drawn for 2842 * @param {number} height the height of the element markers are being drawn for 2843 * @param {number} zoom the zoom level of the window 2844 */ 2845 _drawMarkers(coords, width, height, zoom) { 2846 const markers = coords 2847 .map(([x, y]) => { 2848 return getCirclePath(BASE_MARKER_SIZE, x, y, width, height, zoom); 2849 }) 2850 .join(" "); 2851 const outline = coords 2852 .map(([x, y]) => { 2853 return getCirclePath(BASE_MARKER_SIZE + 2, x, y, width, height, zoom); 2854 }) 2855 .join(" "); 2856 2857 this.getElement("shapes-markers").setAttribute("d", markers); 2858 this.getElement("shapes-markers-outline").setAttribute("d", outline); 2859 } 2860 2861 /** 2862 * Calculate the bounding box of the shape after it is transformed according to 2863 * the transformation matrix. 2864 * 2865 * @returns {object} of form { nw, ne, sw, se, n, s, w, e, rotatePoint, center }. 2866 * Each element in the object is an array of form [x,y], denoting the x/y 2867 * coordinates of the given point. 2868 */ 2869 calculateTransformedBoundingBox() { 2870 const { minX, minY, maxX, maxY } = this.origBoundingBox; 2871 const { width, height } = this.currentDimensions; 2872 const toPixel = scale(width / 100, height / 100); 2873 const toPercent = scale(100 / width, 100 / height); 2874 const matrix = multiply(toPercent, multiply(this.transformMatrix, toPixel)); 2875 const centerX = (minX + maxX) / 2; 2876 const centerY = (minY + maxY) / 2; 2877 const nw = apply(matrix, [minX, minY]); 2878 const ne = apply(matrix, [maxX, minY]); 2879 const sw = apply(matrix, [minX, maxY]); 2880 const se = apply(matrix, [maxX, maxY]); 2881 const n = apply(matrix, [centerX, minY]); 2882 const s = apply(matrix, [centerX, maxY]); 2883 const w = apply(matrix, [minX, centerY]); 2884 const e = apply(matrix, [maxX, centerY]); 2885 const center = apply(matrix, [centerX, centerY]); 2886 2887 const u = [ 2888 ((ne[0] - nw[0]) / 100) * width, 2889 ((ne[1] - nw[1]) / 100) * height, 2890 ]; 2891 const v = [ 2892 ((sw[0] - nw[0]) / 100) * width, 2893 ((sw[1] - nw[1]) / 100) * height, 2894 ]; 2895 const { basis, invertedBasis } = getBasis(u, v); 2896 let rotatePointMatrix = changeMatrixBase( 2897 translate(0, -ROTATE_LINE_LENGTH), 2898 invertedBasis, 2899 basis 2900 ); 2901 rotatePointMatrix = multiply( 2902 toPercent, 2903 multiply(rotatePointMatrix, multiply(this.transformMatrix, toPixel)) 2904 ); 2905 const rotatePoint = apply(rotatePointMatrix, [centerX, centerY]); 2906 return { nw, ne, sw, se, n, s, w, e, rotatePoint, center }; 2907 } 2908 2909 /** 2910 * Hide the highlighter, the outline and the infobar. 2911 */ 2912 _hide() { 2913 setIgnoreLayoutChanges(true); 2914 2915 this._hideShapes(); 2916 this.getElement("shapes-markers").setAttribute("d", ""); 2917 this.getElement("shapes-root").setAttribute("style", ""); 2918 2919 setIgnoreLayoutChanges( 2920 false, 2921 this.highlighterEnv.window.document.documentElement 2922 ); 2923 } 2924 2925 onPageHide({ target }) { 2926 // If a page hide event is triggered for current window's highlighter, hide the 2927 // highlighter. 2928 if (target.defaultView === this.win) { 2929 this.hide(); 2930 } 2931 } 2932 2933 /** 2934 * Get the rough direction of the point relative to the anchor. 2935 * If the handle is roughly horizontal relative to the anchor, return "ew". 2936 * If the handle is roughly vertical relative to the anchor, return "ns" 2937 * If the handle is roughly above/right or below/left, return "nesw" 2938 * If the handle is roughly above/left or below/right, return "nwse" 2939 * 2940 * @param {string} pointName the name of the point being hovered 2941 * @param {string} anchor the name of the anchor point 2942 * @returns {string} The rough direction of the point relative to the anchor 2943 */ 2944 getRoughDirection(pointName, anchor) { 2945 const scalePoint = pointName.split("-")[1]; 2946 const anchorPos = this.transformedBoundingBox[anchor]; 2947 const scalePos = this.transformedBoundingBox[scalePoint]; 2948 const { minX, minY, maxX, maxY } = this.boundingBox; 2949 const width = maxX - minX; 2950 const height = maxY - minY; 2951 const dx = (scalePos[0] - anchorPos[0]) / width; 2952 const dy = (scalePos[1] - anchorPos[1]) / height; 2953 if (dx >= -0.33 && dx <= 0.33) { 2954 return "ns"; 2955 } else if (dy >= -0.33 && dy <= 0.33) { 2956 return "ew"; 2957 } else if ((dx > 0.33 && dy < -0.33) || (dx < -0.33 && dy > 0.33)) { 2958 return "nesw"; 2959 } 2960 return "nwse"; 2961 } 2962 2963 /** 2964 * Given a unit type, get the ratio by which to multiply a pixel value in order to 2965 * convert pixels to that unit. 2966 * 2967 * Percentage units (%) are relative to a size. This must be provided when requesting 2968 * a ratio for converting from pixels to percentages. 2969 * 2970 * @param {string} unit 2971 * One of: %, em, rem, vw, vh 2972 * @param {number} size 2973 * Size to which percentage values are relative to. 2974 * @return {number} 2975 */ 2976 getUnitToPixelRatio(unit, size) { 2977 let ratio; 2978 const windowHeight = this.currentNode.ownerGlobal.innerHeight; 2979 const windowWidth = this.currentNode.ownerGlobal.innerWidth; 2980 switch (unit) { 2981 case "%": 2982 ratio = 100 / size; 2983 break; 2984 case "em": 2985 ratio = 1 / parseFloat(getComputedStyle(this.currentNode).fontSize); 2986 break; 2987 case "rem": { 2988 const root = this.currentNode.ownerDocument.documentElement; 2989 ratio = 1 / parseFloat(getComputedStyle(root).fontSize); 2990 break; 2991 } 2992 case "vw": 2993 ratio = 100 / windowWidth; 2994 break; 2995 case "vh": 2996 ratio = 100 / windowHeight; 2997 break; 2998 case "vmin": 2999 ratio = 100 / Math.min(windowHeight, windowWidth); 3000 break; 3001 case "vmax": 3002 ratio = 100 / Math.max(windowHeight, windowWidth); 3003 break; 3004 default: 3005 // If unit is not recognized, peg ratio 1:1 to pixels. 3006 ratio = 1; 3007 } 3008 3009 return ratio; 3010 } 3011 } 3012 3013 /** 3014 * Get the "raw" (i.e. non-computed) shape definition on the given node. 3015 * 3016 * @param {Node} node the node to analyze 3017 * @param {string} property the CSS property for which a value should be retrieved. 3018 * @returns {string} the value of the given CSS property on the given node. 3019 */ 3020 function getDefinedShapeProperties(node, property) { 3021 let prop = ""; 3022 if (!node) { 3023 return prop; 3024 } 3025 3026 const cssRules = getMatchingCSSRules(node); 3027 for (let i = 0; i < cssRules.length; i++) { 3028 const rule = cssRules[i]; 3029 const value = rule.style.getPropertyValue(property); 3030 if (value && value !== "auto") { 3031 prop = value; 3032 } 3033 } 3034 3035 if (node.style) { 3036 const value = node.style.getPropertyValue(property); 3037 if (value && value !== "auto") { 3038 prop = value; 3039 } 3040 } 3041 3042 return prop.trim(); 3043 } 3044 3045 /** 3046 * Split coordinate pairs separated by a space and return an array. 3047 * 3048 * @param {string} coords the coordinate pair, where each coord is separated by a space. 3049 * @returns {Array} a 2 element array containing the coordinates. 3050 */ 3051 function splitCoords(coords) { 3052 // All coordinate pairs are of the form "x y" where x and y are values or 3053 // calc() expressions. calc() expressions have spaces around operators, so 3054 // replace those spaces with \u00a0 (non-breaking space) so they will not be 3055 // split later. 3056 return coords 3057 .trim() 3058 .replace(/ [\+\-\*\/] /g, match => { 3059 return `\u00a0${match.trim()}\u00a0`; 3060 }) 3061 .split(" "); 3062 } 3063 exports.splitCoords = splitCoords; 3064 3065 /** 3066 * Convert a coordinate to a percentage value. 3067 * 3068 * @param {string} coord a single coordinate 3069 * @param {number} size the size of the element (width or height) that the percentages 3070 * are relative to 3071 * @returns {number} the coordinate as a percentage value 3072 */ 3073 function coordToPercent(coord, size) { 3074 if (coord.includes("%")) { 3075 // Just remove the % sign, nothing else to do, we're in a viewBox that's 100% 3076 // worth. 3077 return parseFloat(coord.replace("%", "")); 3078 } else if (coord.includes("px")) { 3079 // Convert the px value to a % value. 3080 const px = parseFloat(coord.replace("px", "")); 3081 return (px * 100) / size; 3082 } 3083 3084 // Unit-less value, so 0. 3085 return 0; 3086 } 3087 exports.coordToPercent = coordToPercent; 3088 3089 /** 3090 * Evaluates a CSS calc() expression (only handles addition) 3091 * 3092 * @param {string} expression the arguments to the calc() function 3093 * @param {number} size the size of the element (width or height) that percentage values 3094 * are relative to 3095 * @returns {number} the result of the expression as a percentage value 3096 */ 3097 function evalCalcExpression(expression, size) { 3098 // the calc() values returned by getComputedStyle only have addition, as it 3099 // computes calc() expressions as much as possible without resolving percentages, 3100 // leaving only addition. 3101 const values = expression.split("+").map(v => v.trim()); 3102 3103 return values.reduce((prev, curr) => { 3104 return prev + coordToPercent(curr, size); 3105 }, 0); 3106 } 3107 exports.evalCalcExpression = evalCalcExpression; 3108 3109 /** 3110 * Converts a shape mode to the proper CSS property name. 3111 * 3112 * @param {string} mode the mode of the CSS shape 3113 * @returns the equivalent CSS property name 3114 */ 3115 const shapeModeToCssPropertyName = mode => { 3116 const property = mode.substring(3); 3117 return property.substring(0, 1).toLowerCase() + property.substring(1); 3118 }; 3119 exports.shapeModeToCssPropertyName = shapeModeToCssPropertyName; 3120 3121 /** 3122 * Get the SVG path definition for a circle with given attributes. 3123 * 3124 * @param {number} size the radius of the circle in pixels 3125 * @param {number} cx the x coordinate of the centre of the circle 3126 * @param {number} cy the y coordinate of the centre of the circle 3127 * @param {number} width the width of the element the circle is being drawn for 3128 * @param {number} height the height of the element the circle is being drawn for 3129 * @param {number} zoom the zoom level of the window the circle is drawn in 3130 * @returns {string} the definition of the circle in SVG path description format. 3131 */ 3132 const getCirclePath = (size, cx, cy, width, height, zoom) => { 3133 // We use a viewBox of 100x100 for shape-container so it's easy to position things 3134 // based on their percentage, but this makes it more difficult to create circles. 3135 // Therefor, 100px is the base size of shape-container. In order to make the markers' 3136 // size scale properly, we must adjust the radius based on zoom and the width/height of 3137 // the element being highlighted, then calculate a radius for both x/y axes based 3138 // on the aspect ratio of the element. 3139 const radius = (size * (100 / Math.max(width, height))) / zoom; 3140 const ratio = width / height; 3141 const rx = ratio > 1 ? radius : radius / ratio; 3142 const ry = ratio > 1 ? radius * ratio : radius; 3143 // a circle is drawn as two arc lines, starting at the leftmost point of the circle. 3144 return ( 3145 `M${cx - rx},${cy}a${rx},${ry} 0 1,0 ${rx * 2},0` + 3146 `a${rx},${ry} 0 1,0 ${rx * -2},0` 3147 ); 3148 }; 3149 exports.getCirclePath = getCirclePath; 3150 3151 /** 3152 * Calculates the object bounding box for a node given its stroke bounding box. 3153 * 3154 * @param {number} top the y coord of the top edge of the stroke bounding box 3155 * @param {number} left the x coord of the left edge of the stroke bounding box 3156 * @param {number} width the width of the stroke bounding box 3157 * @param {number} height the height of the stroke bounding box 3158 * @param {object} node the node object 3159 * @returns {object} an object of the form { top, left, width, height }, which 3160 * are the top/left/width/height of the object bounding box for the node. 3161 */ 3162 const getObjectBoundingBox = (top, left, width, height, node) => { 3163 // See https://drafts.fxtf.org/css-masking-1/#stroke-bounding-box for details 3164 // on this algorithm. Note that we intentionally do not check "stroke-linecap". 3165 const strokeWidth = parseFloat(getComputedStyle(node).strokeWidth); 3166 let delta = strokeWidth / 2; 3167 const tagName = node.tagName; 3168 3169 if ( 3170 tagName !== "rect" && 3171 tagName !== "ellipse" && 3172 tagName !== "circle" && 3173 tagName !== "image" 3174 ) { 3175 if (getComputedStyle(node).strokeLinejoin === "miter") { 3176 const miter = getComputedStyle(node).strokeMiterlimit; 3177 if (miter < Math.SQRT2) { 3178 delta *= Math.SQRT2; 3179 } else { 3180 delta *= miter; 3181 } 3182 } else { 3183 delta *= Math.SQRT2; 3184 } 3185 } 3186 3187 return { 3188 top: top + delta, 3189 left: left + delta, 3190 width: width - 2 * delta, 3191 height: height - 2 * delta, 3192 }; 3193 }; 3194 3195 /** 3196 * Get the unit (e.g. px, %, em) for the given point value. 3197 * 3198 * @param {any} point a point value for which a unit should be retrieved. 3199 * @returns {string} the unit. 3200 */ 3201 const getUnit = point => { 3202 // If the point has no unit, default to px. 3203 if (isUnitless(point)) { 3204 return "px"; 3205 } 3206 const [unit] = point.match(/[^\d]+$/) || ["px"]; 3207 return unit; 3208 }; 3209 exports.getUnit = getUnit; 3210 3211 /** 3212 * Check if the given point value has a unit. 3213 * 3214 * @param {any} point a point value. 3215 * @returns {boolean} whether the given value has a unit. 3216 */ 3217 const isUnitless = point => { 3218 return ( 3219 !point || 3220 !point.match(/[^\d]+$/) || 3221 // If zero doesn't have a unit, its numeric and string forms should be equal. 3222 (parseFloat(point) === 0 && parseFloat(point).toString() === point) || 3223 point.includes("(") || 3224 point === "center" || 3225 point === "closest-side" || 3226 point === "farthest-side" 3227 ); 3228 }; 3229 3230 /** 3231 * Return the anchor corresponding to the given scale type. 3232 * 3233 * @param {string} type a scale type, of form "scale-[direction]" 3234 * @returns {string} a string describing the anchor, one of the 8 cardinal directions. 3235 */ 3236 const getAnchorPoint = type => { 3237 let anchor = type.split("-")[1]; 3238 if (anchor.includes("n")) { 3239 anchor = anchor.replace("n", "s"); 3240 } else if (anchor.includes("s")) { 3241 anchor = anchor.replace("s", "n"); 3242 } 3243 if (anchor.includes("w")) { 3244 anchor = anchor.replace("w", "e"); 3245 } else if (anchor.includes("e")) { 3246 anchor = anchor.replace("e", "w"); 3247 } 3248 3249 if (anchor === "e" || anchor === "w") { 3250 anchor = "n" + anchor; 3251 } else if (anchor === "n" || anchor === "s") { 3252 anchor = anchor + "w"; 3253 } 3254 3255 return anchor; 3256 }; 3257 3258 /** 3259 * Get the decimal point precision for values depending on unit type. 3260 * Only handle pixels and falsy values for now. Round them to the nearest integer value. 3261 * All other unit types round to two decimal points. 3262 * 3263 * @param {string | undefined} unitType any one of the accepted CSS unit types for position. 3264 * @return {number} decimal precision when rounding a value 3265 */ 3266 function getDecimalPrecision(unitType) { 3267 switch (unitType) { 3268 case "px": 3269 case "": 3270 case undefined: 3271 return 0; 3272 default: 3273 return 2; 3274 } 3275 } 3276 exports.getDecimalPrecision = getDecimalPrecision; 3277 3278 /** 3279 * Round up a numeric value to a fixed number of decimals depending on CSS unit type. 3280 * Used when generating output shape values when: 3281 * - transforming shapes 3282 * - inserting new points on a polygon. 3283 * 3284 * @param {number} number 3285 * Value to round up. 3286 * @param {string} unitType 3287 * CSS unit type, like "px", "%", "em", "vh", etc. 3288 * @return {number} 3289 * Rounded value 3290 */ 3291 function round(number, unitType) { 3292 return number.toFixed(getDecimalPrecision(unitType)); 3293 } 3294 3295 exports.ShapesHighlighter = ShapesHighlighter;