chromium/chrome/browser/resources/ash/settings/device_page/display_layout.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.

/**
 * @fileoverview
 * 'display-layout' presents a visual representation of the layout of one or
 * more displays and allows them to be arranged.
 */

import '../settings_shared.css.js';

import {getInstance as getAnnouncerInstance} from 'chrome://resources/ash/common/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {I18nMixin, I18nMixinInterface} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {strictQuery} from 'chrome://resources/ash/common/typescript_utils/strict_query.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {IronResizableBehavior} from 'chrome://resources/polymer/v3_0/iron-resizable-behavior/iron-resizable-behavior.js';
import {mixinBehaviors, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {castExists} from '../assert_extras.js';
import {Constructor} from '../common/types.js';

import {DevicePageBrowserProxy, DevicePageBrowserProxyImpl} from './device_page_browser_proxy.js';
import {getTemplate} from './display_layout.html.js';
import {LayoutMixin, LayoutMixinInterface, Position} from './layout_mixin.js';

import Bounds = chrome.system.display.Bounds;
import DisplayLayout = chrome.system.display.DisplayLayout;
import DisplayUnitInfo = chrome.system.display.DisplayUnitInfo;

/**
 * Container for DisplayUnitInfo.  Mostly here to make the DisplaySelectEvent
 * typedef more readable.
 */
interface InfoItem {
  item: DisplayUnitInfo;
}

/**
 * Required member fields for events which select displays.
 */
interface DisplaySelectEvent {
  model: InfoItem;
  target: HTMLElement;
}

const MIN_VISUAL_SCALE = .01;

export interface DisplayLayoutElement {
  $: {
    displayArea: HTMLElement,
  };
}

const DisplayLayoutElementBase =
    mixinBehaviors(
        [IronResizableBehavior], LayoutMixin(I18nMixin(PolymerElement))) as
    Constructor<PolymerElement&I18nMixinInterface&LayoutMixinInterface>;

export class DisplayLayoutElement extends DisplayLayoutElementBase {
  static get is() {
    return 'display-layout';
  }

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

  static get properties() {
    return {
      /**
       * Array of displays.
       */
      displays: Array,

      selectedDisplay: Object,

      /**
       * The ratio of the display area div (in px) to DisplayUnitInfo.bounds.
       */
      visualScale: {
        type: Number,
        value: 1,
      },

      /**
       * Ids for mirroring destination displays.
       */
      mirroringDestinationIds_: Array,
    };
  }

  displays: DisplayUnitInfo[];
  selectedDisplay?: DisplayUnitInfo;
  visualScale: number;
  private allowDisplayAlignmentApi_: boolean;
  private browserProxy_: DevicePageBrowserProxy;
  private hasDragStarted_: boolean;
  private invalidDisplayId_: string;
  private lastDragCoordinates_: {x: number, y: number}|null;
  private mirroringDestinationIds_: string[];
  private visualOffset_: {left: number, top: number};

  constructor() {
    super();

    this.visualOffset_ = {left: 0, top: 0};

    /**
     * Stores the previous coordinates of a display once dragging starts. Used
     * to calculate the delta during each step of the drag. Null when there is
     * no drag in progress.
     */
    this.lastDragCoordinates_ = null;

    this.browserProxy_ = DevicePageBrowserProxyImpl.getInstance();

    this.allowDisplayAlignmentApi_ =
        loadTimeData.getBoolean('allowDisplayAlignmentApi');

    this.invalidDisplayId_ = loadTimeData.getString('invalidDisplayId');

    this.hasDragStarted_ = false;

    this.mirroringDestinationIds_ = [];
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();

    this.initializeDrag(false);
  }

  /**
   * Called explicitly when |this.displays| and their associated |this.layouts|
   * have been fetched from chrome.
   */
  updateDisplays(
      displays: DisplayUnitInfo[], layouts: DisplayLayout[],
      mirroringDestinationIds: string[]): void {
    this.displays = displays;
    this.layouts = layouts;
    this.mirroringDestinationIds_ = mirroringDestinationIds;

    this.initializeDisplayLayout(displays, layouts);

    const self = this;
    const retry = 100;  // ms
    function tryCalcVisualScale(): void {
      if (!self.calculateVisualScale_()) {
        setTimeout(tryCalcVisualScale, retry);
      }
    }
    tryCalcVisualScale();

    // Enable keyboard dragging before initialization.
    this.keyboardDragEnabled = true;
    this.initializeDrag(
        !this.mirroring, this.$.displayArea,
        (id, amount) => this.onDrag_(id, amount));
  }

  /**
   * Calculates the visual offset and scale for the display area
   * (i.e. the ratio of the display area div size to the area required to
   * contain the DisplayUnitInfo bounding boxes).
   * @return Whether the calculation was successful.
   */
  private calculateVisualScale_(): boolean {
    const displayAreaDiv = this.$.displayArea;
    if (!displayAreaDiv || !displayAreaDiv.offsetWidth || !this.displays ||
        !this.displays.length) {
      return false;
    }

    let display = this.displays[0];
    let bounds = this.getCalculatedDisplayBounds(display.id);
    const boundsBoundingBox = {
      left: bounds.left,
      right: bounds.left + bounds.width,
      top: bounds.top,
      bottom: bounds.top + bounds.height,
    };
    let maxWidth = bounds.width;
    let maxHeight = bounds.height;
    for (let i = 1; i < this.displays.length; ++i) {
      display = this.displays[i];
      bounds = this.getCalculatedDisplayBounds(display.id);
      boundsBoundingBox.left = Math.min(boundsBoundingBox.left, bounds.left);
      boundsBoundingBox.right =
          Math.max(boundsBoundingBox.right, bounds.left + bounds.width);
      boundsBoundingBox.top = Math.min(boundsBoundingBox.top, bounds.top);
      boundsBoundingBox.bottom =
          Math.max(boundsBoundingBox.bottom, bounds.top + bounds.height);
      maxWidth = Math.max(maxWidth, bounds.width);
      maxHeight = Math.max(maxHeight, bounds.height);
    }

    // Create a margin around the bounding box equal to the size of the
    // largest displays.
    const boundsWidth = boundsBoundingBox.right - boundsBoundingBox.left;
    const boundsHeight = boundsBoundingBox.bottom - boundsBoundingBox.top;

    // Calculate the scale.
    const horizontalScale =
        displayAreaDiv.offsetWidth / (boundsWidth + maxWidth * 2);
    const verticalScale =
        displayAreaDiv.offsetHeight / (boundsHeight + maxHeight * 2);
    const scale = Math.min(horizontalScale, verticalScale);

    // Calculate the offset.
    this.visualOffset_.left =
        ((displayAreaDiv.offsetWidth - (boundsWidth * scale)) / 2) -
        boundsBoundingBox.left * scale;
    this.visualOffset_.top =
        ((displayAreaDiv.offsetHeight - (boundsHeight * scale)) / 2) -
        boundsBoundingBox.top * scale;

    // Update the scale which will trigger calls to getDivStyle_.
    this.visualScale = Math.max(MIN_VISUAL_SCALE, scale);

    return true;
  }

  private getDivStyle_(
      id: string, _displayBounds: Bounds, _visualScale: number,
      offset?: number): string {
    // This matches the size of the box-shadow or border in CSS.
    const BORDER = 1;
    const MARGIN = 4;
    const OFFSET = offset || 0;
    const PADDING = 3;
    const bounds = this.getCalculatedDisplayBounds(id, /* notest */ true);
    if (!bounds) {
      return '';
    }
    const height = Math.round(bounds.height * this.visualScale) - BORDER * 2 -
        MARGIN * 2 - PADDING * 2;
    const width = Math.round(bounds.width * this.visualScale) - BORDER * 2 -
        MARGIN * 2 - PADDING * 2;
    const left = OFFSET +
        Math.round(this.visualOffset_.left + (bounds.left * this.visualScale));
    const top = OFFSET +
        Math.round(this.visualOffset_.top + (bounds.top * this.visualScale));
    return 'height: ' + height + 'px; width: ' + width + 'px;' +
        ' left: ' + left + 'px; top: ' + top + 'px';
  }

  private getMirrorDivStyle_(
      mirroringDestinationIndex: number, mirroringDestinationDisplayNum: number,
      displays: DisplayUnitInfo[], visualScale: number): string {
    // All destination displays have the same bounds as the mirroring source
    // display, but we add a little offset to each destination display's bounds
    // so that they can be distinguished from each other in the layout.
    return this.getDivStyle_(
        displays[0].id, displays[0].bounds, visualScale,
        (mirroringDestinationDisplayNum - mirroringDestinationIndex) * -4);
  }

  private isSelected_(
      display: DisplayUnitInfo, selectedDisplay: DisplayUnitInfo): boolean {
    return display.id === selectedDisplay.id;
  }

  private dispatchSelectDisplayEvent_(displayId: DisplayUnitInfo['id']): void {
    const selectDisplayEvent =
        new CustomEvent('select-display', {composed: true, detail: displayId});
    this.dispatchEvent(selectDisplayEvent);
  }

  private onSelectDisplayClick_(e: DisplaySelectEvent): void {
    this.dispatchSelectDisplayEvent_(e.model.item.id);
    // Keep focused display in-sync with clicked display
    e.target.focus();
  }

  private onFocus_(e: DisplaySelectEvent): void {
    this.dispatchSelectDisplayEvent_(e.model.item.id);
    e.target.focus();
  }

  // Gets the display window position change announcement for a11y.
  private getPositionChangeAnnouncement_(deltaX: number, deltaY: number):
      string {
    let description = '';
    // Position was moved in both X and Y direction.
    if (deltaX !== 0 && deltaY !== 0) {
      if (deltaY > 0 && deltaX > 0) {
        description = 'displayPositionDownAndRight';
      } else if (deltaY > 0 && deltaX < 0) {
        description = 'displayPositionDownAndLeft';
      } else if (deltaY < 0 && deltaX > 0) {
        description = 'displayPositionUpAndRight';
      } else if (deltaY < 0 && deltaX < 0) {
        description = 'displayPositionUpAndLeft';
      }
    } else {
      // Position was moved in only one direction, either X or Y.
      if (deltaY > 0) {
        description = 'displayPositionDown';
      } else if (deltaY < 0) {
        description = 'displayPositionUp';
      } else if (deltaX > 0) {
        description = 'displayPositionRight';
      } else if (deltaX < 0) {
        description = 'displayPositionLeft';
      }
    }
    return this.i18n(description);
  }

  private onDrag_(id: string, amount: Position|null): void {
    id = id.substr(1);  // Skip prefix

    let newBounds: Bounds;
    if (!amount) {
      this.finishUpdateDisplayBounds(id);
      newBounds = this.getCalculatedDisplayBounds(id);
      this.lastDragCoordinates_ = null;
      // When the drag stops, remove the highlight around the display.
      this.browserProxy_.highlightDisplay(this.invalidDisplayId_);
    } else {
      this.browserProxy_.highlightDisplay(id);
      // Make sure the dragged display is also selected.
      if (id !== this.selectedDisplay!.id) {
        this.dispatchSelectDisplayEvent_(id);
      }

      const calculatedBounds = this.getCalculatedDisplayBounds(id);
      newBounds = {...calculatedBounds};
      newBounds.left += Math.round(amount.x / this.visualScale);
      newBounds.top += Math.round(amount.y / this.visualScale);

      if (this.displays.length >= 2) {
        newBounds = this.updateDisplayBounds(id, newBounds);
      }

      if (!this.lastDragCoordinates_) {
        this.hasDragStarted_ = true;
        this.lastDragCoordinates_ = {
          x: calculatedBounds.left,
          y: calculatedBounds.top,
        };
      }

      const deltaX = newBounds.left - this.lastDragCoordinates_.x;
      const deltaY = newBounds.top - this.lastDragCoordinates_.y;

      this.lastDragCoordinates_.x = newBounds.left;
      this.lastDragCoordinates_.y = newBounds.top;

      // Only call dragDisplayDelta() when there is a change in position.
      if (deltaX !== 0 || deltaY !== 0) {
        if (this.allowDisplayAlignmentApi_) {
          this.browserProxy_.dragDisplayDelta(
              id, Math.round(deltaX), Math.round(deltaY));
        }

        // Add ChromeVox announcement.
        const announcer = getAnnouncerInstance(this.$.displayArea);
        // Remove "role = alert" to avoid chromevox announcing "alert" before
        // message.
        strictQuery('#messages', announcer.shadowRoot, HTMLDivElement)
            .removeAttribute('role');
        // Announce the messages.
        announcer.announce(this.getPositionChangeAnnouncement_(deltaX, deltaY));
      }
    }

    const left =
        this.visualOffset_.left + Math.round(newBounds.left * this.visualScale);
    const top =
        this.visualOffset_.top + Math.round(newBounds.top * this.visualScale);
    const div = castExists(this.shadowRoot!.getElementById(`_${id}`));
    div.style.left = '' + left + 'px';
    div.style.top = '' + top + 'px';
    div.focus();
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'display-layout': DisplayLayoutElement;
  }
}

customElements.define(DisplayLayoutElement.is, DisplayLayoutElement);