tor-browser

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

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);