chromium/chrome/browser/resources/extensions/shortcut_input.ts

// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_icons.css.js';
import 'chrome://resources/cr_elements/cr_input/cr_input.js';
import 'chrome://resources/cr_elements/cr_hidden_style.css.js';

import {getInstance as getAnnouncerInstance} from 'chrome://resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import type {CrInputElement} from 'chrome://resources/cr_elements/cr_input/cr_input.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {KeyboardShortcutDelegate} from './keyboard_shortcut_delegate.js';
import {getTemplate} from './shortcut_input.html.js';
import {hasValidModifiers, isValidKeyCode, Key, keystrokeToString} from './shortcut_util.js';

enum ShortcutError {
  NO_ERROR = 0,
  INCLUDE_START_MODIFIER = 1,
  TOO_MANY_MODIFIERS = 2,
  NEED_CHARACTER = 3,
}

// The UI to display and manage keyboard shortcuts set for extension commands.

export interface ExtensionsShortcutInputElement {
  $: {
    input: CrInputElement,
    edit: HTMLElement,
  };
}

const ExtensionsShortcutInputElementBase = I18nMixin(PolymerElement);

export class ExtensionsShortcutInputElement extends
    ExtensionsShortcutInputElementBase {
  static get is() {
    return 'extensions-shortcut-input';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      delegate: Object,
      item: Object,
      command: Object,

      shortcut: {
        type: String,
        value: '',
      },

      capturing_: {
        type: Boolean,
        value: false,
      },

      error_: {
        type: Number,
        value: ShortcutError.NO_ERROR,
      },

      readonly_: {
        type: Boolean,
        value: true,
        reflectToAttribute: true,
      },

      pendingShortcut_: {
        type: String,
        value: '',
      },
    };
  }

  delegate: KeyboardShortcutDelegate;
  item: chrome.developerPrivate.ExtensionInfo;
  command: chrome.developerPrivate.Command;
  shortcut: string;
  private capturing_: boolean;
  private error_: ShortcutError;
  private readonly_: boolean;
  private pendingShortcut_: string;

  override ready() {
    super.ready();

    const node = this.$.input;
    node.addEventListener('mouseup', this.startCapture_.bind(this));
    node.addEventListener('blur', this.endCapture_.bind(this));
    node.addEventListener('focus', this.startCapture_.bind(this));
    node.addEventListener('keydown', this.onKeyDown_.bind(this));
    node.addEventListener('keyup', this.onKeyUp_.bind(this));
  }

  private startCapture_() {
    if (this.capturing_ || this.readonly_) {
      return;
    }
    this.capturing_ = true;
    this.delegate.setShortcutHandlingSuspended(true);
  }

  private endCapture_() {
    if (!this.capturing_) {
      return;
    }
    this.pendingShortcut_ = '';
    this.capturing_ = false;
    this.$.input.blur();
    this.error_ = ShortcutError.NO_ERROR;
    this.delegate.setShortcutHandlingSuspended(false);
    this.readonly_ = true;
  }

  private clearShortcut_() {
    this.pendingShortcut_ = '';
    this.shortcut = '';
    // We commit the empty shortcut in order to clear the current shortcut
    // for the extension.
    this.commitPending_();
    this.endCapture_();
  }

  private onKeyDown_(e: KeyboardEvent) {
    if (this.readonly_) {
      return;
    }

    if (e.target === this.$.edit) {
      return;
    }

    if (e.keyCode === Key.ESCAPE) {
      if (!this.capturing_) {
        // If we're not currently capturing, allow escape to propagate.
        return;
      }
      // Otherwise, escape cancels capturing.
      this.endCapture_();
      e.preventDefault();
      e.stopPropagation();
      return;
    }
    if (e.keyCode === Key.TAB) {
      // Allow tab propagation for keyboard navigation.
      return;
    }

    if (!this.capturing_) {
      this.startCapture_();
    }

    this.handleKey_(e);
  }

  private onKeyUp_(e: KeyboardEvent) {
    // Ignores pressing 'Space' or 'Enter' on the edit button. In 'Enter's
    // case, the edit button disappears before key-up, so 'Enter's key-up
    // target becomes the input field, not the edit button, and needs to
    // be caught explicitly.
    if (this.readonly_) {
      return;
    }

    if (e.target === this.$.edit || e.key === 'Enter') {
      return;
    }

    if (e.keyCode === Key.ESCAPE || e.keyCode === Key.TAB) {
      return;
    }

    this.handleKey_(e);
  }

  private getErrorString_(
      _error: ShortcutError, includeStartModifier: string,
      tooManyModifiers: string, needCharacter: string): string {
    switch (this.error_) {
      case ShortcutError.INCLUDE_START_MODIFIER:
        return includeStartModifier;
      case ShortcutError.TOO_MANY_MODIFIERS:
        return tooManyModifiers;
      case ShortcutError.NEED_CHARACTER:
        return needCharacter;
      default:
        assert(this.error_ === ShortcutError.NO_ERROR);
        return '';
    }
  }

  private handleKey_(e: KeyboardEvent) {
    // While capturing, we prevent all events from bubbling, to prevent
    // shortcuts lacking the right modifier (F3 for example) from activating
    // and ending capture prematurely.
    e.preventDefault();
    e.stopPropagation();

    // We don't allow both Ctrl and Alt in the same keybinding.
    // TODO(devlin): This really should go in hasValidModifiers,
    // but that requires updating the existing page as well.
    if (e.ctrlKey && e.altKey) {
      this.error_ = ShortcutError.TOO_MANY_MODIFIERS;
      return;
    }
    if (!hasValidModifiers(e)) {
      this.pendingShortcut_ = '';
      this.error_ = ShortcutError.INCLUDE_START_MODIFIER;
      return;
    }
    this.pendingShortcut_ = keystrokeToString(e);
    if (!isValidKeyCode(e.keyCode)) {
      this.error_ = ShortcutError.NEED_CHARACTER;
      return;
    }

    this.error_ = ShortcutError.NO_ERROR;

    getAnnouncerInstance().announce(
        this.i18n('shortcutSet', this.computeText_()));

    this.commitPending_();
    this.endCapture_();
  }

  private commitPending_() {
    this.shortcut = this.pendingShortcut_;
    this.delegate.updateExtensionCommandKeybinding(
        this.item.id, this.command.name, this.shortcut);
  }

  private computeInputAriaLabel_(): string {
    return this.i18n(
        'editShortcutInputLabel', this.command.description, this.item.name);
  }

  private computeEditButtonAriaLabel_(): string {
    return this.i18n(
        'editShortcutButtonLabel', this.command.description, this.item.name);
  }

  private computePlaceholder_(): string {
    if (this.readonly_) {
      return this.shortcut ? this.i18n('shortcutSet', this.computeText_()) :
                             this.i18n('shortcutNotSet');
    }
    return this.i18n('shortcutTypeAShortcut');
  }

  /**
   * @return The text to be displayed in the shortcut field.
   */
  private computeText_(): string {
    const shortcutString =
        this.capturing_ ? this.pendingShortcut_ : this.shortcut;
    return shortcutString.split('+').join(' + ');
  }

  private getIsInvalid_(): boolean {
    return this.error_ !== ShortcutError.NO_ERROR;
  }

  private onEditClick_() {
    // TODO(ghazale): The clearing functionality should be improved.
    // Instead of clicking the edit button, and then clicking elsewhere to
    // commit the "empty" shortcut, we want to introduce a separate clear
    // button.
    this.clearShortcut_();
    this.readonly_ = false;
    this.$.input.focus();
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'extensions-shortcut-input': ExtensionsShortcutInputElement;
  }
}

customElements.define(
    ExtensionsShortcutInputElement.is, ExtensionsShortcutInputElement);