chromium/ash/webui/common/resources/keyboard_diagram.js

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

import {I18nBehavior, I18nBehaviorInterface} from 'chrome://resources/ash/common/i18n_behavior.js';
import {html, mixinBehaviors, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './keyboard_diagram.html.js';
import {KeyboardKeyState} from './keyboard_key.js';
import {getKeyboardLayoutForRegionCode} from './keyboard_layouts.js';

/**
 * @fileoverview
 * 'keyboard-diagram' displays a diagram of a CrOS-style keyboard.
 */

// Size ratios derived from diagrams in the Chromebook keyboard spec.
const HEIGHT_TO_WIDTH_RATIO = 663 / 1760;
const EXTENDED_HEIGHT_TO_WIDTH_RATIO = 9 / 31;

/** The minimum diagram height at which key glyphs are legible. */
const MINIMUM_HEIGHT_PX = 250;

/**
 * Enum of mechanical layouts supported by the component.
 * @enum {string}
 */
export const MechanicalLayout = {
  ANSI: 'ansi',
  ISO: 'iso',
  JIS: 'jis',
};

/**
 * Enum of physical styles supported by the component.
 * @enum {string}
 */
export const PhysicalLayout = {
  CHROME_OS: 'chrome-os',
  CHROME_OS_DELL_ENTERPRISE_WILCO: 'dell-enterprise-wilco',
  CHROME_OS_DELL_ENTERPRISE_DRALLION: 'dell-enterprise-drallion',
};

/**
 * Enum of top-right keys supported by the component.
 * @enum {string}
 */
export const TopRightKey = {
  POWER: 'power',
  LOCK: 'lock',
  CONTROL_PANEL: 'control-panel',
};

/**
 * Enum of action keys to be shown on the top row.
 * @enum {!Object<string,
 *                !{icon: ?string, text: ?string, ariaNameI18n: ?string}>}
 */
export const TopRowKey = {
  kNone: {},
  kBack: {icon: 'keyboard:back', ariaNameI18n: 'keyboardDiagramAriaNameBack'},
  kForward: {
    icon: 'keyboard:forward',
    ariaNameI18n: 'keyboardDiagramAriaNameForward',
  },
  kRefresh: {
    icon: 'keyboard:refresh',
    ariaNameI18n: 'keyboardDiagramAriaNameRefresh',
  },
  kFullscreen: {
    icon: 'keyboard:fullscreen',
    ariaNameI18n: 'keyboardDiagramAriaNameFullscreen',
  },
  kOverview: {
    icon: 'keyboard:overview',
    ariaNameI18n: 'keyboardDiagramAriaNameOverview',
  },
  kScreenshot: {
    icon: 'keyboard:screenshot',
    ariaNameI18n: 'keyboardDiagramAriaNameScreenshot',
  },
  kScreenBrightnessDown: {
    icon: 'keyboard:display-brightness-down',
    ariaNameI18n: 'keyboardDiagramAriaNameScreenBrightnessDown',
  },
  kScreenBrightnessUp: {
    icon: 'keyboard:display-brightness-up',
    ariaNameI18n: 'keyboardDiagramAriaNameScreenBrightnessUp',
  },
  kPrivacyScreenToggle: {
    icon: 'keyboard:electronic-privacy-screen',
    ariaNameI18n: 'keyboardDiagramAriaNamePrivacyScreenToggle',
  },
  kMicrophoneMute: {
    icon: 'keyboard:microphone-mute',
    ariaNameI18n: 'keyboardDiagramAriaNameMicrophoneMute',
  },
  kVolumeMute: {
    icon: 'keyboard:volume-mute',
    ariaNameI18n: 'keyboardDiagramAriaNameMute',
  },
  kVolumeDown: {
    icon: 'keyboard:volume-down',
    ariaNameI18n: 'keyboardDiagramAriaNameVolumeDown',
  },
  kVolumeUp: {
    icon: 'keyboard:volume-up',
    ariaNameI18n: 'keyboardDiagramAriaNameVolumeUp',
  },
  kKeyboardBacklightToggle: {
    icon: 'keyboard:keyboard-brightness-toggle',
    ariaNameI18n: 'keyboardDiagramAriaNameKeyboardBacklightToggle',
  },
  kKeyboardBacklightDown: {
    icon: 'keyboard:keyboard-brightness-down',
    ariaNameI18n: 'keyboardDiagramAriaNameKeyboardBacklightDown',
  },
  kKeyboardBacklightUp: {
    icon: 'keyboard:keyboard-brightness-up',
    ariaNameI18n: 'keyboardDiagramAriaNameKeyboardBacklightUp',
  },
  kNextTrack: {
    icon: 'keyboard:next-track',
    ariaNameI18n: 'keyboardDiagramAriaNameTrackNext',
  },
  kPreviousTrack: {
    icon: 'keyboard:last-track',
    ariaNameI18n: 'keyboardDiagramAriaNameTrackPrevious',
  },
  kPlayPause: {
    icon: 'keyboard:play-pause',
    ariaNameI18n: 'keyboardDiagramAriaNamePlayPause',
  },
  kScreenMirror: {
    icon: 'keyboard:screen-mirror',
    ariaNameI18n: 'keyboardDiagramAriaNameScreenMirror',
  },
  // TODO(crbug.com/1207678): work out the localization scheme for keys like
  // delete and unknown.
  kDelete: {text: 'delete'},
  kUnknown: {text: 'unknown'},
};

/**
 * @constructor
 * @extends {PolymerElement}
 * @implements {I18nBehaviorInterface}
 */
const KeyboardDiagramElementBase =
    mixinBehaviors([I18nBehavior], PolymerElement);

/** @polymer */
export class KeyboardDiagramElement extends KeyboardDiagramElementBase {
  static get is() {
    return 'keyboard-diagram';
  }

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

  static get properties() {
    return {
      /**
       * The mechanical layout to be displayed, or null for the default.
       * @type {?MechanicalLayout}
       */
      mechanicalLayout: String,

      /**
       * The physical style of the keyboard to be displayed, or null for the
       * default.
       * @type {?PhysicalLayout}
       */
      physicalLayout: String,

      /**
       * For internal keyboards, the region code of the device, used to
       * determine the key labels.
       * @type {?string}
       */
      regionCode: {
        type: String,
        observer: 'regionCodeChanged_',
      },

      /** Whether to show the Assistant key (between Ctrl and Alt). */
      showAssistantKey: Boolean,

      /** Whether to show a Chrome OS-style number pad.  */
      showNumberPad: {
        type: Boolean,
        observer: 'updateHeight_',
      },

      /** @private {boolean} */
      showFnAndGlobeKeys_: {
        type: Boolean,
        computed: 'computeShowFnAndGlobeKeys_(physicalLayout)',
      },

      /**
       * The keys to display on the top row.
       * @type {!Array<!TopRowKey>}
       */
      topRowKeys: {
        type: Array,
        value: [],
      },

      /**
       * The icon to display on the top-right key.
       * @type {?TopRightKey}
       */
      topRightKey: {
        type: String,
        value: TopRightKey.LOCK,
      },

      /** @protected {number} */
      topRightKeyCode_: {
        type: Number,
        computed: 'computeTopRightKeyCode_(topRightKey)',
      },

      /** @protected {string} */
      topRightKeyIcon_: {
        type: String,
        computed: 'computeTopRightKeyIcon_(topRightKey)',
      },

      /** @protected {string} */
      topRightKeyAriaNameI18n_: {
        type: String,
        computed: 'computeTopRightKeyAriaNameI18n_(topRightKey)',
      },
    };
  }

  /**
   * @param {?PhysicalLayout} physicalLayout
   * @return {boolean}
   * @private
   */
  computeShowFnAndGlobeKeys_(physicalLayout) {
    return physicalLayout == PhysicalLayout.CHROME_OS_DELL_ENTERPRISE_WILCO ||
        physicalLayout == PhysicalLayout.CHROME_OS_DELL_ENTERPRISE_DRALLION;
  }

  /**
   * @param {?TopRightKey} topRightKey
   * @return {number}
   * @private
   */
  computeTopRightKeyCode_(topRightKey) {
    return {
      [TopRightKey.POWER]: 116,
      [TopRightKey.LOCK]: 142,
      [TopRightKey.CONTROL_PANEL]: 579,
    }[topRightKey];
  }

  /**
   * @param {?TopRightKey} topRightKey
   * @return {string}
   * @private
   */
  computeTopRightKeyIcon_(topRightKey) {
    return 'keyboard:' + topRightKey;
  }

  /**
   * @param {?TopRightKey} topRightKey
   * @return {string}
   * @private
   */
  computeTopRightKeyAriaNameI18n_(topRightKey) {
    return {
      [TopRightKey.POWER]: 'keyboardDiagramAriaNamePower',
      [TopRightKey.LOCK]: 'keyboardDiagramAriaNameLock',
      [TopRightKey.CONTROL_PANEL]: 'keyboardDiagramAriaNameControlPanel',
    }[topRightKey];
  }

  constructor() {
    super();

    /** @private */
    this.resizeObserver_ = new ResizeObserver(this.onResize_.bind(this));

    /** @private {?number} */
    this.currentWidth_ = null;
  }

  ready() {
    super.ready();

    // We have to observe the size of an element other than the keyboard itself,
    // to avoid ResizeObserver call loops when we change the width of the
    // keyboard element.
    this.resizeObserver_.observe(this.$.widthChangeDetector);
  }

  /**
   * Utility method for the HTML template to check values are equal.
   * @param {*} lhs
   * @param {*} rhs
   * @return {boolean}
   * @private
   */
  isEqual_(lhs, rhs) {
    return lhs === rhs;
  }

  /**
   * Utility method for the HTML template to retrieve a localized string, that
   * returns null if the ID is null or undefined.
   * @param {?string} stringId The ID to retrieve.
   * @return {?string} The localized string, or null if stringId is null or
   *     undefined.
   * @protected
   */
  optionalI18n_(stringId) {
    if (!stringId) {
      return null;
    }
    return this.i18n(stringId);
  }

  /**
   * @param {?string} newValue
   * @param {?string} oldValue
   * @private
   */
  regionCodeChanged_(newValue, oldValue) {
    const layout = getKeyboardLayoutForRegionCode(newValue);
    if (!layout) {
      return;
    }

    for (const [evdevCode, glyphs] of layout) {
      // Exclude the lower part of the enter key, which has the data-code
      // attribute for an enter key but shouldn't be labelled.
      const keys = this.root.querySelectorAll(
          `:not(#enterKeyLowerPart)[data-code="${evdevCode}"]`);
      for (const key of keys) {
        if (typeof glyphs === 'string') {
          key.ariaName = null;
          key.topLeftGlyph = null;
          key.topRightGlyph = null;
          key.bottomLeftGlyph = null;
          key.bottomRightGlyph = null;
          key.icon = null;
          key.mainGlyph = glyphs;
        } else {
          key.topLeftGlyph = glyphs.topLeft;
          key.topRightGlyph = glyphs.topRight;
          key.bottomLeftGlyph = glyphs.bottomLeft;
          key.bottomRightGlyph = glyphs.bottomRight;
          key.icon = glyphs.icon;
          key.mainGlyph = glyphs.main;

          if (glyphs.ariaNameI18n) {
            key.ariaName = this.i18n(glyphs.ariaNameI18n);
          }
        }
      }
    }
  }

  /** @private */
  updateHeight_() {
    const width = this.$.keyboard.offsetWidth;
    const widthToHeightRatio = this.showNumberPad ?
        EXTENDED_HEIGHT_TO_WIDTH_RATIO :
        HEIGHT_TO_WIDTH_RATIO;
    const height = Math.max(width * widthToHeightRatio, MINIMUM_HEIGHT_PX);
    this.$.keyboard.style.height = `${height}px`;
  }

  /** @private */
  onResize_() {
    const newWidth = this.$.keyboard.offsetWidth;
    if (newWidth !== this.currentWidth_) {
      this.updateHeight_();
      this.currentWidth_ = newWidth;
    }
  }

  /**
   * Set the state of a given key.
   * @param {number} evdevCode
   * @param {!KeyboardKeyState} state
   */
  setKeyState(evdevCode, state) {
    const keys = this.root.querySelectorAll(`[data-code="${evdevCode}"]`);
    if (keys.length === 0) {
      console.warn(`No keys found for evdev code ${evdevCode}.`);
      return;
    }
    for (const key of keys) {
      key.state = state;
    }
  }

  /**
   * Set the state of a top row key.
   * @param {number} topRowPosition The position of the key on the top row,
   *     where 0 is the first key after escape (which is not counted as part of
   *     the top row).
   * @param {!KeyboardKeyState} state
   */
  setTopRowKeyState(topRowPosition, state) {
    if (topRowPosition < 0 || topRowPosition >= this.topRowKeys.length) {
      throw new RangeError(
          `Invalid top row position ${topRowPosition} ` +
          `>= ${this.topRowKeys.length}`);
    }
    this.$.topRow.children[topRowPosition + 1].state = state;
  }

  /** Set any pressed keys to the "tested" state. */
  clearPressedKeys() {
    const keys = this.root.querySelectorAll(
        `keyboard-key[state="${KeyboardKeyState.PRESSED}"]`);
    for (const key of keys) {
      key.state = KeyboardKeyState.TESTED;
    }
  }

  /** Set all keys to the "not pressed" state. */
  resetAllKeys() {
    const keys = this.root.querySelectorAll(`keyboard-key`);
    for (const key of keys) {
      key.state = KeyboardKeyState.NOT_PRESSED;
    }
  }
}

customElements.define(KeyboardDiagramElement.is, KeyboardDiagramElement);