| // Copyright 2015 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as Host from '../../core/host/host.js'; |
| import * as i18n from '../../core/i18n/i18n.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import * as Protocol from '../../generated/protocol.js'; |
| import * as Geometry from '../geometry/geometry.js'; |
| |
| import { |
| type EmulatedDevice, |
| Horizontal, |
| HorizontalSpanned, |
| type Mode, |
| Vertical, |
| VerticalSpanned, |
| } from './EmulatedDevices.js'; |
| |
| const UIStrings = { |
| /** |
| * @description Error message shown in the Devices settings pane when the user enters an empty |
| * width for a custom device. |
| */ |
| widthCannotBeEmpty: 'Width cannot be empty.', |
| /** |
| * @description Error message shown in the Devices settings pane when the user enters an invalid |
| * width for a custom device. |
| */ |
| widthMustBeANumber: 'Width must be a number.', |
| /** |
| * @description Error message shown in the Devices settings pane when the user has entered a width |
| * for a custom device that is too large. |
| * @example {9999} PH1 |
| */ |
| widthMustBeLessThanOrEqualToS: 'Width must be less than or equal to {PH1}.', |
| /** |
| * @description Error message shown in the Devices settings pane when the user has entered a width |
| * for a custom device that is too small. |
| * @example {50} PH1 |
| */ |
| widthMustBeGreaterThanOrEqualToS: 'Width must be greater than or equal to {PH1}.', |
| /** |
| * @description Error message shown in the Devices settings pane when the user enters an empty |
| * height for a custom device. |
| */ |
| heightCannotBeEmpty: 'Height cannot be empty.', |
| /** |
| * @description Error message shown in the Devices settings pane when the user enters an invalid |
| * height for a custom device. |
| */ |
| heightMustBeANumber: 'Height must be a number.', |
| /** |
| * @description Error message shown in the Devices settings pane when the user has entered a height |
| * for a custom device that is too large. |
| * @example {9999} PH1 |
| */ |
| heightMustBeLessThanOrEqualToS: 'Height must be less than or equal to {PH1}.', |
| /** |
| * @description Error message shown in the Devices settings pane when the user has entered a height |
| * for a custom device that is too small. |
| * @example {50} PH1 |
| */ |
| heightMustBeGreaterThanOrEqualTo: 'Height must be greater than or equal to {PH1}.', |
| /** |
| * @description Error message shown in the Devices settings pane when the user enters an invalid |
| * device pixel ratio for a custom device. |
| */ |
| devicePixelRatioMustBeANumberOr: 'Device pixel ratio must be a number or blank.', |
| /** |
| * @description Error message shown in the Devices settings pane when the user enters a device |
| * pixel ratio for a custom device that is too large. |
| * @example {10} PH1 |
| */ |
| devicePixelRatioMustBeLessThanOr: 'Device pixel ratio must be less than or equal to {PH1}.', |
| /** |
| * @description Error message shown in the Devices settings pane when the user enters a device |
| * pixel ratio for a custom device that is too small. |
| * @example {0} PH1 |
| */ |
| devicePixelRatioMustBeGreater: 'Device pixel ratio must be greater than or equal to {PH1}.', |
| } as const; |
| const str_ = i18n.i18n.registerUIStrings('models/emulation/DeviceModeModel.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| |
| let deviceModeModelInstance: DeviceModeModel|null; |
| |
| export class DeviceModeModel extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements |
| SDK.TargetManager.SDKModelObserver<SDK.EmulationModel.EmulationModel> { |
| #screenRect: Rect; |
| #visiblePageRect: Rect; |
| #availableSize: Geometry.Size; |
| #preferredSize: Geometry.Size; |
| #initialized: boolean; |
| #appliedDeviceSize: Geometry.Size; |
| #appliedDeviceScaleFactor: number; |
| #appliedUserAgentType: UA; |
| readonly #scaleSetting: Common.Settings.Setting<number>; |
| #scale: number; |
| #widthSetting: Common.Settings.Setting<number>; |
| #heightSetting: Common.Settings.Setting<number>; |
| #uaSetting: Common.Settings.Setting<UA>; |
| readonly #deviceScaleFactorSetting: Common.Settings.Setting<number>; |
| readonly #deviceOutlineSetting: Common.Settings.Setting<boolean>; |
| readonly #toolbarControlsEnabledSetting: Common.Settings.Setting<boolean>; |
| #type: Type; |
| #device: EmulatedDevice|null; |
| #mode: Mode|null; |
| #fitScale: number; |
| #touchEnabled: boolean; |
| #touchMobile: boolean; |
| #emulationModel: SDK.EmulationModel.EmulationModel|null; |
| #onModelAvailable: (() => void)|null; |
| #outlineRect?: Rect; |
| |
| private constructor() { |
| super(); |
| this.#screenRect = new Rect(0, 0, 1, 1); |
| this.#visiblePageRect = new Rect(0, 0, 1, 1); |
| this.#availableSize = new Geometry.Size(1, 1); |
| this.#preferredSize = new Geometry.Size(1, 1); |
| this.#initialized = false; |
| this.#appliedDeviceSize = new Geometry.Size(1, 1); |
| this.#appliedDeviceScaleFactor = window.devicePixelRatio; |
| this.#appliedUserAgentType = UA.DESKTOP; |
| |
| this.#scaleSetting = Common.Settings.Settings.instance().createSetting('emulation.device-scale', 1); |
| // We've used to allow zero before. |
| if (!this.#scaleSetting.get()) { |
| this.#scaleSetting.set(1); |
| } |
| this.#scaleSetting.addChangeListener(this.scaleSettingChanged, this); |
| this.#scale = 1; |
| |
| this.#widthSetting = Common.Settings.Settings.instance().createSetting('emulation.device-width', 400); |
| if (this.#widthSetting.get() < MinDeviceSize) { |
| this.#widthSetting.set(MinDeviceSize); |
| } |
| if (this.#widthSetting.get() > MaxDeviceSize) { |
| this.#widthSetting.set(MaxDeviceSize); |
| } |
| this.#widthSetting.addChangeListener(this.widthSettingChanged, this); |
| |
| this.#heightSetting = Common.Settings.Settings.instance().createSetting('emulation.device-height', 0); |
| if (this.#heightSetting.get() && this.#heightSetting.get() < MinDeviceSize) { |
| this.#heightSetting.set(MinDeviceSize); |
| } |
| if (this.#heightSetting.get() > MaxDeviceSize) { |
| this.#heightSetting.set(MaxDeviceSize); |
| } |
| this.#heightSetting.addChangeListener(this.heightSettingChanged, this); |
| |
| this.#uaSetting = Common.Settings.Settings.instance().createSetting('emulation.device-ua', UA.MOBILE); |
| this.#uaSetting.addChangeListener(this.uaSettingChanged, this); |
| this.#deviceScaleFactorSetting = |
| Common.Settings.Settings.instance().createSetting('emulation.device-scale-factor', 0); |
| this.#deviceScaleFactorSetting.addChangeListener(this.deviceScaleFactorSettingChanged, this); |
| |
| this.#deviceOutlineSetting = Common.Settings.Settings.instance().moduleSetting('emulation.show-device-outline'); |
| this.#deviceOutlineSetting.addChangeListener(this.deviceOutlineSettingChanged, this); |
| |
| this.#toolbarControlsEnabledSetting = Common.Settings.Settings.instance().createSetting( |
| 'emulation.toolbar-controls-enabled', true, Common.Settings.SettingStorageType.SESSION); |
| |
| this.#type = Type.None; |
| this.#device = null; |
| this.#mode = null; |
| this.#fitScale = 1; |
| this.#touchEnabled = false; |
| this.#touchMobile = false; |
| |
| this.#emulationModel = null; |
| this.#onModelAvailable = null; |
| SDK.TargetManager.TargetManager.instance().observeModels(SDK.EmulationModel.EmulationModel, this); |
| } |
| |
| static instance(opts?: {forceNew: boolean}): DeviceModeModel { |
| if (!deviceModeModelInstance || opts?.forceNew) { |
| deviceModeModelInstance = new DeviceModeModel(); |
| } |
| |
| return deviceModeModelInstance; |
| } |
| |
| /** |
| * This wraps `instance()` in a try/catch because in some DevTools entry points |
| * (such as worker_app.ts) the Emulation panel is not included and as such |
| * the below code fails; it tries to instantiate the model which requires |
| * reading the value of a setting which has not been registered. |
| * See crbug.com/361515458 for an example bug that this resolves. |
| */ |
| static tryInstance(opts?: {forceNew: boolean}): DeviceModeModel|null { |
| try { |
| return this.instance(opts); |
| } catch { |
| return null; |
| } |
| } |
| |
| static widthValidator(value: string): { |
| valid: boolean, |
| errorMessage: (string|undefined), |
| } { |
| let valid = false; |
| let errorMessage; |
| |
| if (!value) { |
| errorMessage = i18nString(UIStrings.widthCannotBeEmpty); |
| } else if (!/^[\d]+$/.test(value)) { |
| errorMessage = i18nString(UIStrings.widthMustBeANumber); |
| } else if (Number(value) > MaxDeviceSize) { |
| errorMessage = i18nString(UIStrings.widthMustBeLessThanOrEqualToS, {PH1: MaxDeviceSize}); |
| } else if (Number(value) < MinDeviceSize) { |
| errorMessage = i18nString(UIStrings.widthMustBeGreaterThanOrEqualToS, {PH1: MinDeviceSize}); |
| } else { |
| valid = true; |
| } |
| |
| return {valid, errorMessage}; |
| } |
| |
| static heightValidator(value: string): { |
| valid: boolean, |
| errorMessage: (string|undefined), |
| } { |
| let valid = false; |
| let errorMessage; |
| |
| if (!value) { |
| errorMessage = i18nString(UIStrings.heightCannotBeEmpty); |
| } else if (!/^[\d]+$/.test(value)) { |
| errorMessage = i18nString(UIStrings.heightMustBeANumber); |
| } else if (Number(value) > MaxDeviceSize) { |
| errorMessage = i18nString(UIStrings.heightMustBeLessThanOrEqualToS, {PH1: MaxDeviceSize}); |
| } else if (Number(value) < MinDeviceSize) { |
| errorMessage = i18nString(UIStrings.heightMustBeGreaterThanOrEqualTo, {PH1: MinDeviceSize}); |
| } else { |
| valid = true; |
| } |
| |
| return {valid, errorMessage}; |
| } |
| |
| static scaleValidator(value: string): { |
| valid: boolean, |
| errorMessage: (string|undefined), |
| } { |
| let valid = false; |
| let errorMessage; |
| const parsedValue = Number(value.trim()); |
| |
| if (!value) { |
| valid = true; |
| } else if (Number.isNaN(parsedValue)) { |
| errorMessage = i18nString(UIStrings.devicePixelRatioMustBeANumberOr); |
| } else if (Number(value) > MaxDeviceScaleFactor) { |
| errorMessage = i18nString(UIStrings.devicePixelRatioMustBeLessThanOr, {PH1: MaxDeviceScaleFactor}); |
| } else if (Number(value) < MinDeviceScaleFactor) { |
| errorMessage = i18nString(UIStrings.devicePixelRatioMustBeGreater, {PH1: MinDeviceScaleFactor}); |
| } else { |
| valid = true; |
| } |
| |
| return {valid, errorMessage}; |
| } |
| |
| get scaleSettingInternal(): Common.Settings.Setting<number> { |
| return this.#scaleSetting; |
| } |
| |
| setAvailableSize(availableSize: Geometry.Size, preferredSize: Geometry.Size): void { |
| this.#availableSize = availableSize; |
| this.#preferredSize = preferredSize; |
| this.#initialized = true; |
| this.calculateAndEmulate(false); |
| } |
| |
| emulate(type: Type, device: EmulatedDevice|null, mode: Mode|null, scale?: number): void { |
| const resetPageScaleFactor = this.#type !== type || this.#device !== device || this.#mode !== mode; |
| this.#type = type; |
| |
| if (type === Type.Device && device && mode) { |
| console.assert(Boolean(device) && Boolean(mode), 'Must pass device and mode for device emulation'); |
| this.#mode = mode; |
| this.#device = device; |
| if (this.#initialized) { |
| const orientation = device.orientationByName(mode.orientation); |
| this.#scaleSetting.set( |
| scale || |
| this.calculateFitScale(orientation.width, orientation.height, this.currentOutline(), this.currentInsets())); |
| } |
| } else { |
| this.#device = null; |
| this.#mode = null; |
| } |
| |
| if (type !== Type.None) { |
| Host.userMetrics.actionTaken(Host.UserMetrics.Action.DeviceModeEnabled); |
| } |
| this.calculateAndEmulate(resetPageScaleFactor); |
| } |
| |
| setWidth(width: number): void { |
| const max = Math.min(MaxDeviceSize, this.preferredScaledWidth()); |
| width = Math.max(Math.min(width, max), 1); |
| this.#widthSetting.set(width); |
| } |
| |
| setWidthAndScaleToFit(width: number): void { |
| width = Math.max(Math.min(width, MaxDeviceSize), 1); |
| this.#scaleSetting.set(this.calculateFitScale(width, this.#heightSetting.get())); |
| this.#widthSetting.set(width); |
| } |
| |
| setHeight(height: number): void { |
| const max = Math.min(MaxDeviceSize, this.preferredScaledHeight()); |
| height = Math.max(Math.min(height, max), 0); |
| if (height === this.preferredScaledHeight()) { |
| height = 0; |
| } |
| this.#heightSetting.set(height); |
| } |
| |
| setHeightAndScaleToFit(height: number): void { |
| height = Math.max(Math.min(height, MaxDeviceSize), 0); |
| this.#scaleSetting.set(this.calculateFitScale(this.#widthSetting.get(), height)); |
| this.#heightSetting.set(height); |
| } |
| |
| setScale(scale: number): void { |
| this.#scaleSetting.set(scale); |
| } |
| |
| device(): EmulatedDevice|null { |
| return this.#device; |
| } |
| |
| mode(): Mode|null { |
| return this.#mode; |
| } |
| |
| type(): Type { |
| return this.#type; |
| } |
| |
| screenImage(): string { |
| return (this.#device && this.#mode) ? this.#device.modeImage(this.#mode) : ''; |
| } |
| |
| outlineImage(): string { |
| return (this.#device && this.#mode && this.#deviceOutlineSetting.get()) ? this.#device.outlineImage(this.#mode) : |
| ''; |
| } |
| |
| outlineRect(): Rect|null { |
| return this.#outlineRect || null; |
| } |
| |
| screenRect(): Rect { |
| return this.#screenRect; |
| } |
| |
| visiblePageRect(): Rect { |
| return this.#visiblePageRect; |
| } |
| |
| scale(): number { |
| return this.#scale; |
| } |
| |
| fitScale(): number { |
| return this.#fitScale; |
| } |
| |
| appliedDeviceSize(): Geometry.Size { |
| return this.#appliedDeviceSize; |
| } |
| |
| appliedDeviceScaleFactor(): number { |
| return this.#appliedDeviceScaleFactor; |
| } |
| |
| appliedUserAgentType(): UA { |
| return this.#appliedUserAgentType; |
| } |
| |
| isFullHeight(): boolean { |
| return !this.#heightSetting.get(); |
| } |
| |
| isMobile(): boolean { |
| switch (this.#type) { |
| case Type.Device: |
| return this.#device ? this.#device.mobile() : false; |
| case Type.None: |
| return false; |
| case Type.Responsive: |
| return this.#uaSetting.get() === UA.MOBILE || this.#uaSetting.get() === UA.MOBILE_NO_TOUCH; |
| } |
| return false; |
| } |
| |
| enabledSetting(): Common.Settings.Setting<boolean> { |
| return Common.Settings.Settings.instance().createSetting('emulation.show-device-mode', false); |
| } |
| |
| scaleSetting(): Common.Settings.Setting<number> { |
| return this.#scaleSetting; |
| } |
| |
| uaSetting(): Common.Settings.Setting<UA> { |
| return this.#uaSetting; |
| } |
| |
| deviceScaleFactorSetting(): Common.Settings.Setting<number> { |
| return this.#deviceScaleFactorSetting; |
| } |
| |
| deviceOutlineSetting(): Common.Settings.Setting<boolean> { |
| return this.#deviceOutlineSetting; |
| } |
| |
| toolbarControlsEnabledSetting(): Common.Settings.Setting<boolean> { |
| return this.#toolbarControlsEnabledSetting; |
| } |
| |
| reset(): void { |
| this.#deviceScaleFactorSetting.set(0); |
| this.#scaleSetting.set(1); |
| this.setWidth(400); |
| this.setHeight(0); |
| this.#uaSetting.set(UA.MOBILE); |
| } |
| |
| modelAdded(emulationModel: SDK.EmulationModel.EmulationModel): void { |
| if (emulationModel.target() === SDK.TargetManager.TargetManager.instance().primaryPageTarget() && |
| emulationModel.supportsDeviceEmulation()) { |
| this.#emulationModel = emulationModel; |
| if (this.#onModelAvailable) { |
| const callback = this.#onModelAvailable; |
| this.#onModelAvailable = null; |
| callback(); |
| } |
| const resourceTreeModel = emulationModel.target().model(SDK.ResourceTreeModel.ResourceTreeModel); |
| if (resourceTreeModel) { |
| resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.FrameResized, this.onFrameChange, this); |
| resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.FrameNavigated, this.onFrameChange, this); |
| } |
| } else { |
| void emulationModel.emulateTouch(this.#touchEnabled, this.#touchMobile); |
| } |
| } |
| |
| modelRemoved(emulationModel: SDK.EmulationModel.EmulationModel): void { |
| if (this.#emulationModel === emulationModel) { |
| this.#emulationModel = null; |
| } |
| } |
| |
| inspectedURL(): string|null { |
| return this.#emulationModel ? this.#emulationModel.target().inspectedURL() : null; |
| } |
| |
| private onFrameChange(): void { |
| const overlayModel = this.#emulationModel ? this.#emulationModel.overlayModel() : null; |
| if (!overlayModel) { |
| return; |
| } |
| |
| this.showHingeIfApplicable(overlayModel); |
| } |
| |
| private scaleSettingChanged(): void { |
| this.calculateAndEmulate(false); |
| } |
| |
| private widthSettingChanged(): void { |
| this.calculateAndEmulate(false); |
| } |
| |
| private heightSettingChanged(): void { |
| this.calculateAndEmulate(false); |
| } |
| |
| private uaSettingChanged(): void { |
| this.calculateAndEmulate(true); |
| } |
| |
| private deviceScaleFactorSettingChanged(): void { |
| this.calculateAndEmulate(false); |
| } |
| |
| private deviceOutlineSettingChanged(): void { |
| this.calculateAndEmulate(false); |
| } |
| |
| private preferredScaledWidth(): number { |
| return Math.floor(this.#preferredSize.width / (this.#scaleSetting.get() || 1)); |
| } |
| |
| private preferredScaledHeight(): number { |
| return Math.floor(this.#preferredSize.height / (this.#scaleSetting.get() || 1)); |
| } |
| |
| private currentOutline(): Insets { |
| let outline: Insets = new Insets(0, 0, 0, 0); |
| if (this.#type !== Type.Device || !this.#device || !this.#mode) { |
| return outline; |
| } |
| const orientation = this.#device.orientationByName(this.#mode.orientation); |
| if (this.#deviceOutlineSetting.get()) { |
| outline = orientation.outlineInsets || outline; |
| } |
| return outline; |
| } |
| |
| private currentInsets(): Insets { |
| if (this.#type !== Type.Device || !this.#mode) { |
| return new Insets(0, 0, 0, 0); |
| } |
| return this.#mode.insets; |
| } |
| |
| private getScreenOrientationType(): Protocol.Emulation.ScreenOrientationType { |
| if (!this.#mode) { |
| throw new Error('Mode required to get orientation type.'); |
| } |
| switch (this.#mode.orientation) { |
| case VerticalSpanned: |
| case Vertical: |
| return Protocol.Emulation.ScreenOrientationType.PortraitPrimary; |
| case HorizontalSpanned: |
| case Horizontal: |
| default: |
| return Protocol.Emulation.ScreenOrientationType.LandscapePrimary; |
| } |
| } |
| |
| private calculateAndEmulate(resetPageScaleFactor: boolean): void { |
| if (!this.#emulationModel) { |
| this.#onModelAvailable = this.calculateAndEmulate.bind(this, resetPageScaleFactor); |
| } |
| const mobile = this.isMobile(); |
| const overlayModel = this.#emulationModel ? this.#emulationModel.overlayModel() : null; |
| if (overlayModel) { |
| this.showHingeIfApplicable(overlayModel); |
| } |
| if (this.#type === Type.Device && this.#device && this.#mode) { |
| const orientation = this.#device.orientationByName(this.#mode.orientation); |
| const outline = this.currentOutline(); |
| const insets = this.currentInsets(); |
| this.#fitScale = this.calculateFitScale(orientation.width, orientation.height, outline, insets); |
| if (mobile) { |
| this.#appliedUserAgentType = this.#device.touch() ? UA.MOBILE : UA.MOBILE_NO_TOUCH; |
| } else { |
| this.#appliedUserAgentType = this.#device.touch() ? UA.DESKTOP_TOUCH : UA.DESKTOP; |
| } |
| this.applyDeviceMetrics( |
| new Geometry.Size(orientation.width, orientation.height), insets, outline, this.#scaleSetting.get(), |
| this.#device.deviceScaleFactor, mobile, this.getScreenOrientationType(), resetPageScaleFactor); |
| this.applyUserAgent(this.#device.userAgent, this.#device.userAgentMetadata); |
| this.applyTouch(this.#device.touch(), mobile); |
| } else if (this.#type === Type.None) { |
| this.#fitScale = this.calculateFitScale(this.#availableSize.width, this.#availableSize.height); |
| this.#appliedUserAgentType = UA.DESKTOP; |
| this.applyDeviceMetrics( |
| this.#availableSize, new Insets(0, 0, 0, 0), new Insets(0, 0, 0, 0), 1, 0, mobile, null, |
| resetPageScaleFactor); |
| this.applyUserAgent('', null); |
| this.applyTouch(false, false); |
| } else if (this.#type === Type.Responsive) { |
| let screenWidth = this.#widthSetting.get(); |
| if (!screenWidth || screenWidth > this.preferredScaledWidth()) { |
| screenWidth = this.preferredScaledWidth(); |
| } |
| let screenHeight = this.#heightSetting.get(); |
| if (!screenHeight || screenHeight > this.preferredScaledHeight()) { |
| screenHeight = this.preferredScaledHeight(); |
| } |
| const defaultDeviceScaleFactor = mobile ? defaultMobileScaleFactor : 0; |
| this.#fitScale = this.calculateFitScale(this.#widthSetting.get(), this.#heightSetting.get()); |
| this.#appliedUserAgentType = this.#uaSetting.get(); |
| this.applyDeviceMetrics( |
| new Geometry.Size(screenWidth, screenHeight), new Insets(0, 0, 0, 0), new Insets(0, 0, 0, 0), |
| this.#scaleSetting.get(), this.#deviceScaleFactorSetting.get() || defaultDeviceScaleFactor, mobile, |
| screenHeight >= screenWidth ? Protocol.Emulation.ScreenOrientationType.PortraitPrimary : |
| Protocol.Emulation.ScreenOrientationType.LandscapePrimary, |
| resetPageScaleFactor); |
| this.applyUserAgent(mobile ? defaultMobileUserAgent : '', mobile ? defaultMobileUserAgentMetadata : null); |
| this.applyTouch( |
| this.#uaSetting.get() === UA.DESKTOP_TOUCH || this.#uaSetting.get() === UA.MOBILE, |
| this.#uaSetting.get() === UA.MOBILE); |
| } |
| |
| if (overlayModel) { |
| overlayModel.setShowViewportSizeOnResize(this.#type === Type.None); |
| } |
| this.dispatchEventToListeners(Events.UPDATED); |
| } |
| |
| private calculateFitScale(screenWidth: number, screenHeight: number, outline?: Insets, insets?: Insets): number { |
| const outlineWidth = outline ? outline.left + outline.right : 0; |
| const outlineHeight = outline ? outline.top + outline.bottom : 0; |
| const insetsWidth = insets ? insets.left + insets.right : 0; |
| const insetsHeight = insets ? insets.top + insets.bottom : 0; |
| let scale = Math.min( |
| screenWidth ? this.#preferredSize.width / (screenWidth + outlineWidth) : 1, |
| screenHeight ? this.#preferredSize.height / (screenHeight + outlineHeight) : 1); |
| scale = Math.min(Math.floor(scale * 100), 100); |
| |
| let sharpScale = scale; |
| while (sharpScale > scale * 0.7) { |
| let sharp = true; |
| if (screenWidth) { |
| sharp = sharp && Number.isInteger((screenWidth - insetsWidth) * sharpScale / 100); |
| } |
| if (screenHeight) { |
| sharp = sharp && Number.isInteger((screenHeight - insetsHeight) * sharpScale / 100); |
| } |
| if (sharp) { |
| return sharpScale / 100; |
| } |
| sharpScale -= 1; |
| } |
| return scale / 100; |
| } |
| |
| setSizeAndScaleToFit(width: number, height: number): void { |
| this.#scaleSetting.set(this.calculateFitScale(width, height)); |
| this.setWidth(width); |
| this.setHeight(height); |
| } |
| |
| private applyUserAgent(userAgent: string, userAgentMetadata: Protocol.Emulation.UserAgentMetadata|null): void { |
| SDK.NetworkManager.MultitargetNetworkManager.instance().setUserAgentOverride(userAgent, userAgentMetadata); |
| } |
| |
| private applyDeviceMetrics( |
| screenSize: Geometry.Size, insets: Insets, outline: Insets, scale: number, deviceScaleFactor: number, |
| mobile: boolean, screenOrientation: Protocol.Emulation.ScreenOrientationType|null, |
| resetPageScaleFactor: boolean): void { |
| screenSize.width = Math.max(1, Math.floor(screenSize.width)); |
| screenSize.height = Math.max(1, Math.floor(screenSize.height)); |
| |
| let pageWidth: 0|number = screenSize.width - insets.left - insets.right; |
| let pageHeight: 0|number = screenSize.height - insets.top - insets.bottom; |
| |
| const positionX = insets.left; |
| const positionY = insets.top; |
| const screenOrientationAngle = |
| screenOrientation === Protocol.Emulation.ScreenOrientationType.LandscapePrimary ? 90 : 0; |
| |
| this.#appliedDeviceSize = screenSize; |
| this.#appliedDeviceScaleFactor = deviceScaleFactor || window.devicePixelRatio; |
| this.#screenRect = new Rect( |
| Math.max(0, (this.#availableSize.width - screenSize.width * scale) / 2), outline.top * scale, |
| screenSize.width * scale, screenSize.height * scale); |
| this.#outlineRect = new Rect( |
| this.#screenRect.left - outline.left * scale, 0, (outline.left + screenSize.width + outline.right) * scale, |
| (outline.top + screenSize.height + outline.bottom) * scale); |
| this.#visiblePageRect = new Rect( |
| positionX * scale, positionY * scale, |
| Math.min(pageWidth * scale, this.#availableSize.width - this.#screenRect.left - positionX * scale), |
| Math.min(pageHeight * scale, this.#availableSize.height - this.#screenRect.top - positionY * scale)); |
| this.#scale = scale; |
| const displayFeature = this.getDisplayFeature(); |
| if (!displayFeature) { |
| // When sending displayFeature, we cannot use the optimization below due to backend restrictions. |
| if (scale === 1 && this.#availableSize.width >= screenSize.width && |
| this.#availableSize.height >= screenSize.height) { |
| // When we have enough space, no page size override is required. This will speed things up and remove lag. |
| pageWidth = 0; |
| pageHeight = 0; |
| } |
| if (this.#visiblePageRect.width === pageWidth * scale && this.#visiblePageRect.height === pageHeight * scale && |
| Number.isInteger(pageWidth * scale) && Number.isInteger(pageHeight * scale)) { |
| // When we only have to apply scale, do not resize the page. This will speed things up and remove lag. |
| pageWidth = 0; |
| pageHeight = 0; |
| } |
| } |
| |
| if (!this.#emulationModel) { |
| return; |
| } |
| |
| if (resetPageScaleFactor) { |
| void this.#emulationModel.resetPageScaleFactor(); |
| } |
| if (pageWidth || pageHeight || mobile || deviceScaleFactor || scale !== 1 || screenOrientation || displayFeature) { |
| const metrics: Protocol.Emulation.SetDeviceMetricsOverrideRequest = { |
| width: pageWidth, |
| height: pageHeight, |
| deviceScaleFactor, |
| mobile, |
| scale, |
| screenWidth: screenSize.width, |
| screenHeight: screenSize.height, |
| positionX, |
| positionY, |
| dontSetVisibleSize: true, |
| displayFeature: undefined, |
| devicePosture: undefined, |
| screenOrientation: undefined, |
| }; |
| if (displayFeature) { |
| metrics.displayFeature = displayFeature; |
| metrics.devicePosture = {type: Protocol.Emulation.DevicePostureType.Folded}; |
| } else { |
| metrics.devicePosture = {type: Protocol.Emulation.DevicePostureType.Continuous}; |
| } |
| if (screenOrientation) { |
| metrics.screenOrientation = {type: screenOrientation, angle: screenOrientationAngle}; |
| } |
| void this.#emulationModel.emulateDevice(metrics); |
| } else { |
| void this.#emulationModel.emulateDevice(null); |
| } |
| } |
| |
| exitHingeMode(): void { |
| const overlayModel = this.#emulationModel ? this.#emulationModel.overlayModel() : null; |
| if (overlayModel) { |
| overlayModel.showHingeForDualScreen(null); |
| } |
| } |
| |
| async captureScreenshot(fullSize: boolean, clip?: Protocol.Page.Viewport): Promise<string|null> { |
| const screenCaptureModel = |
| this.#emulationModel ? this.#emulationModel.target().model(SDK.ScreenCaptureModel.ScreenCaptureModel) : null; |
| if (!screenCaptureModel) { |
| return null; |
| } |
| |
| let screenshotMode; |
| if (clip) { |
| screenshotMode = SDK.ScreenCaptureModel.ScreenshotMode.FROM_CLIP; |
| } else if (fullSize) { |
| screenshotMode = SDK.ScreenCaptureModel.ScreenshotMode.FULLPAGE; |
| } else { |
| screenshotMode = SDK.ScreenCaptureModel.ScreenshotMode.FROM_VIEWPORT; |
| } |
| |
| const overlayModel = this.#emulationModel ? this.#emulationModel.overlayModel() : null; |
| if (overlayModel) { |
| overlayModel.setShowViewportSizeOnResize(false); |
| } |
| |
| const screenshot = await screenCaptureModel.captureScreenshot( |
| Protocol.Page.CaptureScreenshotRequestFormat.Png, 100, screenshotMode, clip); |
| |
| const deviceMetrics: Protocol.Page.SetDeviceMetricsOverrideRequest = { |
| width: 0, |
| height: 0, |
| deviceScaleFactor: 0, |
| mobile: false, |
| }; |
| if (fullSize && this.#emulationModel) { |
| if (this.#device && this.#mode) { |
| const orientation = this.#device.orientationByName(this.#mode.orientation); |
| deviceMetrics.width = orientation.width; |
| deviceMetrics.height = orientation.height; |
| const dispFeature = this.getDisplayFeature(); |
| if (dispFeature) { |
| // @ts-expect-error: displayFeature isn't in protocol.ts but is an |
| // experimental flag: |
| // https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setDeviceMetricsOverride |
| deviceMetrics.displayFeature = dispFeature; |
| } |
| } else { |
| deviceMetrics.width = 0; |
| deviceMetrics.height = 0; |
| } |
| await this.#emulationModel.emulateDevice(deviceMetrics); |
| } |
| this.calculateAndEmulate(false); |
| return screenshot; |
| } |
| |
| private applyTouch(touchEnabled: boolean, mobile: boolean): void { |
| this.#touchEnabled = touchEnabled; |
| this.#touchMobile = mobile; |
| for (const emulationModel of SDK.TargetManager.TargetManager.instance().models(SDK.EmulationModel.EmulationModel)) { |
| void emulationModel.emulateTouch(touchEnabled, mobile); |
| } |
| } |
| |
| private showHingeIfApplicable(overlayModel: SDK.OverlayModel.OverlayModel): void { |
| const orientation = (this.#device && this.#mode) ? this.#device.orientationByName(this.#mode.orientation) : null; |
| if (orientation?.hinge) { |
| overlayModel.showHingeForDualScreen(orientation.hinge); |
| return; |
| } |
| |
| overlayModel.showHingeForDualScreen(null); |
| } |
| |
| private getDisplayFeatureOrientation(): Protocol.Emulation.DisplayFeatureOrientation { |
| if (!this.#mode) { |
| throw new Error('Mode required to get display feature orientation.'); |
| } |
| switch (this.#mode.orientation) { |
| case VerticalSpanned: |
| case Vertical: |
| return Protocol.Emulation.DisplayFeatureOrientation.Vertical; |
| case HorizontalSpanned: |
| case Horizontal: |
| default: |
| return Protocol.Emulation.DisplayFeatureOrientation.Horizontal; |
| } |
| } |
| |
| private getDisplayFeature(): Protocol.Emulation.DisplayFeature|null { |
| if (!this.#device || !this.#mode || |
| (this.#mode.orientation !== VerticalSpanned && this.#mode.orientation !== HorizontalSpanned)) { |
| return null; |
| } |
| |
| const orientation = this.#device.orientationByName(this.#mode.orientation); |
| if (!orientation?.hinge) { |
| return null; |
| } |
| |
| const hinge = orientation.hinge; |
| return { |
| orientation: this.getDisplayFeatureOrientation(), |
| offset: (this.#mode.orientation === VerticalSpanned) ? hinge.x : hinge.y, |
| maskLength: (this.#mode.orientation === VerticalSpanned) ? hinge.width : hinge.height, |
| }; |
| } |
| } |
| |
| export class Insets { |
| constructor(public left: number, public top: number, public right: number, public bottom: number) { |
| } |
| |
| isEqual(insets: Insets|null): boolean { |
| return insets !== null && this.left === insets.left && this.top === insets.top && this.right === insets.right && |
| this.bottom === insets.bottom; |
| } |
| } |
| |
| export class Rect { |
| constructor(public left: number, public top: number, public width: number, public height: number) { |
| } |
| |
| isEqual(rect: Rect|null): boolean { |
| return rect !== null && this.left === rect.left && this.top === rect.top && this.width === rect.width && |
| this.height === rect.height; |
| } |
| |
| scale(scale: number): Rect { |
| return new Rect(this.left * scale, this.top * scale, this.width * scale, this.height * scale); |
| } |
| |
| relativeTo(origin: Rect): Rect { |
| return new Rect(this.left - origin.left, this.top - origin.top, this.width, this.height); |
| } |
| |
| rebaseTo(origin: Rect): Rect { |
| return new Rect(this.left + origin.left, this.top + origin.top, this.width, this.height); |
| } |
| } |
| |
| export const enum Events { |
| UPDATED = 'Updated', |
| } |
| |
| export interface EventTypes { |
| [Events.UPDATED]: void; |
| } |
| |
| export enum Type { |
| /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */ |
| None = 'None', |
| Responsive = 'Responsive', |
| Device = 'Device', |
| /* eslint-enable @typescript-eslint/naming-convention */ |
| } |
| |
| export const enum UA { |
| // TODO(crbug.com/1136655): This enum is used for both display and code functionality. |
| // we should refactor this so localization of these strings only happens for user display. |
| MOBILE = 'Mobile', |
| MOBILE_NO_TOUCH = 'Mobile (no touch)', |
| DESKTOP = 'Desktop', |
| DESKTOP_TOUCH = 'Desktop (touch)', |
| } |
| |
| export const MinDeviceSize = 50; |
| export const MaxDeviceSize = 9999; |
| export const MinDeviceScaleFactor = 0; |
| export const MaxDeviceScaleFactor = 10; |
| export const MaxDeviceNameLength = 50; |
| |
| const mobileUserAgent = |
| 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Mobile Safari/537.36'; |
| const defaultMobileUserAgent = |
| SDK.NetworkManager.MultitargetNetworkManager.patchUserAgentWithChromeVersion(mobileUserAgent); |
| |
| const defaultMobileUserAgentMetadata = { |
| platform: 'Android', |
| platformVersion: '6.0', |
| architecture: '', |
| model: 'Nexus 5', |
| mobile: true, |
| }; |
| export const defaultMobileScaleFactor = 2; |