profile-avatar-selector.mjs (27803B)
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 import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; 6 import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs"; 7 import { Region, ViewDimensions } from "./avatarSelectionHelpers.mjs"; 8 9 const AVATARS = [ 10 "barbell", 11 "bike", 12 "book", 13 "briefcase", 14 "canvas", 15 "craft", 16 "default-favicon", 17 "diamond", 18 "flower", 19 "folder", 20 "hammer", 21 "heart", 22 "heart-rate", 23 "history", 24 "leaf", 25 "lightbulb", 26 "makeup", 27 "message", 28 "musical-note", 29 "palette", 30 "paw-print", 31 "plane", 32 "present", 33 "shopping", 34 "soccer", 35 "sparkle-single", 36 "star", 37 "video-game-controller", 38 ]; 39 40 const AVATAR_TOOLTIP_IDS = { 41 barbell: "barbell-avatar-tooltip", 42 bike: "bike-avatar-tooltip", 43 book: "book-avatar-tooltip", 44 briefcase: "briefcase-avatar-tooltip", 45 canvas: "picture-avatar-tooltip", 46 craft: "craft-avatar-tooltip", 47 "default-favicon": "globe-avatar-tooltip", 48 diamond: "diamond-avatar-tooltip", 49 flower: "flower-avatar-tooltip", 50 folder: "folder-avatar-tooltip", 51 hammer: "hammer-avatar-tooltip", 52 heart: "heart-avatar-tooltip", 53 "heart-rate": "heart-rate-avatar-tooltip", 54 history: "clock-avatar-tooltip", 55 leaf: "leaf-avatar-tooltip", 56 lightbulb: "lightbulb-avatar-tooltip", 57 makeup: "makeup-avatar-tooltip", 58 message: "message-avatar-tooltip", 59 "musical-note": "musical-note-avatar-tooltip", 60 palette: "palette-avatar-tooltip", 61 "paw-print": "paw-print-avatar-tooltip", 62 plane: "plane-avatar-tooltip", 63 present: "present-avatar-tooltip", 64 shopping: "shopping-avatar-tooltip", 65 soccer: "soccer-ball-avatar-tooltip", 66 "sparkle-single": "sparkle-single-avatar-tooltip", 67 star: "star-avatar-tooltip", 68 "video-game-controller": "video-game-controller-avatar-tooltip", 69 }; 70 71 const VIEWS = { 72 ICON: "icon", 73 CUSTOM: "custom", 74 CROP: "crop", 75 }; 76 77 const STATES = { 78 SELECTED: "selected", 79 RESIZING: "resizing", 80 }; 81 82 const SCROLL_BY_EDGE = 20; 83 84 /** 85 * Element used for displaying an avatar on the about:editprofile and about:newprofile pages. 86 */ 87 export class ProfileAvatarSelector extends MozLitElement { 88 #moverId = ""; 89 90 static properties = { 91 value: { type: String }, 92 view: { type: String }, 93 state: { type: String }, 94 avatarLabels: { type: Object, state: true }, 95 }; 96 97 static queries = { 98 input: "#custom-image-input", 99 saveButton: "#save-button", 100 customAvatarCropArea: ".custom-avatar-crop-area", 101 customAvatarImage: "#custom-avatar-image", 102 avatarSelectionContainer: "#avatar-selection-container", 103 highlight: "#highlight", 104 iconTabButton: "#icon", 105 customTabButton: "#custom", 106 topLeftMover: "#mover-topLeft", 107 topRightMover: "#mover-topRight", 108 bottomLeftMover: "#mover-bottomLeft", 109 bottomRightMover: "#mover-bottomRight", 110 avatarPicker: "#avatars", 111 avatars: { all: "moz-visual-picker-item" }, 112 }; 113 114 constructor() { 115 super(); 116 117 this.setView(VIEWS.ICON); 118 this.viewDimensions = new ViewDimensions(); 119 this.avatarRegion = new Region(this.viewDimensions); 120 121 this.state = STATES.SELECTED; 122 this.avatarLabels = {}; 123 } 124 125 async connectedCallback() { 126 super.connectedCallback(); 127 128 await this.loadAvatarLabels(); 129 } 130 131 async loadAvatarLabels() { 132 const avatarL10nData = await document.l10n.formatValues( 133 AVATARS.map(avatar => this.getAvatarL10nId(avatar)) 134 ); 135 136 this.avatarLabels = {}; 137 for (let i = 0; i < AVATARS.length; i++) { 138 this.avatarLabels[AVATARS[i]] = avatarL10nData[i]; 139 } 140 141 this.requestUpdate(); 142 } 143 144 setView(newView) { 145 if (this.view === VIEWS.CROP) { 146 this.cropViewEnd(); 147 } 148 149 switch (newView) { 150 case VIEWS.ICON: 151 this.view = VIEWS.ICON; 152 break; 153 case VIEWS.CUSTOM: 154 this.view = VIEWS.CUSTOM; 155 break; 156 case VIEWS.CROP: 157 this.view = VIEWS.CROP; 158 this.cropViewStart(); 159 break; 160 } 161 } 162 163 toggleHidden(force = null) { 164 if (force === true) { 165 this.hidden = true; 166 } else if (force === false) { 167 this.hidden = false; 168 } else { 169 this.hidden = !this.hidden; 170 } 171 172 // Add or remove event listeners as necessary 173 if (this.hidden) { 174 document.removeEventListener("click", this); 175 window.removeEventListener("keydown", this); 176 } else { 177 document.addEventListener("click", this); 178 window.addEventListener("keydown", this); 179 } 180 } 181 182 show() { 183 this.toggleHidden(false); 184 } 185 186 hide() { 187 this.toggleHidden(true); 188 } 189 190 maybeHide() { 191 if (this.view === VIEWS.CROP) { 192 this.setView(VIEWS.CUSTOM); 193 return; 194 } 195 196 this.hide(); 197 } 198 199 cropViewStart() { 200 window.addEventListener("pointerdown", this); 201 window.addEventListener("pointermove", this); 202 window.addEventListener("pointerup", this); 203 document.documentElement.classList.add("disable-text-selection"); 204 } 205 206 cropViewEnd() { 207 window.removeEventListener("pointerdown", this); 208 window.removeEventListener("pointermove", this); 209 window.removeEventListener("pointerup", this); 210 document.documentElement.classList.remove("disable-text-selection"); 211 } 212 getAvatarL10nId(value) { 213 switch (value) { 214 case "barbell": 215 return "barbell-avatar"; 216 case "bike": 217 return "bike-avatar"; 218 case "book": 219 return "book-avatar"; 220 case "briefcase": 221 return "briefcase-avatar"; 222 case "canvas": 223 return "picture-avatar"; 224 case "craft": 225 return "craft-avatar"; 226 case "default-favicon": 227 return "globe-avatar"; 228 case "diamond": 229 return "diamond-avatar"; 230 case "flower": 231 return "flower-avatar"; 232 case "folder": 233 return "folder-avatar"; 234 case "hammer": 235 return "hammer-avatar"; 236 case "heart": 237 return "heart-avatar"; 238 case "heart-rate": 239 return "heart-rate-avatar"; 240 case "history": 241 return "clock-avatar"; 242 case "leaf": 243 return "leaf-avatar"; 244 case "lightbulb": 245 return "lightbulb-avatar"; 246 case "makeup": 247 return "makeup-avatar"; 248 case "message": 249 return "message-avatar"; 250 case "musical-note": 251 return "musical-note-avatar"; 252 case "palette": 253 return "palette-avatar"; 254 case "paw-print": 255 return "paw-print-avatar"; 256 case "plane": 257 return "plane-avatar"; 258 case "present": 259 return "present-avatar"; 260 case "shopping": 261 return "shopping-avatar"; 262 case "soccer": 263 return "soccer-ball-avatar"; 264 case "sparkle-single": 265 return "sparkle-single-avatar"; 266 case "star": 267 return "star-avatar"; 268 case "video-game-controller": 269 return "video-game-controller-avatar"; 270 default: 271 return "custom-avatar"; 272 } 273 } 274 275 handleAvatarChange() { 276 const selectedAvatar = this.avatarPicker.value; 277 278 document.dispatchEvent( 279 new CustomEvent("Profiles:AvatarSelected", { 280 detail: { avatar: selectedAvatar }, 281 }) 282 ); 283 } 284 285 handleTabClick(event) { 286 event.stopImmediatePropagation(); 287 if (event.target.id === "icon") { 288 this.setView(VIEWS.ICON); 289 } else { 290 this.setView(VIEWS.CUSTOM); 291 } 292 } 293 294 iconTabContentTemplate() { 295 return html`<moz-visual-picker 296 type="listbox" 297 value=${this.avatar} 298 name="avatar" 299 id="avatars" 300 @change=${this.handleAvatarChange} 301 >${AVATARS.map( 302 avatar => 303 html`<moz-visual-picker-item 304 aria-label=${ifDefined(this.avatarLabels[avatar])} 305 value=${avatar} 306 ?checked=${this.value === avatar} 307 ><moz-button 308 class="avatar-button" 309 type="ghost" 310 iconSrc="chrome://browser/content/profiles/assets/16_${avatar}.svg" 311 tabindex="-1" 312 data-l10n-id=${AVATAR_TOOLTIP_IDS[avatar]} 313 data-l10n-attrs="tooltiptext" 314 ></moz-button 315 ></moz-visual-picker-item>` 316 )}</moz-visual-picker 317 >`; 318 } 319 320 customTabUploadFileContentTemplate() { 321 return html`<div 322 class="custom-avatar-add-image-header" 323 data-l10n-id="avatar-selector-add-image" 324 ></div> 325 <div class="custom-avatar-upload-area"> 326 <input 327 @change=${this.handleFileUpload} 328 id="custom-image-input" 329 type="file" 330 accept="image/*" 331 label="Upload a file" 332 /> 333 <div id="file-messages"> 334 <img src="chrome://browser/skin/open.svg" /> 335 <span 336 id="upload-text" 337 data-l10n-id="avatar-selector-upload-file" 338 ></span> 339 <span id="drag-text" data-l10n-id="avatar-selector-drag-file"></span> 340 </div> 341 </div>`; 342 } 343 344 customTabViewImageTemplate() { 345 return html`<div 346 class="custom-avatar-crop-header" 347 data-l10n-id="custom-avatar-crop-view" 348 > 349 <moz-button 350 id="back-button" 351 @click=${this.handleCancelClick} 352 @keydown=${this.handleBackKeyDown} 353 data-l10n-id="custom-avatar-crop-back-button" 354 type="icon ghost" 355 iconSrc="chrome://global/skin/icons/arrow-left.svg" 356 ></moz-button> 357 <span data-l10n-id="avatar-selector-crop"></span> 358 <div id="spacer"></div> 359 </div> 360 <div class="custom-avatar-crop-area"> 361 <div id="avatar-selection-container"> 362 <div 363 id="highlight" 364 class="highlight" 365 tabindex="0" 366 data-l10n-id="custom-avatar-crop-area" 367 > 368 <div id="highlight-background"></div> 369 <div 370 id="mover-topLeft" 371 data-l10n-id="custom-avatar-drag-handle" 372 class="mover-target direction-topLeft" 373 tabindex="0" 374 > 375 <div class="mover"></div> 376 </div> 377 378 <div 379 id="mover-topRight" 380 data-l10n-id="custom-avatar-drag-handle" 381 class="mover-target direction-topRight" 382 tabindex="0" 383 > 384 <div class="mover"></div> 385 </div> 386 387 <div 388 id="mover-bottomRight" 389 data-l10n-id="custom-avatar-drag-handle" 390 class="mover-target direction-bottomRight" 391 tabindex="0" 392 > 393 <div class="mover"></div> 394 </div> 395 396 <div 397 id="mover-bottomLeft" 398 data-l10n-id="custom-avatar-drag-handle" 399 class="mover-target direction-bottomLeft" 400 tabindex="0" 401 > 402 <div class="mover"></div> 403 </div> 404 </div> 405 </div> 406 <img 407 id="custom-avatar-image" 408 src=${this.blobURL} 409 @load=${this.imageLoaded} 410 /> 411 </div> 412 <moz-button-group class="custom-avatar-actions" 413 ><moz-button 414 @click=${this.handleCancelClick} 415 @keydown=${this.handleCancelKeyDown} 416 data-l10n-id="avatar-selector-cancel-button" 417 ></moz-button 418 ><moz-button 419 type="primary" 420 id="save-button" 421 @click=${this.handleSaveClick} 422 @keydown=${this.handleSaveKeyDown} 423 data-l10n-id="avatar-selector-save-button" 424 ></moz-button 425 ></moz-button-group>`; 426 } 427 428 handleCancelClick(event) { 429 event.stopImmediatePropagation(); 430 431 this.setView(VIEWS.CUSTOM); 432 if (this.blobURL) { 433 URL.revokeObjectURL(this.blobURL); 434 } 435 this.file = null; 436 } 437 438 handleBackKeyDown(event) { 439 if (event.code === "Enter" || event.code === "Space") { 440 event.preventDefault(); 441 this.handleCancelClick(event); 442 } 443 } 444 445 handleCancelKeyDown(event) { 446 if (event.code === "Enter" || event.code === "Space") { 447 event.preventDefault(); 448 this.handleCancelClick(event); 449 } 450 } 451 452 handleSaveKeyDown(event) { 453 if (event.code === "Enter" || event.code === "Space") { 454 event.preventDefault(); 455 this.handleSaveClick(event); 456 } 457 } 458 459 async handleSaveClick(event) { 460 event.stopImmediatePropagation(); 461 462 const img = new Image(); 463 img.src = this.blobURL; 464 await img.decode(); 465 466 const { width: imageWidth, height: imageHeight } = img; 467 const scale = 468 imageWidth <= imageHeight 469 ? imageWidth / this.customAvatarCropArea.clientWidth 470 : imageHeight / this.customAvatarCropArea.clientHeight; 471 472 // eslint-disable-next-line no-shadow 473 const { left, top, radius } = this.avatarRegion.dimensions; 474 // eslint-disable-next-line no-shadow 475 const { devicePixelRatio } = this.viewDimensions.dimensions; 476 const { scrollTop, scrollLeft } = this.customAvatarCropArea; 477 478 // Create the canvas so it is a square around the selected area. 479 const scaledRadius = Math.round(radius * scale * devicePixelRatio); 480 const squareSize = scaledRadius * 2; 481 const squareCanvas = new OffscreenCanvas(squareSize, squareSize); 482 const squareCtx = squareCanvas.getContext("2d"); 483 484 // Crop the canvas so it is a circle. 485 squareCtx.beginPath(); 486 squareCtx.arc(scaledRadius, scaledRadius, scaledRadius, 0, Math.PI * 2); 487 squareCtx.clip(); 488 489 const sourceX = Math.round((left + scrollLeft) * scale); 490 const sourceY = Math.round((top + scrollTop) * scale); 491 const sourceWidth = Math.round(radius * 2 * scale); 492 493 // Draw the image onto the canvas. 494 squareCtx.drawImage( 495 img, 496 sourceX, 497 sourceY, 498 sourceWidth, 499 sourceWidth, 500 0, 501 0, 502 squareSize, 503 squareSize 504 ); 505 506 const blob = await squareCanvas.convertToBlob({ type: "image/png" }); 507 const circularFile = new File([blob], this.file.name, { 508 type: "image/png", 509 }); 510 511 document.dispatchEvent( 512 new CustomEvent("Profiles:CustomAvatarUpload", { 513 detail: { file: circularFile }, 514 }) 515 ); 516 517 if (this.blobURL) { 518 URL.revokeObjectURL(this.blobURL); 519 } 520 521 this.setView(VIEWS.CUSTOM); 522 this.hide(); 523 } 524 525 updateViewDimensions() { 526 let { width, height } = this.customAvatarImage; 527 528 this.viewDimensions.dimensions = { 529 width: this.customAvatarCropArea.clientWidth, 530 height: this.customAvatarCropArea.clientHeight, 531 devicePixelRatio: window.devicePixelRatio, 532 }; 533 534 if (width > height) { 535 this.customAvatarImage.classList.add("height-full"); 536 } else { 537 this.customAvatarImage.classList.add("width-full"); 538 } 539 } 540 541 imageLoaded() { 542 this.updateViewDimensions(); 543 this.setInitialAvatarSelection(); 544 this.highlight.focus({ focusVisible: true }); 545 } 546 547 setInitialAvatarSelection() { 548 // Make initial size a little smaller than the view so the movers aren't 549 // behind the scrollbar 550 let diameter = 551 Math.min(this.viewDimensions.width, this.viewDimensions.height) - 20; 552 553 let left = 554 Math.floor(this.viewDimensions.width / 2) - Math.floor(diameter / 2); 555 // eslint-disable-next-line no-shadow 556 let top = 557 Math.floor(this.viewDimensions.height / 2) - Math.floor(diameter / 2); 558 559 let right = left + diameter; 560 let bottom = top + diameter; 561 562 this.avatarRegion.resizeToSquare({ left, top, right, bottom }); 563 564 this.drawSelectionContainer(); 565 } 566 567 drawSelectionContainer() { 568 // eslint-disable-next-line no-shadow 569 let { top, left, width, height } = this.avatarRegion.dimensions; 570 571 this.highlight.style = `top:${top}px;left:${left}px;width:${width}px;height:${height}px;`; 572 } 573 574 getCoordinatesFromEvent(event) { 575 let { clientX, clientY, movementX, movementY } = event; 576 let rect = this.avatarSelectionContainer.getBoundingClientRect(); 577 578 return { x: clientX - rect.x, y: clientY - rect.y, movementX, movementY }; 579 } 580 581 handleEvent(event) { 582 switch (event.type) { 583 case "pointerdown": { 584 this.handlePointerDown(event); 585 break; 586 } 587 case "pointermove": { 588 this.handlePointerMove(event); 589 break; 590 } 591 case "pointerup": { 592 this.handlePointerUp(event); 593 break; 594 } 595 case "keydown": { 596 this.handleKeyDown(event); 597 break; 598 } 599 case "click": { 600 if (this.view === VIEWS.CROP) { 601 return; 602 } 603 604 let element = event.originalTarget; 605 while (element && element !== this) { 606 element = element?.getRootNode()?.host; 607 } 608 609 if (element === this) { 610 return; 611 } 612 613 this.hide(); 614 break; 615 } 616 } 617 } 618 619 handlePointerDown(event) { 620 let targetId = event.originalTarget?.id; 621 if ( 622 [ 623 "highlight", 624 "mover-topLeft", 625 "mover-topRight", 626 "mover-bottomRight", 627 "mover-bottomLeft", 628 ].includes(targetId) 629 ) { 630 this.state = STATES.RESIZING; 631 this.#moverId = targetId; 632 } 633 } 634 635 handlePointerMove(event) { 636 if (this.state === STATES.RESIZING) { 637 let { x, y, movementX, movementY } = this.getCoordinatesFromEvent(event); 638 this.handleResizingPointerMove(x, y, movementX, movementY); 639 } 640 } 641 642 handleResizingPointerMove(x, y, movementX, movementY) { 643 switch (this.#moverId) { 644 case "highlight": { 645 this.avatarRegion.resizeToSquare( 646 { 647 left: this.avatarRegion.left + movementX, 648 top: this.avatarRegion.top + movementY, 649 right: this.avatarRegion.right + movementX, 650 bottom: this.avatarRegion.bottom + movementY, 651 }, 652 this.#moverId 653 ); 654 break; 655 } 656 case "mover-topLeft": { 657 this.avatarRegion.resizeToSquare( 658 { 659 left: x, 660 top: y, 661 }, 662 this.#moverId 663 ); 664 break; 665 } 666 case "mover-topRight": { 667 this.avatarRegion.resizeToSquare( 668 { 669 top: y, 670 right: x, 671 }, 672 this.#moverId 673 ); 674 break; 675 } 676 case "mover-bottomRight": { 677 this.avatarRegion.resizeToSquare( 678 { 679 right: x, 680 bottom: y, 681 }, 682 this.#moverId 683 ); 684 break; 685 } 686 case "mover-bottomLeft": { 687 this.avatarRegion.resizeToSquare( 688 { 689 left: x, 690 bottom: y, 691 }, 692 this.#moverId 693 ); 694 break; 695 } 696 default: 697 return; 698 } 699 700 this.scrollIfByEdge(x, y); 701 this.drawSelectionContainer(); 702 } 703 704 handlePointerUp() { 705 this.state = STATES.SELECTED; 706 this.#moverId = ""; 707 this.avatarRegion.sortCoords(); 708 } 709 710 handleKeyDown(event) { 711 if (event.key === "Escape") { 712 this.maybeHide(); 713 } 714 715 if (this.view !== VIEWS.CROP) { 716 return; 717 } 718 719 switch (event.key) { 720 case "ArrowLeft": 721 this.handleArrowLeftKeyDown(event); 722 break; 723 case "ArrowUp": 724 this.handleArrowUpKeyDown(event); 725 break; 726 case "ArrowRight": 727 this.handleArrowRightKeyDown(event); 728 break; 729 case "ArrowDown": 730 this.handleArrowDownKeyDown(event); 731 break; 732 case "Tab": 733 return; 734 default: 735 event.preventDefault(); 736 return; 737 } 738 event.preventDefault(); 739 this.drawSelectionContainer(); 740 } 741 742 handleArrowLeftKeyDown(event) { 743 let targetId = event.originalTarget.id; 744 switch (targetId) { 745 case "highlight": 746 this.avatarRegion.left -= 1; 747 this.avatarRegion.right -= 1; 748 749 this.scrollIfByEdge( 750 this.avatarRegion.left, 751 this.viewDimensions.height / 2 752 ); 753 break; 754 case "mover-topLeft": 755 this.avatarRegion.left -= 1; 756 this.avatarRegion.top -= 1; 757 758 this.scrollIfByEdge(this.avatarRegion.left, this.avatarRegion.top); 759 break; 760 case "mover-bottomLeft": 761 this.avatarRegion.left -= 1; 762 this.avatarRegion.bottom += 1; 763 764 this.scrollIfByEdge(this.avatarRegion.left, this.avatarRegion.bottom); 765 break; 766 case "mover-topRight": 767 this.avatarRegion.right -= 1; 768 this.avatarRegion.top += 1; 769 770 if ( 771 this.avatarRegion.x1 >= this.avatarRegion.x2 || 772 this.avatarRegion.y1 >= this.avatarRegion.y2 773 ) { 774 this.avatarRegion.sortCoords(); 775 this.bottomLeftMover.focus({ focusVisible: true }); 776 } 777 break; 778 case "mover-bottomRight": 779 this.avatarRegion.right -= 1; 780 this.avatarRegion.bottom -= 1; 781 782 if ( 783 this.avatarRegion.x1 >= this.avatarRegion.x2 || 784 this.avatarRegion.y1 >= this.avatarRegion.y2 785 ) { 786 this.avatarRegion.sortCoords(); 787 this.topLeftMover.focus({ focusVisible: true }); 788 } 789 break; 790 default: 791 return; 792 } 793 794 this.avatarRegion.forceSquare(targetId); 795 } 796 797 handleArrowUpKeyDown(event) { 798 let targetId = event.originalTarget.id; 799 switch (targetId) { 800 case "highlight": 801 this.avatarRegion.top -= 1; 802 this.avatarRegion.bottom -= 1; 803 804 this.scrollIfByEdge( 805 this.viewDimensions.width / 2, 806 this.avatarRegion.top 807 ); 808 break; 809 case "mover-topLeft": 810 this.avatarRegion.left -= 1; 811 this.avatarRegion.top -= 1; 812 813 this.scrollIfByEdge(this.avatarRegion.left, this.avatarRegion.top); 814 break; 815 case "mover-bottomLeft": 816 this.avatarRegion.left += 1; 817 this.avatarRegion.bottom -= 1; 818 819 if ( 820 this.avatarRegion.x1 >= this.avatarRegion.x2 || 821 this.avatarRegion.y1 >= this.avatarRegion.y2 822 ) { 823 this.avatarRegion.sortCoords(); 824 this.topRightMover.focus({ focusVisible: true }); 825 } 826 break; 827 case "mover-topRight": 828 this.avatarRegion.right += 1; 829 this.avatarRegion.top -= 1; 830 831 this.scrollIfByEdge(this.avatarRegion.right, this.avatarRegion.top); 832 break; 833 case "mover-bottomRight": 834 this.avatarRegion.right -= 1; 835 this.avatarRegion.bottom -= 1; 836 837 if ( 838 this.avatarRegion.x1 >= this.avatarRegion.x2 || 839 this.avatarRegion.y1 >= this.avatarRegion.y2 840 ) { 841 this.avatarRegion.sortCoords(); 842 this.topLeftMover.focus({ focusVisible: true }); 843 } 844 break; 845 default: 846 return; 847 } 848 849 this.avatarRegion.forceSquare(targetId); 850 } 851 852 handleArrowRightKeyDown(event) { 853 let targetId = event.originalTarget.id; 854 switch (targetId) { 855 case "highlight": 856 this.avatarRegion.left += 1; 857 this.avatarRegion.right += 1; 858 859 this.scrollIfByEdge( 860 this.avatarRegion.right, 861 this.viewDimensions.height / 2 862 ); 863 break; 864 case "mover-topLeft": 865 this.avatarRegion.left += 1; 866 this.avatarRegion.top += 1; 867 868 if ( 869 this.avatarRegion.x1 >= this.avatarRegion.x2 || 870 this.avatarRegion.y1 >= this.avatarRegion.y2 871 ) { 872 this.avatarRegion.sortCoords(); 873 this.bottomRightMover.focus({ focusVisible: true }); 874 } 875 break; 876 case "mover-bottomLeft": 877 this.avatarRegion.left += 1; 878 this.avatarRegion.bottom -= 1; 879 880 if ( 881 this.avatarRegion.x1 >= this.avatarRegion.x2 || 882 this.avatarRegion.y1 >= this.avatarRegion.y2 883 ) { 884 this.avatarRegion.sortCoords(); 885 this.topRightMover.focus({ focusVisible: true }); 886 } 887 break; 888 case "mover-topRight": 889 this.avatarRegion.right += 1; 890 this.avatarRegion.top -= 1; 891 892 this.scrollIfByEdge(this.avatarRegion.right, this.avatarRegion.top); 893 break; 894 case "mover-bottomRight": 895 this.avatarRegion.right += 1; 896 this.avatarRegion.bottom += 1; 897 898 this.scrollIfByEdge(this.avatarRegion.right, this.avatarRegion.bottom); 899 break; 900 default: 901 return; 902 } 903 904 this.avatarRegion.forceSquare(targetId); 905 } 906 907 handleArrowDownKeyDown(event) { 908 let targetId = event.originalTarget.id; 909 switch (targetId) { 910 case "highlight": 911 this.avatarRegion.top += 1; 912 this.avatarRegion.bottom += 1; 913 914 this.scrollIfByEdge( 915 this.viewDimensions.width / 2, 916 this.avatarRegion.bottom 917 ); 918 break; 919 case "mover-topLeft": 920 this.avatarRegion.left += 1; 921 this.avatarRegion.top += 1; 922 923 if ( 924 this.avatarRegion.x1 >= this.avatarRegion.x2 || 925 this.avatarRegion.y1 >= this.avatarRegion.y2 926 ) { 927 this.avatarRegion.sortCoords(); 928 this.bottomRightMover.focus({ focusVisible: true }); 929 } 930 break; 931 case "mover-bottomLeft": 932 this.avatarRegion.left -= 1; 933 this.avatarRegion.bottom += 1; 934 935 this.scrollIfByEdge(this.avatarRegion.left, this.avatarRegion.bottom); 936 break; 937 case "mover-topRight": 938 this.avatarRegion.right -= 1; 939 this.avatarRegion.top += 1; 940 941 if ( 942 this.avatarRegion.x1 >= this.avatarRegion.x2 || 943 this.avatarRegion.y1 >= this.avatarRegion.y2 944 ) { 945 this.avatarRegion.sortCoords(); 946 this.bottomLeftMover.focus({ focusVisible: true }); 947 } 948 break; 949 case "mover-bottomRight": 950 this.avatarRegion.right += 1; 951 this.avatarRegion.bottom += 1; 952 953 this.scrollIfByEdge(this.avatarRegion.right, this.avatarRegion.bottom); 954 break; 955 default: 956 return; 957 } 958 959 this.avatarRegion.forceSquare(targetId); 960 } 961 962 scrollIfByEdge(viewX, viewY) { 963 const { width, height } = this.viewDimensions.dimensions; 964 965 if (viewY <= SCROLL_BY_EDGE) { 966 // Scroll up 967 this.scrollView(0, -(SCROLL_BY_EDGE - viewY)); 968 } else if (height - viewY < SCROLL_BY_EDGE) { 969 // Scroll down 970 this.scrollView(0, SCROLL_BY_EDGE - (height - viewY)); 971 } 972 973 if (viewX <= SCROLL_BY_EDGE) { 974 // Scroll left 975 this.scrollView(-(SCROLL_BY_EDGE - viewX), 0); 976 } else if (width - viewX <= SCROLL_BY_EDGE) { 977 // Scroll right 978 this.scrollView(SCROLL_BY_EDGE - (width - viewX), 0); 979 } 980 } 981 982 scrollView(x, y) { 983 this.customAvatarCropArea.scrollBy(x, y); 984 } 985 986 handleFileUpload(event) { 987 const [file] = event.target.files; 988 this.file = file; 989 990 if (this.blobURL) { 991 URL.revokeObjectURL(this.blobURL); 992 } 993 994 this.blobURL = URL.createObjectURL(file); 995 this.setView(VIEWS.CROP); 996 } 997 998 contentTemplate() { 999 switch (this.view) { 1000 case VIEWS.ICON: { 1001 return this.iconTabContentTemplate(); 1002 } 1003 case VIEWS.CUSTOM: { 1004 return this.customTabUploadFileContentTemplate(); 1005 } 1006 case VIEWS.CROP: { 1007 return this.customTabViewImageTemplate(); 1008 } 1009 } 1010 return null; 1011 } 1012 1013 render() { 1014 return html`<link 1015 rel="stylesheet" 1016 href="chrome://browser/content/profiles/profile-avatar-selector.css" 1017 /> 1018 <moz-card id="avatar-selector"> 1019 <div id="content"> 1020 <div class="button-group"> 1021 <moz-button 1022 id="icon" 1023 type=${this.view === VIEWS.ICON ? "primary" : "default"} 1024 size="small" 1025 data-l10n-id="avatar-selector-icon-tab" 1026 @click=${this.handleTabClick} 1027 ></moz-button> 1028 <moz-button 1029 id="custom" 1030 type=${this.view === VIEWS.ICON ? "default" : "primary"} 1031 size="small" 1032 data-l10n-id="avatar-selector-custom-tab" 1033 @click=${this.handleTabClick} 1034 ></moz-button> 1035 </div> 1036 ${this.contentTemplate()} 1037 </div> 1038 </moz-card>`; 1039 } 1040 } 1041 1042 customElements.define("profile-avatar-selector", ProfileAvatarSelector);