BoxModelMain.js (22483B)
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 createFactory, 9 PureComponent, 10 } = require("resource://devtools/client/shared/vendor/react.mjs"); 11 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 12 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs"); 13 const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); 14 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 15 16 const BoxModelEditable = createFactory( 17 require("resource://devtools/client/inspector/boxmodel/components/BoxModelEditable.js") 18 ); 19 20 const Types = require("resource://devtools/client/inspector/boxmodel/types.js"); 21 22 const { 23 highlightSelectedNode, 24 unhighlightNode, 25 } = require("resource://devtools/client/inspector/boxmodel/actions/box-model-highlighter.js"); 26 27 const SHARED_STRINGS_URI = "devtools/client/locales/shared.properties"; 28 const SHARED_L10N = new LocalizationHelper(SHARED_STRINGS_URI); 29 30 class BoxModelMain extends PureComponent { 31 static get propTypes() { 32 return { 33 boxModel: PropTypes.shape(Types.boxModel).isRequired, 34 boxModelContainer: PropTypes.object, 35 dispatch: PropTypes.func.isRequired, 36 onShowBoxModelEditor: PropTypes.func.isRequired, 37 onShowRulePreviewTooltip: PropTypes.func.isRequired, 38 }; 39 } 40 41 constructor(props) { 42 super(props); 43 44 this.state = { 45 activeDescendant: null, 46 focusable: false, 47 }; 48 49 this.getActiveDescendant = this.getActiveDescendant.bind(this); 50 this.getBorderOrPaddingValue = this.getBorderOrPaddingValue.bind(this); 51 this.getContextBox = this.getContextBox.bind(this); 52 this.getDisplayPosition = this.getDisplayPosition.bind(this); 53 this.getHeightValue = this.getHeightValue.bind(this); 54 this.getMarginValue = this.getMarginValue.bind(this); 55 this.getPositionValue = this.getPositionValue.bind(this); 56 this.getWidthValue = this.getWidthValue.bind(this); 57 this.moveFocus = this.moveFocus.bind(this); 58 this.onHighlightMouseOver = this.onHighlightMouseOver.bind(this); 59 this.onKeyDown = this.onKeyDown.bind(this); 60 this.onLevelClick = this.onLevelClick.bind(this); 61 this.setActive = this.setActive.bind(this); 62 } 63 64 componentDidUpdate() { 65 const displayPosition = this.getDisplayPosition(); 66 const isContentBox = this.getContextBox(); 67 68 this.layouts = { 69 position: new Map([ 70 [KeyCodes.DOM_VK_ESCAPE, this.positionLayout], 71 [KeyCodes.DOM_VK_DOWN, this.marginLayout], 72 [KeyCodes.DOM_VK_RETURN, this.positionEditable], 73 [KeyCodes.DOM_VK_UP, null], 74 ["click", this.positionLayout], 75 ]), 76 margin: new Map([ 77 [KeyCodes.DOM_VK_ESCAPE, this.marginLayout], 78 [KeyCodes.DOM_VK_DOWN, this.borderLayout], 79 [KeyCodes.DOM_VK_RETURN, this.marginEditable], 80 [KeyCodes.DOM_VK_UP, displayPosition ? this.positionLayout : null], 81 ["click", this.marginLayout], 82 ]), 83 border: new Map([ 84 [KeyCodes.DOM_VK_ESCAPE, this.borderLayout], 85 [KeyCodes.DOM_VK_DOWN, this.paddingLayout], 86 [KeyCodes.DOM_VK_RETURN, this.borderEditable], 87 [KeyCodes.DOM_VK_UP, this.marginLayout], 88 ["click", this.borderLayout], 89 ]), 90 padding: new Map([ 91 [KeyCodes.DOM_VK_ESCAPE, this.paddingLayout], 92 [KeyCodes.DOM_VK_DOWN, isContentBox ? this.contentLayout : null], 93 [KeyCodes.DOM_VK_RETURN, this.paddingEditable], 94 [KeyCodes.DOM_VK_UP, this.borderLayout], 95 ["click", this.paddingLayout], 96 ]), 97 content: new Map([ 98 [KeyCodes.DOM_VK_ESCAPE, this.contentLayout], 99 [KeyCodes.DOM_VK_DOWN, null], 100 [KeyCodes.DOM_VK_RETURN, this.contentEditable], 101 [KeyCodes.DOM_VK_UP, this.paddingLayout], 102 ["click", this.contentLayout], 103 ]), 104 }; 105 } 106 107 getActiveDescendant() { 108 let { activeDescendant } = this.state; 109 110 if (!activeDescendant) { 111 const displayPosition = this.getDisplayPosition(); 112 const nextLayout = displayPosition 113 ? this.positionLayout 114 : this.marginLayout; 115 activeDescendant = nextLayout.getAttribute("data-box"); 116 this.setActive(nextLayout); 117 } 118 119 return activeDescendant; 120 } 121 122 getBorderOrPaddingValue(property) { 123 const { layout } = this.props.boxModel; 124 return layout[property] ? parseFloat(layout[property]) : "-"; 125 } 126 127 /** 128 * Returns true if the layout box sizing is context box and false otherwise. 129 */ 130 getContextBox() { 131 const { layout } = this.props.boxModel; 132 return layout["box-sizing"] == "content-box"; 133 } 134 135 /** 136 * Returns true if the position is displayed and false otherwise. 137 */ 138 getDisplayPosition() { 139 const { layout } = this.props.boxModel; 140 return layout.position && layout.position != "static"; 141 } 142 143 getHeightValue(property) { 144 if (property == undefined) { 145 return "-"; 146 } 147 148 const { layout } = this.props.boxModel; 149 150 property -= 151 parseFloat(layout["border-top-width"]) + 152 parseFloat(layout["border-bottom-width"]) + 153 parseFloat(layout["padding-top"]) + 154 parseFloat(layout["padding-bottom"]); 155 property = parseFloat(property.toPrecision(6)); 156 157 return property >= 0 ? property : "auto"; 158 } 159 160 getMarginValue(property, direction) { 161 const { layout } = this.props.boxModel; 162 const autoMargins = layout.autoMargins || {}; 163 let value = "-"; 164 165 if (direction in autoMargins) { 166 value = autoMargins[direction]; 167 } else if (layout[property]) { 168 const parsedValue = parseFloat(layout[property]); 169 170 if (Number.isNaN(parsedValue)) { 171 // Not a number. We use the raw string. 172 // Useful for pseudo-elements with auto margins since they 173 // don't appear in autoMargins. 174 value = layout[property]; 175 } else { 176 value = parsedValue; 177 } 178 } 179 180 return value; 181 } 182 183 getPositionValue(property) { 184 const { layout } = this.props.boxModel; 185 let value = "-"; 186 187 if (!layout[property]) { 188 return value; 189 } 190 191 const parsedValue = parseFloat(layout[property]); 192 193 if (Number.isNaN(parsedValue)) { 194 // Not a number. We use the raw string. 195 value = layout[property]; 196 } else { 197 value = parsedValue; 198 } 199 200 return value; 201 } 202 203 getWidthValue(property) { 204 if (property == undefined) { 205 return "-"; 206 } 207 208 const { layout } = this.props.boxModel; 209 210 property -= 211 parseFloat(layout["border-left-width"]) + 212 parseFloat(layout["border-right-width"]) + 213 parseFloat(layout["padding-left"]) + 214 parseFloat(layout["padding-right"]); 215 property = parseFloat(property.toPrecision(6)); 216 217 return property >= 0 ? property : "auto"; 218 } 219 220 /** 221 * Move the focus to the next/previous editable element of the current layout. 222 * 223 * @param {Element} target 224 * Node to be observed 225 * @param {boolean} shiftKey 226 * Determines if shiftKey was pressed 227 */ 228 moveFocus({ target, shiftKey }) { 229 const editBoxes = [ 230 ...this.positionLayout.querySelectorAll("[data-box].boxmodel-editable"), 231 ]; 232 const editingMode = target.tagName === "input"; 233 // target.nextSibling is input field 234 let position = editingMode 235 ? editBoxes.indexOf(target.nextSibling) 236 : editBoxes.indexOf(target); 237 238 if (position === editBoxes.length - 1 && !shiftKey) { 239 position = 0; 240 } else if (position === 0 && shiftKey) { 241 position = editBoxes.length - 1; 242 } else { 243 shiftKey ? position-- : position++; 244 } 245 246 const editBox = editBoxes[position]; 247 this.setActive(editBox); 248 editBox.focus(); 249 250 if (editingMode) { 251 editBox.click(); 252 } 253 } 254 255 /** 256 * Active level set to current layout. 257 * 258 * @param {Element} nextLayout 259 * Element of next layout that user has navigated to 260 */ 261 setActive(nextLayout) { 262 const { boxModelContainer } = this.props; 263 264 // We set this attribute for testing purposes. 265 if (boxModelContainer) { 266 boxModelContainer.dataset.activeDescendantClassName = 267 nextLayout.className; 268 } 269 270 this.setState({ 271 activeDescendant: nextLayout.getAttribute("data-box"), 272 }); 273 } 274 275 onHighlightMouseOver(event) { 276 let region = event.target.getAttribute("data-box"); 277 278 if (!region) { 279 let el = event.target; 280 281 do { 282 el = el.parentNode; 283 284 if (el && el.getAttribute("data-box")) { 285 region = el.getAttribute("data-box"); 286 break; 287 } 288 } while (el.parentNode); 289 290 this.props.dispatch(unhighlightNode()); 291 } 292 293 this.props.dispatch( 294 highlightSelectedNode({ 295 region, 296 showOnly: region, 297 onlyRegionArea: true, 298 }) 299 ); 300 301 event.preventDefault(); 302 } 303 304 /** 305 * Handle keyboard navigation and focus for box model layouts. 306 * 307 * Updates active layout on arrow key navigation 308 * Focuses next layout's editboxes on enter key 309 * Unfocuses current layout's editboxes when active layout changes 310 * Controls tabbing between editBoxes 311 * 312 * @param {Event} event 313 * The event triggered by a keypress on the box model 314 */ 315 onKeyDown(event) { 316 const { target, keyCode } = event; 317 const isEditable = target._editable || target.editor; 318 319 const level = this.getActiveDescendant(); 320 const editingMode = target.tagName === "input"; 321 322 switch (keyCode) { 323 case KeyCodes.DOM_VK_RETURN: 324 if (!isEditable) { 325 this.setState({ focusable: true }, () => { 326 const editableBox = this.layouts[level].get(keyCode); 327 if (editableBox) { 328 editableBox.boxModelEditable.focus(); 329 } 330 }); 331 } 332 break; 333 case KeyCodes.DOM_VK_DOWN: 334 case KeyCodes.DOM_VK_UP: 335 if (!editingMode) { 336 event.preventDefault(); 337 event.stopPropagation(); 338 this.setState({ focusable: false }, () => { 339 const nextLayout = this.layouts[level].get(keyCode); 340 341 if (!nextLayout) { 342 return; 343 } 344 345 this.setActive(nextLayout); 346 347 if (target?._editable) { 348 target.blur(); 349 } 350 351 this.props.boxModelContainer.focus(); 352 }); 353 } 354 break; 355 case KeyCodes.DOM_VK_TAB: 356 if (isEditable) { 357 event.preventDefault(); 358 this.moveFocus(event); 359 } 360 break; 361 case KeyCodes.DOM_VK_ESCAPE: 362 if (target._editable) { 363 event.preventDefault(); 364 event.stopPropagation(); 365 this.setState({ focusable: false }, () => { 366 this.props.boxModelContainer.focus(); 367 }); 368 } 369 break; 370 default: 371 break; 372 } 373 } 374 375 /** 376 * Update active on mouse click. 377 * 378 * @param {Event} event 379 * The event triggered by a mouse click on the box model 380 */ 381 onLevelClick(event) { 382 const { target } = event; 383 const displayPosition = this.getDisplayPosition(); 384 const isContentBox = this.getContextBox(); 385 386 // Avoid switching the active descendant to the position or content layout 387 // if those are not editable. 388 if ( 389 (!displayPosition && target == this.positionLayout) || 390 (!isContentBox && target == this.contentLayout) 391 ) { 392 return; 393 } 394 395 const nextLayout = 396 this.layouts[target.getAttribute("data-box")].get("click"); 397 this.setActive(nextLayout); 398 399 if (target?._editable) { 400 target.blur(); 401 } 402 } 403 404 render() { 405 const { 406 boxModel, 407 dispatch, 408 onShowBoxModelEditor, 409 onShowRulePreviewTooltip, 410 } = this.props; 411 const { layout } = boxModel; 412 let { height, width } = layout; 413 const { activeDescendant: level, focusable } = this.state; 414 415 const borderTop = this.getBorderOrPaddingValue("border-top-width"); 416 const borderRight = this.getBorderOrPaddingValue("border-right-width"); 417 const borderBottom = this.getBorderOrPaddingValue("border-bottom-width"); 418 const borderLeft = this.getBorderOrPaddingValue("border-left-width"); 419 420 const paddingTop = this.getBorderOrPaddingValue("padding-top"); 421 const paddingRight = this.getBorderOrPaddingValue("padding-right"); 422 const paddingBottom = this.getBorderOrPaddingValue("padding-bottom"); 423 const paddingLeft = this.getBorderOrPaddingValue("padding-left"); 424 425 const displayPosition = this.getDisplayPosition(); 426 const positionTop = this.getPositionValue("top"); 427 const positionRight = this.getPositionValue("right"); 428 const positionBottom = this.getPositionValue("bottom"); 429 const positionLeft = this.getPositionValue("left"); 430 431 const marginTop = this.getMarginValue("margin-top", "top"); 432 const marginRight = this.getMarginValue("margin-right", "right"); 433 const marginBottom = this.getMarginValue("margin-bottom", "bottom"); 434 const marginLeft = this.getMarginValue("margin-left", "left"); 435 436 height = this.getHeightValue(height); 437 width = this.getWidthValue(width); 438 439 const contentBox = 440 layout["box-sizing"] == "content-box" 441 ? dom.div( 442 { className: "boxmodel-size" }, 443 BoxModelEditable({ 444 box: "content", 445 focusable, 446 level, 447 property: "width", 448 ref: editable => { 449 this.contentEditable = editable; 450 }, 451 textContent: width, 452 onShowBoxModelEditor, 453 onShowRulePreviewTooltip, 454 }), 455 dom.span({}, "\u00D7"), 456 BoxModelEditable({ 457 box: "content", 458 focusable, 459 level, 460 property: "height", 461 textContent: height, 462 onShowBoxModelEditor, 463 onShowRulePreviewTooltip, 464 }) 465 ) 466 : dom.p( 467 { 468 className: "boxmodel-size", 469 id: "boxmodel-size-id", 470 }, 471 dom.span( 472 { title: "content" }, 473 SHARED_L10N.getFormatStr("dimensions", width, height) 474 ) 475 ); 476 477 return dom.div( 478 { 479 className: "boxmodel-main devtools-monospace", 480 "data-box": "position", 481 ref: div => { 482 this.positionLayout = div; 483 }, 484 onClick: this.onLevelClick, 485 onKeyDown: this.onKeyDown, 486 onMouseOver: this.onHighlightMouseOver, 487 onMouseOut: () => dispatch(unhighlightNode()), 488 }, 489 displayPosition 490 ? dom.span( 491 { 492 className: "boxmodel-legend", 493 "data-box": "position", 494 title: "position", 495 }, 496 "position" 497 ) 498 : null, 499 dom.div( 500 { className: "boxmodel-box" }, 501 dom.span( 502 { 503 className: "boxmodel-legend", 504 "data-box": "margin", 505 title: "margin", 506 role: "region", 507 "aria-level": "1", // margin, outermost box 508 "aria-owns": 509 "margin-top-id margin-right-id margin-bottom-id margin-left-id margins-div", 510 }, 511 "margin" 512 ), 513 dom.div( 514 { 515 className: "boxmodel-margins", 516 id: "margins-div", 517 "data-box": "margin", 518 title: "margin", 519 ref: div => { 520 this.marginLayout = div; 521 }, 522 }, 523 dom.span( 524 { 525 className: "boxmodel-legend", 526 "data-box": "border", 527 title: "border", 528 role: "region", 529 "aria-level": "2", // margin -> border, second box 530 "aria-owns": 531 "border-top-width-id border-right-width-id border-bottom-width-id border-left-width-id borders-div", 532 }, 533 "border" 534 ), 535 dom.div( 536 { 537 className: "boxmodel-borders", 538 id: "borders-div", 539 "data-box": "border", 540 title: "border", 541 ref: div => { 542 this.borderLayout = div; 543 }, 544 }, 545 dom.span( 546 { 547 className: "boxmodel-legend", 548 "data-box": "padding", 549 title: "padding", 550 role: "region", 551 "aria-level": "3", // margin -> border -> padding 552 "aria-owns": 553 "padding-top-id padding-right-id padding-bottom-id padding-left-id padding-div", 554 }, 555 "padding" 556 ), 557 dom.div( 558 { 559 className: "boxmodel-paddings", 560 id: "padding-div", 561 "data-box": "padding", 562 title: "padding", 563 "aria-owns": "boxmodel-contents-id", 564 ref: div => { 565 this.paddingLayout = div; 566 }, 567 }, 568 dom.div({ 569 className: "boxmodel-contents", 570 id: "boxmodel-contents-id", 571 "data-box": "content", 572 title: "content", 573 role: "region", 574 "aria-level": "4", // margin -> border -> padding -> content 575 "aria-label": SHARED_L10N.getFormatStr( 576 "boxModelSize.accessibleLabel", 577 width, 578 height 579 ), 580 "aria-owns": "boxmodel-size-id", 581 ref: div => { 582 this.contentLayout = div; 583 }, 584 }) 585 ) 586 ) 587 ) 588 ), 589 displayPosition 590 ? BoxModelEditable({ 591 box: "position", 592 direction: "top", 593 focusable, 594 level, 595 property: "position-top", 596 ref: editable => { 597 this.positionEditable = editable; 598 }, 599 textContent: positionTop, 600 onShowBoxModelEditor, 601 onShowRulePreviewTooltip, 602 }) 603 : null, 604 displayPosition 605 ? BoxModelEditable({ 606 box: "position", 607 direction: "right", 608 focusable, 609 level, 610 property: "position-right", 611 textContent: positionRight, 612 onShowBoxModelEditor, 613 onShowRulePreviewTooltip, 614 }) 615 : null, 616 displayPosition 617 ? BoxModelEditable({ 618 box: "position", 619 direction: "bottom", 620 focusable, 621 level, 622 property: "position-bottom", 623 textContent: positionBottom, 624 onShowBoxModelEditor, 625 onShowRulePreviewTooltip, 626 }) 627 : null, 628 displayPosition 629 ? BoxModelEditable({ 630 box: "position", 631 direction: "left", 632 focusable, 633 level, 634 property: "position-left", 635 textContent: positionLeft, 636 onShowBoxModelEditor, 637 onShowRulePreviewTooltip, 638 }) 639 : null, 640 BoxModelEditable({ 641 box: "margin", 642 direction: "top", 643 focusable, 644 level, 645 property: "margin-top", 646 ref: editable => { 647 this.marginEditable = editable; 648 }, 649 textContent: marginTop, 650 onShowBoxModelEditor, 651 onShowRulePreviewTooltip, 652 }), 653 BoxModelEditable({ 654 box: "margin", 655 direction: "right", 656 focusable, 657 level, 658 property: "margin-right", 659 textContent: marginRight, 660 onShowBoxModelEditor, 661 onShowRulePreviewTooltip, 662 }), 663 BoxModelEditable({ 664 box: "margin", 665 direction: "bottom", 666 focusable, 667 level, 668 property: "margin-bottom", 669 textContent: marginBottom, 670 onShowBoxModelEditor, 671 onShowRulePreviewTooltip, 672 }), 673 BoxModelEditable({ 674 box: "margin", 675 direction: "left", 676 focusable, 677 level, 678 property: "margin-left", 679 textContent: marginLeft, 680 onShowBoxModelEditor, 681 onShowRulePreviewTooltip, 682 }), 683 BoxModelEditable({ 684 box: "border", 685 direction: "top", 686 focusable, 687 level, 688 property: "border-top-width", 689 ref: editable => { 690 this.borderEditable = editable; 691 }, 692 textContent: borderTop, 693 onShowBoxModelEditor, 694 onShowRulePreviewTooltip, 695 }), 696 BoxModelEditable({ 697 box: "border", 698 direction: "right", 699 focusable, 700 level, 701 property: "border-right-width", 702 textContent: borderRight, 703 onShowBoxModelEditor, 704 onShowRulePreviewTooltip, 705 }), 706 BoxModelEditable({ 707 box: "border", 708 direction: "bottom", 709 focusable, 710 level, 711 property: "border-bottom-width", 712 textContent: borderBottom, 713 onShowBoxModelEditor, 714 onShowRulePreviewTooltip, 715 }), 716 BoxModelEditable({ 717 box: "border", 718 direction: "left", 719 focusable, 720 level, 721 property: "border-left-width", 722 textContent: borderLeft, 723 onShowBoxModelEditor, 724 onShowRulePreviewTooltip, 725 }), 726 BoxModelEditable({ 727 box: "padding", 728 direction: "top", 729 focusable, 730 level, 731 property: "padding-top", 732 ref: editable => { 733 this.paddingEditable = editable; 734 }, 735 textContent: paddingTop, 736 onShowBoxModelEditor, 737 onShowRulePreviewTooltip, 738 }), 739 BoxModelEditable({ 740 box: "padding", 741 direction: "right", 742 focusable, 743 level, 744 property: "padding-right", 745 textContent: paddingRight, 746 onShowBoxModelEditor, 747 onShowRulePreviewTooltip, 748 }), 749 BoxModelEditable({ 750 box: "padding", 751 direction: "bottom", 752 focusable, 753 level, 754 property: "padding-bottom", 755 textContent: paddingBottom, 756 onShowBoxModelEditor, 757 onShowRulePreviewTooltip, 758 }), 759 BoxModelEditable({ 760 box: "padding", 761 direction: "left", 762 focusable, 763 level, 764 property: "padding-left", 765 textContent: paddingLeft, 766 onShowBoxModelEditor, 767 onShowRulePreviewTooltip, 768 }), 769 contentBox 770 ); 771 } 772 } 773 774 module.exports = BoxModelMain;