tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 &nbsp; 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 &nbsp; 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 &nbsp; 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 &nbsp; 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;