tor-browser

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

geometry-editor.js (24031B)


      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  AutoRefreshHighlighter,
      9 } = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
     10 const {
     11  CanvasFrameAnonymousContentHelper,
     12  getComputedStyle,
     13 } = require("resource://devtools/server/actors/highlighters/utils/markup.js");
     14 const {
     15  setIgnoreLayoutChanges,
     16  getAdjustedQuads,
     17  getCurrentZoom,
     18 } = require("resource://devtools/shared/layout/utils.js");
     19 const {
     20  getMatchingCSSRules,
     21 } = require("resource://devtools/shared/inspector/css-logic.js");
     22 
     23 const GEOMETRY_LABEL_SIZE = 6;
     24 
     25 // List of all DOM Events subscribed directly to the document from the
     26 // Geometry Editor highlighter
     27 const DOM_EVENTS = ["mousemove", "mouseup", "pagehide"];
     28 
     29 const _dragging = Symbol("geometry/dragging");
     30 
     31 /**
     32 * Element geometry properties helper that gives names of position and size
     33 * properties.
     34 */
     35 var GeoProp = {
     36  SIDES: ["top", "right", "bottom", "left"],
     37  SIZES: ["width", "height"],
     38 
     39  allProps() {
     40    return [...this.SIDES, ...this.SIZES];
     41  },
     42 
     43  isSide(name) {
     44    return this.SIDES.includes(name);
     45  },
     46 
     47  isSize(name) {
     48    return this.SIZES.includes(name);
     49  },
     50 
     51  containsSide(names) {
     52    return names.some(name => this.SIDES.includes(name));
     53  },
     54 
     55  containsSize(names) {
     56    return names.some(name => this.SIZES.includes(name));
     57  },
     58 
     59  isHorizontal(name) {
     60    return name === "left" || name === "right" || name === "width";
     61  },
     62 
     63  isInverted(name) {
     64    return name === "right" || name === "bottom";
     65  },
     66 
     67  mainAxisStart(name) {
     68    return this.isHorizontal(name) ? "left" : "top";
     69  },
     70 
     71  crossAxisStart(name) {
     72    return this.isHorizontal(name) ? "top" : "left";
     73  },
     74 
     75  mainAxisSize(name) {
     76    return this.isHorizontal(name) ? "width" : "height";
     77  },
     78 
     79  crossAxisSize(name) {
     80    return this.isHorizontal(name) ? "height" : "width";
     81  },
     82 
     83  axis(name) {
     84    return this.isHorizontal(name) ? "x" : "y";
     85  },
     86 
     87  crossAxis(name) {
     88    return this.isHorizontal(name) ? "y" : "x";
     89  },
     90 };
     91 
     92 /**
     93 * Get the provided node's offsetParent dimensions.
     94 * Returns an object with the {parent, dimension} properties.
     95 * Note that the returned parent will be null if the offsetParent is the
     96 * default, non-positioned, body or html node.
     97 *
     98 * node.offsetParent returns the nearest positioned ancestor but if it is
     99 * non-positioned itself, we just return null to let consumers know the node is
    100 * actually positioned relative to the viewport.
    101 *
    102 * @return {object}
    103 */
    104 function getOffsetParent(node) {
    105  const win = node.ownerGlobal;
    106 
    107  let offsetParent = node.offsetParent;
    108  if (offsetParent && getComputedStyle(offsetParent).position === "static") {
    109    offsetParent = null;
    110  }
    111 
    112  let width, height;
    113  if (!offsetParent) {
    114    height = win.innerHeight;
    115    width = win.innerWidth;
    116  } else {
    117    height = offsetParent.offsetHeight;
    118    width = offsetParent.offsetWidth;
    119  }
    120 
    121  return {
    122    element: offsetParent,
    123    dimension: { width, height },
    124  };
    125 }
    126 
    127 /**
    128 * Get the list of geometry properties that are actually set on the provided
    129 * node.
    130 *
    131 * @param {Node} node The node to analyze.
    132 * @return {Map} A map indexed by property name and where the value is an
    133 * object having the cssRule property.
    134 */
    135 function getDefinedGeometryProperties(node) {
    136  const props = new Map();
    137  if (!node) {
    138    return props;
    139  }
    140 
    141  // Get the list of css rules applying to the current node.
    142  const cssRules = getMatchingCSSRules(node);
    143  for (let i = 0; i < cssRules.length; i++) {
    144    const rule = cssRules[i];
    145    for (const name of GeoProp.allProps()) {
    146      const value = rule.style.getPropertyValue(name);
    147      if (value && value !== "auto") {
    148        // getMatchingCSSRules returns rules ordered from least to most specific
    149        // so just override any previous properties we have set.
    150        props.set(name, {
    151          cssRule: rule,
    152        });
    153      }
    154    }
    155  }
    156 
    157  // Go through the inline styles last, only if the node supports inline style
    158  // (e.g. pseudo elements don't have a style property)
    159  if (node.style) {
    160    for (const name of GeoProp.allProps()) {
    161      const value = node.style.getPropertyValue(name);
    162      if (value && value !== "auto") {
    163        props.set(name, {
    164          // There's no cssRule to store here, so store the node instead since
    165          // node.style exists.
    166          cssRule: node,
    167        });
    168      }
    169    }
    170  }
    171 
    172  // Post-process the list for invalid properties. This is done after the fact
    173  // because of cases like relative positioning with both top and bottom where
    174  // only top will actually be used, but both exists in css rules and computed
    175  // styles.
    176  const { position } = getComputedStyle(node);
    177  for (const [name] of props) {
    178    // Top/left/bottom/right on static positioned elements have no effect.
    179    if (position === "static" && GeoProp.SIDES.includes(name)) {
    180      props.delete(name);
    181    }
    182 
    183    // Bottom/right on relative positioned elements are only used if top/left
    184    // are not defined.
    185    const hasRightAndLeft = name === "right" && props.has("left");
    186    const hasBottomAndTop = name === "bottom" && props.has("top");
    187    if (position === "relative" && (hasRightAndLeft || hasBottomAndTop)) {
    188      props.delete(name);
    189    }
    190  }
    191 
    192  return props;
    193 }
    194 exports.getDefinedGeometryProperties = getDefinedGeometryProperties;
    195 
    196 /**
    197 * The GeometryEditor highlights an elements's top, left, bottom, right, width
    198 * and height dimensions, when they are set.
    199 *
    200 * To determine if an element has a set size and position, the highlighter lists
    201 * the CSS rules that apply to the element and checks for the top, left, bottom,
    202 * right, width and height properties.
    203 * The highlighter won't be shown if the element doesn't have any of these
    204 * properties set, but will be shown when at least 1 property is defined.
    205 *
    206 * The highlighter displays lines and labels for each of the defined properties
    207 * in and around the element (relative to the offset parent when one exists).
    208 * The highlighter also highlights the element itself and its offset parent if
    209 * there is one.
    210 *
    211 * Note that the class name contains the word Editor because the aim is for the
    212 * handles to be draggable in content to make the geometry editable.
    213 */
    214 class GeometryEditorHighlighter extends AutoRefreshHighlighter {
    215  constructor(highlighterEnv) {
    216    super(highlighterEnv);
    217 
    218    // The list of element geometry properties that can be set.
    219    this.definedProperties = new Map();
    220 
    221    this.markup = new CanvasFrameAnonymousContentHelper(
    222      highlighterEnv,
    223      this._buildMarkup.bind(this),
    224      {
    225        contentRootHostClassName: "devtools-highlighter-geometry-editor",
    226      }
    227    );
    228    this.isReady = this.initialize();
    229 
    230    const { pageListenerTarget } = this.highlighterEnv;
    231 
    232    // Register the geometry editor instance to all events we're interested in.
    233    DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this));
    234 
    235    this.onWillNavigate = this.onWillNavigate.bind(this);
    236 
    237    this.highlighterEnv.on("will-navigate", this.onWillNavigate);
    238  }
    239 
    240  async initialize() {
    241    await this.markup.initialize();
    242    // Register the mousedown event for each Geometry Editor's handler.
    243    // Those events are automatically removed when the markup is destroyed.
    244    const onMouseDown = this.handleEvent.bind(this);
    245 
    246    for (const side of GeoProp.SIDES) {
    247      this.getElement("geometry-editor-handler-" + side).addEventListener(
    248        "mousedown",
    249        onMouseDown
    250      );
    251    }
    252  }
    253 
    254  _buildMarkup() {
    255    const container = this.markup.createNode({
    256      attributes: { class: "highlighter-container" },
    257    });
    258 
    259    this.rootEl = this.markup.createNode({
    260      parent: container,
    261      attributes: {
    262        id: "geometry-editor-root",
    263        class: "geometry-editor-root",
    264        hidden: "true",
    265      },
    266    });
    267 
    268    const svg = this.markup.createSVGNode({
    269      nodeType: "svg",
    270      parent: this.rootEl,
    271      attributes: {
    272        id: "geometry-editor-elements",
    273        width: "100%",
    274        height: "100%",
    275      },
    276    });
    277 
    278    // Offset parent node highlighter.
    279    this.markup.createSVGNode({
    280      nodeType: "polygon",
    281      parent: svg,
    282      attributes: {
    283        class: "geometry-editor-offset-parent",
    284        id: "geometry-editor-offset-parent",
    285        hidden: "true",
    286      },
    287    });
    288 
    289    // Current node highlighter (margin box).
    290    this.markup.createSVGNode({
    291      nodeType: "polygon",
    292      parent: svg,
    293      attributes: {
    294        class: "geometry-editor-current-node",
    295        id: "geometry-editor-current-node",
    296        hidden: "true",
    297      },
    298    });
    299 
    300    // Build the 4 side arrows, handlers and labels.
    301    for (const name of GeoProp.SIDES) {
    302      this.markup.createSVGNode({
    303        nodeType: "line",
    304        parent: svg,
    305        attributes: {
    306          class: "geometry-editor-arrow " + name,
    307          id: "geometry-editor-arrow-" + name,
    308          hidden: "true",
    309        },
    310      });
    311 
    312      this.markup.createSVGNode({
    313        nodeType: "circle",
    314        parent: svg,
    315        attributes: {
    316          class: "geometry-editor-handler-" + name,
    317          id: "geometry-editor-handler-" + name,
    318          r: "4",
    319          "data-side": name,
    320          hidden: "true",
    321        },
    322      });
    323 
    324      // Labels are positioned by using a translated <g>. This group contains
    325      // a path and text that are themselves positioned using another translated
    326      // <g>. This is so that the label arrow points at the 0,0 coordinates of
    327      // parent <g>.
    328      const labelG = this.markup.createSVGNode({
    329        nodeType: "g",
    330        parent: svg,
    331        attributes: {
    332          id: "geometry-editor-label-" + name,
    333          hidden: "true",
    334        },
    335      });
    336 
    337      const subG = this.markup.createSVGNode({
    338        nodeType: "g",
    339        parent: labelG,
    340        attributes: {
    341          transform: GeoProp.isHorizontal(name)
    342            ? "translate(-30 -30)"
    343            : "translate(5 -10)",
    344        },
    345      });
    346 
    347      this.markup.createSVGNode({
    348        nodeType: "path",
    349        parent: subG,
    350        attributes: {
    351          class: "geometry-editor-label-bubble",
    352          d: GeoProp.isHorizontal(name)
    353            ? "M0 0 L60 0 L60 20 L35 20 L30 25 L25 20 L0 20z"
    354            : "M5 0 L65 0 L65 20 L5 20 L5 15 L0 10 L5 5z",
    355        },
    356      });
    357 
    358      this.markup.createSVGNode({
    359        nodeType: "text",
    360        parent: subG,
    361        attributes: {
    362          class: "geometry-editor-label-text",
    363          id: "geometry-editor-label-text-" + name,
    364          x: GeoProp.isHorizontal(name) ? "30" : "35",
    365          y: "10",
    366        },
    367      });
    368    }
    369 
    370    return container;
    371  }
    372 
    373  destroy() {
    374    // Avoiding exceptions if `destroy` is called multiple times; and / or the
    375    // highlighter environment was already destroyed.
    376    if (!this.highlighterEnv) {
    377      return;
    378    }
    379 
    380    const { pageListenerTarget } = this.highlighterEnv;
    381 
    382    if (pageListenerTarget) {
    383      DOM_EVENTS.forEach(type =>
    384        pageListenerTarget.removeEventListener(type, this)
    385      );
    386    }
    387 
    388    AutoRefreshHighlighter.prototype.destroy.call(this);
    389 
    390    this.markup.destroy();
    391    this.rootEl = null;
    392 
    393    this.definedProperties.clear();
    394    this.definedProperties = null;
    395    this.offsetParent = null;
    396  }
    397 
    398  handleEvent(event, id) {
    399    // No event handling if the highlighter is hidden
    400    if (this.getElement("geometry-editor-root").hasAttribute("hidden")) {
    401      return;
    402    }
    403 
    404    const { target, type, pageX, pageY } = event;
    405 
    406    switch (type) {
    407      case "pagehide":
    408        // If a page hide event is triggered for current window's highlighter, hide the
    409        // highlighter.
    410        if (target.defaultView === this.win) {
    411          this.destroy();
    412        }
    413 
    414        break;
    415      case "mousedown": {
    416        // The mousedown event is intended only for the handler
    417        if (!id) {
    418          return;
    419        }
    420 
    421        const handlerSide = this.markup
    422          .getElement(id)
    423          .getAttribute("data-side");
    424 
    425        if (handlerSide) {
    426          const side = handlerSide;
    427          const sideProp = this.definedProperties.get(side);
    428 
    429          if (!sideProp) {
    430            return;
    431          }
    432 
    433          let value = sideProp.cssRule.style.getPropertyValue(side);
    434          const computedValue = this.computedStyle.getPropertyValue(side);
    435 
    436          const [unit] = value.match(/[^\d]+$/) || [""];
    437 
    438          value = parseFloat(value);
    439 
    440          const ratio = value / parseFloat(computedValue) || 1;
    441          const dir = GeoProp.isInverted(side) ? -1 : 1;
    442 
    443          // Store all the initial values needed for drag & drop
    444          this[_dragging] = {
    445            side,
    446            value,
    447            unit,
    448            x: pageX,
    449            y: pageY,
    450            inc: ratio * dir,
    451          };
    452 
    453          this.getElement("geometry-editor-handler-" + side).classList?.add(
    454            "dragging"
    455          );
    456        }
    457 
    458        this.getElement("geometry-editor-root").setAttribute(
    459          "dragging",
    460          "true"
    461        );
    462        break;
    463      }
    464      case "mouseup":
    465        // If we're dragging, drop it.
    466        if (this[_dragging]) {
    467          const { side } = this[_dragging];
    468          this.getElement("geometry-editor-root").removeAttribute("dragging");
    469          this.getElement("geometry-editor-handler-" + side).classList?.remove(
    470            "dragging"
    471          );
    472          this[_dragging] = null;
    473        }
    474        break;
    475      case "mousemove": {
    476        if (!this[_dragging]) {
    477          return;
    478        }
    479 
    480        const { side, x, y, value, unit, inc } = this[_dragging];
    481        const sideProps = this.definedProperties.get(side);
    482 
    483        if (!sideProps) {
    484          return;
    485        }
    486 
    487        const delta =
    488          (GeoProp.isHorizontal(side) ? pageX - x : pageY - y) * inc;
    489 
    490        // The inline style has usually the priority over any other CSS rule
    491        // set in stylesheets. However, if a rule has `!important` keyword,
    492        // it will override the inline style too. To ensure Geometry Editor
    493        // will always update the element, we have to add `!important` as
    494        // well.
    495        this.currentNode.style.setProperty(
    496          side,
    497          value + delta + unit,
    498          "important"
    499        );
    500 
    501        break;
    502      }
    503    }
    504  }
    505 
    506  getElement(id) {
    507    return this.markup.getElement(id);
    508  }
    509 
    510  _show() {
    511    this.computedStyle = getComputedStyle(this.currentNode);
    512    const pos = this.computedStyle.position;
    513    // XXX: sticky positioning is ignored for now. To be implemented next.
    514    if (pos === "sticky") {
    515      this.hide();
    516      return false;
    517    }
    518 
    519    const hasUpdated = this._update();
    520    if (!hasUpdated) {
    521      this.hide();
    522      return false;
    523    }
    524 
    525    this.getElement("geometry-editor-root").removeAttribute("hidden");
    526 
    527    return true;
    528  }
    529 
    530  _update() {
    531    // At each update, the position or/and size may have changed, so get the
    532    // list of defined properties, and re-position the arrows and highlighters.
    533    this.definedProperties = getDefinedGeometryProperties(this.currentNode);
    534    // We need the zoom factor to fix the original position of the node
    535    // as well as the arrows.
    536    this.zoomFactor = getCurrentZoom(this.currentNode);
    537 
    538    if (!this.definedProperties.size) {
    539      console.warn("The element does not have editable geometry properties");
    540      return false;
    541    }
    542 
    543    setIgnoreLayoutChanges(true);
    544 
    545    // Update the highlighters and arrows.
    546    this.updateOffsetParent();
    547    this.updateCurrentNode();
    548    this.updateArrows();
    549 
    550    // Avoid zooming the arrows when content is zoomed.
    551    const node = this.currentNode;
    552    this.markup.scaleRootElement(node, "geometry-editor-root");
    553 
    554    setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
    555    return true;
    556  }
    557 
    558  /**
    559   * Update the offset parent rectangle.
    560   * There are 3 different cases covered here:
    561   * - the node is absolutely/fixed positioned, and an offsetParent is defined
    562   *   (i.e. it's not just positioned in the viewport): the offsetParent node
    563   *   is highlighted (i.e. the rectangle is shown),
    564   * - the node is relatively positioned: the rectangle is shown where the node
    565   *   would originally have been (because that's where the relative positioning
    566   *   is calculated from),
    567   * - the node has no offset parent at all: the offsetParent rectangle is
    568   *   hidden.
    569   */
    570  updateOffsetParent() {
    571    // Get the offsetParent, if any.
    572    this.offsetParent = getOffsetParent(this.currentNode);
    573    // And the offsetParent quads.
    574    this.parentQuads = getAdjustedQuads(
    575      this.win,
    576      this.offsetParent.element,
    577      "padding"
    578    );
    579 
    580    const el = this.getElement("geometry-editor-offset-parent");
    581 
    582    const isPositioned =
    583      this.computedStyle.position === "absolute" ||
    584      this.computedStyle.position === "fixed";
    585    const isRelative = this.computedStyle.position === "relative";
    586    let isHighlighted = false;
    587 
    588    if (this.offsetParent.element && isPositioned) {
    589      const { p1, p2, p3, p4 } = this.parentQuads[0];
    590      const points =
    591        p1.x +
    592        "," +
    593        p1.y +
    594        " " +
    595        p2.x +
    596        "," +
    597        p2.y +
    598        " " +
    599        p3.x +
    600        "," +
    601        p3.y +
    602        " " +
    603        p4.x +
    604        "," +
    605        p4.y;
    606      el.setAttribute("points", points);
    607      isHighlighted = true;
    608    } else if (isRelative) {
    609      const xDelta = parseFloat(this.computedStyle.left) * this.zoomFactor;
    610      const yDelta = parseFloat(this.computedStyle.top) * this.zoomFactor;
    611      if (xDelta || yDelta) {
    612        const { p1, p2, p3, p4 } = this.currentQuads.margin[0];
    613        const points =
    614          p1.x -
    615          xDelta +
    616          "," +
    617          (p1.y - yDelta) +
    618          " " +
    619          (p2.x - xDelta) +
    620          "," +
    621          (p2.y - yDelta) +
    622          " " +
    623          (p3.x - xDelta) +
    624          "," +
    625          (p3.y - yDelta) +
    626          " " +
    627          (p4.x - xDelta) +
    628          "," +
    629          (p4.y - yDelta);
    630        el.setAttribute("points", points);
    631        isHighlighted = true;
    632      }
    633    }
    634 
    635    if (isHighlighted) {
    636      el.removeAttribute("hidden");
    637    } else {
    638      el.setAttribute("hidden", "true");
    639    }
    640  }
    641 
    642  updateCurrentNode() {
    643    const box = this.getElement("geometry-editor-current-node");
    644    const { p1, p2, p3, p4 } = this.currentQuads.margin[0];
    645    const attr =
    646      p1.x +
    647      "," +
    648      p1.y +
    649      " " +
    650      p2.x +
    651      "," +
    652      p2.y +
    653      " " +
    654      p3.x +
    655      "," +
    656      p3.y +
    657      " " +
    658      p4.x +
    659      "," +
    660      p4.y;
    661    box.setAttribute("points", attr);
    662    box.removeAttribute("hidden");
    663  }
    664 
    665  _hide() {
    666    setIgnoreLayoutChanges(true);
    667 
    668    this.getElement("geometry-editor-root").setAttribute("hidden", "true");
    669    this.getElement("geometry-editor-current-node").setAttribute(
    670      "hidden",
    671      "true"
    672    );
    673    this.getElement("geometry-editor-offset-parent").setAttribute(
    674      "hidden",
    675      "true"
    676    );
    677    this.hideArrows();
    678 
    679    this.definedProperties.clear();
    680 
    681    setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
    682  }
    683 
    684  hideArrows() {
    685    for (const side of GeoProp.SIDES) {
    686      this.getElement("geometry-editor-arrow-" + side).setAttribute(
    687        "hidden",
    688        "true"
    689      );
    690      this.getElement("geometry-editor-label-" + side).setAttribute(
    691        "hidden",
    692        "true"
    693      );
    694      this.getElement("geometry-editor-handler-" + side).setAttribute(
    695        "hidden",
    696        "true"
    697      );
    698    }
    699  }
    700 
    701  updateArrows() {
    702    this.hideArrows();
    703 
    704    // Position arrows always end at the node's margin box.
    705    const marginBox = this.currentQuads.margin[0].bounds;
    706 
    707    // Position the side arrows which need to be visible.
    708    // Arrows always start at the offsetParent edge, and end at the middle
    709    // position of the node's margin edge.
    710    // Note that for relative positioning, the offsetParent is considered to be
    711    // the node itself, where it would have been originally.
    712    // +------------------+----------------+
    713    // | offsetparent     | top            |
    714    // | or viewport      |                |
    715    // |         +--------+--------+       |
    716    // |         | node            |       |
    717    // +---------+                 +-------+
    718    // | left    |                 | right |
    719    // |         +--------+--------+       |
    720    // |                  | bottom         |
    721    // +------------------+----------------+
    722    const getSideArrowStartPos = side => {
    723      // In case of relative positioning.
    724      if (this.computedStyle.position === "relative") {
    725        if (GeoProp.isInverted(side)) {
    726          return (
    727            marginBox[side] +
    728            parseFloat(this.computedStyle[side]) * this.zoomFactor
    729          );
    730        }
    731        return (
    732          marginBox[side] -
    733          parseFloat(this.computedStyle[side]) * this.zoomFactor
    734        );
    735      }
    736 
    737      // In case an offsetParent exists and is highlighted.
    738      if (this.parentQuads && this.parentQuads.length) {
    739        return this.parentQuads[0].bounds[side];
    740      }
    741 
    742      // In case the element is positioned in the viewport.
    743      if (GeoProp.isInverted(side)) {
    744        return this.offsetParent.dimension[GeoProp.mainAxisSize(side)];
    745      }
    746      return (
    747        -1 *
    748        this.currentNode.ownerGlobal[
    749          "scroll" + GeoProp.axis(side).toUpperCase()
    750        ]
    751      );
    752    };
    753 
    754    for (const side of GeoProp.SIDES) {
    755      const sideProp = this.definedProperties.get(side);
    756      if (!sideProp) {
    757        continue;
    758      }
    759 
    760      const mainAxisStartPos = getSideArrowStartPos(side);
    761      const mainAxisEndPos = marginBox[side];
    762      const crossAxisPos =
    763        marginBox[GeoProp.crossAxisStart(side)] +
    764        marginBox[GeoProp.crossAxisSize(side)] / 2;
    765 
    766      this.updateArrow(
    767        side,
    768        mainAxisStartPos,
    769        mainAxisEndPos,
    770        crossAxisPos,
    771        sideProp.cssRule.style.getPropertyValue(side)
    772      );
    773    }
    774  }
    775 
    776  updateArrow(side, mainStart, mainEnd, crossPos, labelValue) {
    777    const arrowEl = this.getElement("geometry-editor-arrow-" + side);
    778    const labelEl = this.getElement("geometry-editor-label-" + side);
    779    const labelTextEl = this.getElement("geometry-editor-label-text-" + side);
    780    const handlerEl = this.getElement("geometry-editor-handler-" + side);
    781 
    782    // Position the arrow <line>.
    783    arrowEl.setAttribute(GeoProp.axis(side) + "1", mainStart);
    784    arrowEl.setAttribute(GeoProp.crossAxis(side) + "1", crossPos);
    785    arrowEl.setAttribute(GeoProp.axis(side) + "2", mainEnd);
    786    arrowEl.setAttribute(GeoProp.crossAxis(side) + "2", crossPos);
    787    arrowEl.removeAttribute("hidden");
    788 
    789    handlerEl.setAttribute("c" + GeoProp.axis(side), mainEnd);
    790    handlerEl.setAttribute("c" + GeoProp.crossAxis(side), crossPos);
    791    handlerEl.removeAttribute("hidden");
    792 
    793    // Position the label <text> in the middle of the arrow (making sure it's
    794    // not hidden below the fold).
    795    const capitalize = str => str[0].toUpperCase() + str.substring(1);
    796    const winMain = this.win["inner" + capitalize(GeoProp.mainAxisSize(side))];
    797    let labelMain = mainStart + (mainEnd - mainStart) / 2;
    798    if (
    799      (mainStart > 0 && mainStart < winMain) ||
    800      (mainEnd > 0 && mainEnd < winMain)
    801    ) {
    802      if (labelMain < GEOMETRY_LABEL_SIZE) {
    803        labelMain = GEOMETRY_LABEL_SIZE;
    804      } else if (labelMain > winMain - GEOMETRY_LABEL_SIZE) {
    805        labelMain = winMain - GEOMETRY_LABEL_SIZE;
    806      }
    807    }
    808    const labelCross = crossPos;
    809    labelEl.setAttribute(
    810      "transform",
    811      GeoProp.isHorizontal(side)
    812        ? "translate(" + labelMain + " " + labelCross + ")"
    813        : "translate(" + labelCross + " " + labelMain + ")"
    814    );
    815    labelEl.removeAttribute("hidden");
    816    labelTextEl.setTextContent(labelValue);
    817  }
    818 
    819  onWillNavigate({ isTopLevel }) {
    820    if (isTopLevel) {
    821      this.hide();
    822    }
    823  }
    824 }
    825 
    826 exports.GeometryEditorHighlighter = GeometryEditorHighlighter;