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;