| // 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 type {I18nMixinInterface} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js'; |
| import {I18nMixin} 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 type {Constructor} from '../common/types.js'; |
| |
| import type {DevicePageBrowserProxy} from './device_page_browser_proxy.js'; |
| import {DevicePageBrowserProxyImpl} from './device_page_browser_proxy.js'; |
| import {getTemplate} from './display_layout.html.js'; |
| import type {LayoutMixinInterface, Position} from './layout_mixin.js'; |
| import {LayoutMixin} 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); |