UNPKG

chrome-devtools-frontend

Version:
1,025 lines (931 loc) • 48.3 kB
// 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. /* eslint-disable @devtools/no-imperative-dom-api */ import '../../../ui/kit/kit.js'; import * as i18n from '../../../core/i18n/i18n.js'; import * as EmulationModel from '../../../models/emulation/emulation.js'; import type * as Buttons from '../../../ui/components/buttons/buttons.js'; import * as UI from '../../../ui/legacy/legacy.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import * as EmulationComponents from './components/components.js'; import devicesSettingsTabStyles from './devicesSettingsTab.css.js'; const UIStrings = { /** * @description Title for a section of the UI that shows all of the custom devices the user can emulate, in the device toolbar. */ customDevices: 'Custom devices', /** * @description Title for a section of the UI that shows all of the default devices the user can emulate, in the device toolbar. */ defaultDevices: 'Default devices', /** * @description Button to add a custom device (e.g., phone, tablet) to the device toolbar. */ addCustomDevice: 'Add custom device', /** * @description Label/title for UI to add a new custom device type. Device means mobile/tablet, etc. */ device: 'Device', /** * @description Placeholder for text input for the name of a custom device. */ deviceName: 'Device name', /** * @description Placeholder text for text input for the width of a custom device in pixels. */ width: 'Width', /** * @description Placeholder text for text input for the height of a custom device in pixels. */ height: 'Height', /** * @description Placeholder text for text input for the height/width ratio of a custom device in pixels. */ devicePixelRatio: 'Device pixel ratio', /** * @description Label in the Devices settings tab for the user agent string input of a custom device. */ userAgentString: 'User agent string', /** * @description Tooltip text for a drop-down in the Devices settings tab for the user agent type input of a custom device. * 'Type' refers to different options, such as mobile or desktop. */ userAgentType: 'User agent type', /** * @description Error message in the Devices settings tab that declares the maximum length of the device name input. * @example {50} PH1 */ deviceNameMustBeLessThanS: 'Device name must be less than {PH1} characters.', /** * @description Error message in the Devices settings tab that declares that the device name input must not be empty. */ deviceNameCannotBeEmpty: 'Device name can’t be empty.', /** * @description Success message for screen readers when device is added. * @example {TestDevice} PH1 */ deviceAddedOrUpdated: 'Device {PH1} successfully added/updated.', /** * @description Error message in the Devices settings tab shown when the user agent string is empty. */ userAgentStringCannotBeEmpty: 'User agent string can’t be empty.', /** * @description Label for portrait safe-area values on a custom device. */ portraitSafeArea: 'Portrait safe area', /** * @description Label for landscape safe-area values on a custom device. */ landscapeSafeArea: 'Landscape safe area', /** * @description Placeholder text for a custom device safe-area left inset field. */ safeAreaLeft: 'Left inset', /** * @description Placeholder text for a custom device safe-area top inset field. */ safeAreaTop: 'Top inset', /** * @description Placeholder text for a custom device safe-area right inset field. */ safeAreaRight: 'Right inset', /** * @description Placeholder text for a custom device safe-area bottom inset field. */ safeAreaBottom: 'Bottom inset', /** * @description Error message shown when a custom device safe-area value is invalid. * @example {Portrait safe area} PH1 * @example {Top inset} PH2 * @example {9999} PH3 */ safeAreaValueMustBeInRange: '{PH1}: {PH2} must be an integer from 0 to {PH3}.', /** * @description Error message shown when custom device safe-area left and right insets are too large. * @example {Portrait safe area} PH1 */ safeAreaHorizontalInsetsExceedWidth: '{PH1}: Left and right insets must not exceed the device width.', /** * @description Error message shown when custom device safe-area top and bottom insets are too large. * @example {Landscape safe area} PH1 */ safeAreaVerticalInsetsExceedHeight: '{PH1}: Top and bottom insets must not exceed the device height.', /** * @description Label for display cutout values on a custom device. */ displayCutout: 'Display cutout', /** * @description Option shown when a custom device has no display cutout. */ noDisplayCutout: 'No cutout', /** * @description Option shown for pill-shaped display cutouts such as Dynamic Island. */ pillDisplayCutout: 'Pill', /** * @description Option shown for classic notch display cutouts. */ notchDisplayCutout: 'Notch', /** * @description Option shown for circular display cutouts such as hole-punch cameras. */ circleDisplayCutout: 'Circle', /** * @description Option shown for rectangular display cutouts. */ rectangleDisplayCutout: 'Rectangle', /** * @description Placeholder text for a custom device display cutout x coordinate field. */ cutoutX: 'Cutout x', /** * @description Placeholder text for a custom device display cutout y coordinate field. */ cutoutY: 'Cutout y', /** * @description Placeholder text for a custom device display cutout width field. */ cutoutWidth: 'Cutout width', /** * @description Placeholder text for a custom device display cutout height field. */ cutoutHeight: 'Cutout height', /** * @description Placeholder text for a custom device pill-shaped display cutout radius field. */ cutoutBorderRadius: 'Pill radius', /** * @description Placeholder text for a custom device notch upper radius field. */ cutoutUpperRadius: 'Upper radius', /** * @description Placeholder text for a custom device notch lower radius field. */ cutoutLowerRadius: 'Lower radius', /** * @description Placeholder text for a custom device circular display cutout center x coordinate field. */ cutoutCenterX: 'Center x', /** * @description Placeholder text for a custom device circular display cutout center y coordinate field. */ cutoutCenterY: 'Center y', /** * @description Placeholder text for a custom device circular display cutout radius field. */ cutoutRadius: 'Radius', /** * @description Error message shown when a custom display cutout field is required. * @example {Cutout width} PH1 */ cutoutFieldRequired: '{PH1} is required when display cutout is enabled.', /** * @description Error message shown when a custom display cutout field is outside its supported integer range. * @example {Cutout x} PH1 * @example {9999} PH2 */ cutoutValueMustBeInRange: '{PH1} must be an integer from 0 to {PH2}.', /** * @description Error message shown when a custom display cutout field must be positive. * @example {Cutout width} PH1 */ cutoutValueMustBePositiveInteger: '{PH1} must be a positive integer.', /** * @description Error message shown when a custom display cutout's x coordinate plus its width exceeds the device width. */ cutoutXAndWidthExceedDeviceWidth: 'Cutout x plus width must not exceed the device width.', /** * @description Error message shown when a custom display cutout's y coordinate plus its height exceeds the device height. */ cutoutYAndHeightExceedDeviceHeight: 'Cutout y plus height must not exceed the device height.', /** * @description Error message shown when a circular display cutout is outside its cutout bounds. */ circleMustFitCutoutBounds: 'Circle must fit within the cutout bounds.', } as const; const str_ = i18n.i18n.registerUIStrings('panels/settings/emulation/DevicesSettingsTab.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); function parseOptionalNonNegativeInteger(value: string): number|null { const trimmedValue = value.trim(); if (!trimmedValue) { return 0; } if (!/^\d+$/.test(trimmedValue)) { return null; } const parsedValue = Number(trimmedValue); if (!Number.isSafeInteger(parsedValue) || parsedValue < 0 || parsedValue > EmulationModel.DeviceModeModel.MaxDeviceSize) { return null; } return parsedValue; } const NO_CUSTOM_CUTOUT = 'none'; type DeviceEditorValidator = (item: EmulationModel.EmulatedDevices.EmulatedDevice, index: number, input: UI.ListWidget.EditorControl) => UI.ListWidget.ValidatorResult; interface CutoutFieldValidators { shape: DeviceEditorValidator; x: DeviceEditorValidator; y: DeviceEditorValidator; width: DeviceEditorValidator; height: DeviceEditorValidator; pillRadius: DeviceEditorValidator; notchUpperRadius: DeviceEditorValidator; notchLowerRadius: DeviceEditorValidator; circleCenterX: DeviceEditorValidator; circleCenterY: DeviceEditorValidator; circleRadius: DeviceEditorValidator; } export class DevicesSettingsTab extends UI.Widget.VBox implements UI.ListWidget.Delegate<EmulationModel.EmulatedDevices.EmulatedDevice> { containerElement: HTMLElement; private readonly addCustomButton: Buttons.Button.Button; private readonly ariaSuccessMessageElement: HTMLElement; readonly #customDeviceList: UI.ListWidget.ListWidget<EmulationModel.EmulatedDevices.EmulatedDevice>; readonly #defaultDeviceLists = new Map<EmulationModel.EmulatedDevices.Category, { container: HTMLElement, list: UI.ListWidget.ListWidget<EmulationModel.EmulatedDevices.EmulatedDevice>, }>(); private muteUpdate: boolean; private emulatedDevicesList: EmulationModel.EmulatedDevices.EmulatedDevicesList; private editor?: UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice>; constructor() { super({jslog: `${VisualLogging.pane('devices')}`}); this.registerRequiredCSS(devicesSettingsTabStyles); this.containerElement = this.contentElement.createChild('div', 'settings-card-container-wrapper').createChild('div'); this.containerElement.classList.add('settings-card-container', 'ignore-list-settings'); this.muteUpdate = false; this.emulatedDevicesList = EmulationModel.EmulatedDevices.EmulatedDevicesList.instance(); this.emulatedDevicesList.addEventListener( EmulationModel.EmulatedDevices.Events.CUSTOM_DEVICES_UPDATED, this.devicesUpdated, this); this.emulatedDevicesList.addEventListener( EmulationModel.EmulatedDevices.Events.STANDARD_DEVICES_UPDATED, this.devicesUpdated, this); this.ariaSuccessMessageElement = this.contentElement.createChild('div', 'device-success-message'); UI.ARIAUtils.markAsPoliteLiveRegion(this.ariaSuccessMessageElement, false); this.addCustomButton = UI.UIUtils.createTextButton( i18nString(UIStrings.addCustomDevice), this.addCustomDevice.bind(this), {jslogContext: 'add-custom-device'}); this.addCustomButton.id = 'custom-device-add-button'; const customSettings = document.createElement('div'); customSettings.classList.add('device-card-content'); customSettings.appendChild(this.ariaSuccessMessageElement); const deviceList = customSettings.createChild('div'); customSettings.appendChild(this.addCustomButton); const customDevicesCard = this.containerElement.createChild('devtools-card'); customDevicesCard.heading = i18nString(UIStrings.customDevices); customDevicesCard.append(customSettings); this.#customDeviceList = new UI.ListWidget.ListWidget(this, false /* delegatesFocus */); this.#customDeviceList.registerRequiredCSS(devicesSettingsTabStyles); this.#customDeviceList.element.classList.add('devices-list'); this.#customDeviceList.show(deviceList); const defaultDevicesCard = this.containerElement.createChild('devtools-card'); defaultDevicesCard.heading = i18nString(UIStrings.defaultDevices); for (const category of EmulationModel.EmulatedDevices.CATEGORY_ORDER) { const groupContainer = document.createElement('div'); groupContainer.classList.add('device-group-container'); const groupTitle = groupContainer.createChild('div', 'device-group-title'); groupTitle.textContent = EmulationModel.EmulatedDevices.getCategoryTitle(category); defaultDevicesCard.append(groupContainer); const listWidget = new UI.ListWidget.ListWidget(this, false /* delegatesFocus */); listWidget.registerRequiredCSS(devicesSettingsTabStyles); listWidget.element.classList.add('devices-list', 'device-card-content'); listWidget.show(groupContainer); this.#defaultDeviceLists.set(category, {container: groupContainer, list: listWidget}); } } override wasShown(): void { super.wasShown(); this.devicesUpdated(); } private devicesUpdated(): void { if (this.muteUpdate) { return; } for (const {list} of this.#defaultDeviceLists.values()) { list.clear(); } this.#customDeviceList.clear(); const customDevices = this.emulatedDevicesList.custom().slice(); for (let i = 0; i < customDevices.length; ++i) { this.#customDeviceList.appendItem(customDevices[i], true); } const standardDevices = this.emulatedDevicesList.standard().slice(); standardDevices.sort(EmulationModel.EmulatedDevices.EmulatedDevice.deviceComparator); const categoryItemCounts = new Map<EmulationModel.EmulatedDevices.Category, number>(); for (const device of standardDevices) { const cat = EmulationModel.EmulatedDevices.deviceCategory(device); const group = this.#defaultDeviceLists.get(cat); if (group) { group.list.appendItem(device, false); categoryItemCounts.set(cat, (categoryItemCounts.get(cat) || 0) + 1); } } for (const [cat, group] of this.#defaultDeviceLists.entries()) { const count = categoryItemCounts.get(cat) || 0; group.container.style.display = count > 0 ? '' : 'none'; } } private muteAndSaveDeviceList(custom: boolean): void { this.muteUpdate = true; if (custom) { this.emulatedDevicesList.saveCustomDevices(); } else { this.emulatedDevicesList.saveStandardDevices(); } this.muteUpdate = false; } private addCustomDevice(): void { const device = new EmulationModel.EmulatedDevices.EmulatedDevice(); device.deviceScaleFactor = 0; device.horizontal.width = 700; device.horizontal.height = 400; device.vertical.width = 400; device.vertical.height = 700; device.userAgent = navigator.userAgent; this.#customDeviceList.addNewItem(this.emulatedDevicesList.custom().length, device); } private toNumericInputValue(value: number): string { return value ? String(value) : ''; } private verticalMode(device: EmulationModel.EmulatedDevices.EmulatedDevice): EmulationModel.EmulatedDevices.Mode |null { return device.modes.find(mode => mode.orientation === EmulationModel.EmulatedDevices.Vertical) || null; } private horizontalMode(device: EmulationModel.EmulatedDevices.EmulatedDevice): EmulationModel.EmulatedDevices.Mode |null { return device.modes.find(mode => mode.orientation === EmulationModel.EmulatedDevices.Horizontal) || null; } private editorIntegerValue(editor: UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice>, controlName: string): number { return parseOptionalNonNegativeInteger(editor.control(controlName).value) ?? 0; } renderItem(device: EmulationModel.EmulatedDevices.EmulatedDevice, editable: boolean): Element { const label = document.createElement('label'); label.classList.add('devices-list-item'); const checkbox = label.createChild('input', 'devices-list-checkbox'); checkbox.type = 'checkbox'; checkbox.checked = device.show(); checkbox.addEventListener('click', onItemClicked.bind(this), false); checkbox.setAttribute('jslog', `${VisualLogging.toggle().track({click: true})}`); const span = document.createElement('span'); span.classList.add('device-name'); span.appendChild(document.createTextNode(device.title)); label.appendChild(span); return label; function onItemClicked(this: DevicesSettingsTab, event: Event): void { const show = checkbox.checked; device.setShow(show); this.muteAndSaveDeviceList(editable); event.consume(); } } removeItemRequested(item: EmulationModel.EmulatedDevices.EmulatedDevice): void { this.emulatedDevicesList.removeCustomDevice(item); } commitEdit( device: EmulationModel.EmulatedDevices.EmulatedDevice, editor: UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice>, isNew: boolean): void { device.title = editor.control('title').value.trim(); device.vertical.width = editor.control('width').value ? parseInt(editor.control('width').value, 10) : 0; device.vertical.height = editor.control('height').value ? parseInt(editor.control('height').value, 10) : 0; device.horizontal.width = device.vertical.height; device.horizontal.height = device.vertical.width; device.deviceScaleFactor = editor.control('scale').value ? parseFloat(editor.control('scale').value) : 0; device.userAgent = editor.control('user-agent').value; device.modes = []; const verticalMode: EmulationModel.EmulatedDevices.Mode = { title: '', orientation: EmulationModel.EmulatedDevices.Vertical, insets: new EmulationModel.DeviceModeModel.Insets(0, 0, 0, 0), }; const safeAreaInsets = this.safeAreaInsetsFromEditor(editor); if (safeAreaInsets) { verticalMode.safeAreaInsets = safeAreaInsets; } const cutout = this.cutoutFromEditor(editor); if (cutout) { verticalMode.cutout = cutout; } device.modes.push(verticalMode); const horizontalMode: EmulationModel.EmulatedDevices.Mode = { title: '', orientation: EmulationModel.EmulatedDevices.Horizontal, insets: new EmulationModel.DeviceModeModel.Insets(0, 0, 0, 0), }; const landscapeSafeAreaInsets = this.safeAreaInsetsFromEditor(editor, 'landscape-'); if (landscapeSafeAreaInsets) { horizontalMode.safeAreaInsets = landscapeSafeAreaInsets; } device.modes.push(horizontalMode); device.capabilities = []; const uaType = editor.control('ua-type').value; if (uaType === EmulationModel.DeviceModeModel.UA.MOBILE || uaType === EmulationModel.DeviceModeModel.UA.MOBILE_NO_TOUCH) { device.capabilities.push(EmulationModel.EmulatedDevices.Capability.MOBILE); } if (uaType === EmulationModel.DeviceModeModel.UA.MOBILE || uaType === EmulationModel.DeviceModeModel.UA.DESKTOP_TOUCH) { device.capabilities.push(EmulationModel.EmulatedDevices.Capability.TOUCH); } const userAgentControlValue = (editor.control('ua-metadata') as UI.ListWidget.CustomEditorControl<EmulationComponents.UserAgentClientHintsForm.UserAgentClientHintsFormData>) .value.metaData; const hasUserAgentOverride = device.userAgent.trim().length > 0; device.userAgentMetadata = null; if (hasUserAgentOverride && userAgentControlValue) { device.userAgentMetadata = { ...userAgentControlValue, mobile: (uaType === EmulationModel.DeviceModeModel.UA.MOBILE || uaType === EmulationModel.DeviceModeModel.UA.MOBILE_NO_TOUCH), }; } if (isNew) { this.emulatedDevicesList.addCustomDevice(device); } else { this.emulatedDevicesList.saveCustomDevices(); } this.addCustomButton.scrollIntoViewIfNeeded(); this.addCustomButton.focus(); this.ariaSuccessMessageElement.setAttribute( 'aria-label', i18nString(UIStrings.deviceAddedOrUpdated, {PH1: device.title})); } beginEdit(device: EmulationModel.EmulatedDevices.EmulatedDevice): UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice> { const editor = this.createEditor(); editor.control('title').value = device.title; editor.control('width').value = this.toNumericInputValue(device.vertical.width); editor.control('height').value = this.toNumericInputValue(device.vertical.height); editor.control('scale').value = this.toNumericInputValue(device.deviceScaleFactor); editor.control('user-agent').value = device.userAgent; this.populateSafeAreaEditor(editor, device); this.populateCutoutEditor(editor, device); let uaType; if (device.mobile()) { uaType = device.touch() ? EmulationModel.DeviceModeModel.UA.MOBILE : EmulationModel.DeviceModeModel.UA.MOBILE_NO_TOUCH; } else { uaType = device.touch() ? EmulationModel.DeviceModeModel.UA.DESKTOP_TOUCH : EmulationModel.DeviceModeModel.UA.DESKTOP; } editor.control('ua-type').value = uaType; (editor.control('ua-metadata') as UI.ListWidget.CustomEditorControl<EmulationComponents.UserAgentClientHintsForm.UserAgentClientHintsFormData>) .value = {metaData: device.userAgentMetadata || undefined}; return editor; } private safeAreaInsetsFromEditor(editor: UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice>, controlPrefix = ''): EmulationModel.DeviceModeModel.Insets|null { const left = this.editorIntegerValue(editor, `${controlPrefix}safe-area-left`); const top = this.editorIntegerValue(editor, `${controlPrefix}safe-area-top`); const right = this.editorIntegerValue(editor, `${controlPrefix}safe-area-right`); const bottom = this.editorIntegerValue(editor, `${controlPrefix}safe-area-bottom`); if (!left && !top && !right && !bottom) { return null; } return new EmulationModel.DeviceModeModel.Insets(left, top, right, bottom); } private cutoutFromEditor(editor: UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice>): EmulationModel.EmulatedDevices.Cutout|null { const shape = editor.control('cutout-shape').value; const baseCutout = { x: this.editorIntegerValue(editor, 'cutout-x'), y: this.editorIntegerValue(editor, 'cutout-y'), width: this.editorIntegerValue(editor, 'cutout-width'), height: this.editorIntegerValue(editor, 'cutout-height'), }; switch (shape) { case EmulationModel.EmulatedDevices.CutoutShape.PILL: return { shape, ...baseCutout, borderRadius: this.editorIntegerValue(editor, 'cutout-border-radius'), }; case EmulationModel.EmulatedDevices.CutoutShape.NOTCH: return { shape, ...baseCutout, upperRadius: this.editorIntegerValue(editor, 'cutout-upper-radius'), lowerRadius: this.editorIntegerValue(editor, 'cutout-lower-radius'), }; case EmulationModel.EmulatedDevices.CutoutShape.CIRCLE: return { shape, ...baseCutout, cx: this.editorIntegerValue(editor, 'cutout-cx'), cy: this.editorIntegerValue(editor, 'cutout-cy'), radius: this.editorIntegerValue(editor, 'cutout-radius'), }; case EmulationModel.EmulatedDevices.CutoutShape.RECTANGLE: return {shape, ...baseCutout}; default: return null; } } private populateSafeAreaEditor(editor: UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice>, device: EmulationModel.EmulatedDevices.EmulatedDevice): void { const safeAreaInsets = this.verticalMode(device)?.safeAreaInsets; editor.control('safe-area-left').value = this.toNumericInputValue(safeAreaInsets?.left || 0); editor.control('safe-area-top').value = this.toNumericInputValue(safeAreaInsets?.top || 0); editor.control('safe-area-right').value = this.toNumericInputValue(safeAreaInsets?.right || 0); editor.control('safe-area-bottom').value = this.toNumericInputValue(safeAreaInsets?.bottom || 0); const landscapeSafeAreaInsets = this.horizontalMode(device)?.safeAreaInsets; editor.control('landscape-safe-area-left').value = this.toNumericInputValue(landscapeSafeAreaInsets?.left || 0); editor.control('landscape-safe-area-top').value = this.toNumericInputValue(landscapeSafeAreaInsets?.top || 0); editor.control('landscape-safe-area-right').value = this.toNumericInputValue(landscapeSafeAreaInsets?.right || 0); editor.control('landscape-safe-area-bottom').value = this.toNumericInputValue(landscapeSafeAreaInsets?.bottom || 0); } private populateCutoutEditor(editor: UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice>, device: EmulationModel.EmulatedDevices.EmulatedDevice): void { const cutout = this.verticalMode(device)?.cutout; editor.control('cutout-shape').value = cutout?.shape || NO_CUSTOM_CUTOUT; editor.control('cutout-x').value = String(cutout?.x ?? ''); editor.control('cutout-y').value = String(cutout?.y ?? ''); editor.control('cutout-width').value = String(cutout?.width ?? ''); editor.control('cutout-height').value = String(cutout?.height ?? ''); editor.control('cutout-border-radius').value = String(cutout?.shape === EmulationModel.EmulatedDevices.CutoutShape.PILL ? cutout.borderRadius : ''); editor.control('cutout-upper-radius').value = String(cutout?.shape === EmulationModel.EmulatedDevices.CutoutShape.NOTCH ? cutout.upperRadius : ''); editor.control('cutout-lower-radius').value = String(cutout?.shape === EmulationModel.EmulatedDevices.CutoutShape.NOTCH ? cutout.lowerRadius : ''); editor.control('cutout-cx').value = String(cutout?.shape === EmulationModel.EmulatedDevices.CutoutShape.CIRCLE ? cutout.cx : ''); editor.control('cutout-cy').value = String(cutout?.shape === EmulationModel.EmulatedDevices.CutoutShape.CIRCLE ? cutout.cy : ''); editor.control('cutout-radius').value = String(cutout?.shape === EmulationModel.EmulatedDevices.CutoutShape.CIRCLE ? cutout.radius : ''); this.updateCutoutFieldsVisibility(editor); } private updateCutoutFieldsVisibility(editor: UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice>): void { const shape = editor.control('cutout-shape').value; const noCutout = shape === NO_CUSTOM_CUTOUT; const isPill = shape === EmulationModel.EmulatedDevices.CutoutShape.PILL; const isNotch = shape === EmulationModel.EmulatedDevices.CutoutShape.NOTCH; const isCircle = shape === EmulationModel.EmulatedDevices.CutoutShape.CIRCLE; const content = editor.contentElement(); const rectRow = content.querySelector<HTMLElement>('.devices-edit-cutout-rect-row'); const radiusRow = content.querySelector<HTMLElement>('.devices-edit-cutout-radius-row'); if (rectRow) { rectRow.hidden = noCutout; } if (radiusRow) { radiusRow.hidden = noCutout || !(isPill || isNotch || isCircle); } (editor.control('cutout-border-radius') as HTMLElement).hidden = !isPill; (editor.control('cutout-upper-radius') as HTMLElement).hidden = !isNotch; (editor.control('cutout-lower-radius') as HTMLElement).hidden = !isNotch; (editor.control('cutout-cx') as HTMLElement).hidden = !isCircle; (editor.control('cutout-cy') as HTMLElement).hidden = !isCircle; (editor.control('cutout-radius') as HTMLElement).hidden = !isCircle; } private createEditor(): UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice> { if (this.editor) { return this.editor; } const editor = new UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice>(); this.editor = editor; const content = editor.contentElement(); const deviceFields = content.createChild('div', 'devices-edit-fields'); UI.UIUtils.createTextChild(deviceFields.createChild('b'), i18nString(UIStrings.device)); const deviceNameField = editor.createInput('title', 'text', i18nString(UIStrings.deviceName), titleValidator); deviceFields.createChild('div', 'hbox').appendChild(deviceNameField); deviceNameField.id = 'custom-device-name-field'; const screen = deviceFields.createChild('div', 'hbox'); screen.appendChild(editor.createInput('width', 'text', i18nString(UIStrings.width), widthValidator)); screen.appendChild(editor.createInput('height', 'text', i18nString(UIStrings.height), heightValidator)); const dpr = editor.createInput('scale', 'text', i18nString(UIStrings.devicePixelRatio), scaleValidator); dpr.classList.add('device-edit-fixed'); screen.appendChild(dpr); this.appendSafeAreaFields(editor, deviceFields, i18nString(UIStrings.portraitSafeArea), '', portraitSafeAreaValidator); this.appendSafeAreaFields(editor, deviceFields, i18nString(UIStrings.landscapeSafeArea), 'landscape-', landscapeSafeAreaValidator); this.appendCutoutFields(editor, content, { shape: cutoutShapeValidator, x: cutoutXValidator, y: cutoutYValidator, width: cutoutWidthValidator, height: cutoutHeightValidator, pillRadius: cutoutPillRadiusValidator, notchUpperRadius: cutoutNotchUpperRadiusValidator, notchLowerRadius: cutoutNotchLowerRadiusValidator, circleCenterX: cutoutCircleCenterXValidator, circleCenterY: cutoutCircleCenterYValidator, circleRadius: cutoutCircleRadiusValidator, }); const uaStringFields = content.createChild('div', 'devices-edit-fields'); UI.UIUtils.createTextChild(uaStringFields.createChild('b'), i18nString(UIStrings.userAgentString)); const ua = uaStringFields.createChild('div', 'hbox'); ua.appendChild(editor.createInput('user-agent', 'text', i18nString(UIStrings.userAgentString), userAgentValidator)); const uaTypeOptions = [ EmulationModel.DeviceModeModel.UA.MOBILE, EmulationModel.DeviceModeModel.UA.MOBILE_NO_TOUCH, EmulationModel.DeviceModeModel.UA.DESKTOP, EmulationModel.DeviceModeModel.UA.DESKTOP_TOUCH, ]; const uaType = editor.createSelect('ua-type', uaTypeOptions, () => { return {valid: true}; }, i18nString(UIStrings.userAgentType)); uaType.classList.add('device-edit-fixed'); ua.appendChild(uaType); const uaMetadata = editor.createCustomControl( 'ua-metadata', EmulationComponents.UserAgentClientHintsForm.UserAgentClientHintsForm, userAgentMetadataValidator); uaMetadata.value = {}; uaMetadata.addEventListener('clienthintschange', () => editor.requestValidation(), false); content.appendChild(uaMetadata); return editor; function userAgentMetadataValidator(): UI.ListWidget.ValidatorResult { return uaMetadata.validate(); } function userAgentValidator( _item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { if (input.value.trim().length > 0) { return {valid: true}; } return {valid: false, errorMessage: i18nString(UIStrings.userAgentStringCannotBeEmpty)}; } function titleValidator( _item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { let valid = false; let errorMessage; const value = input.value.trim(); if (value.length >= EmulationModel.DeviceModeModel.MaxDeviceNameLength) { errorMessage = i18nString(UIStrings.deviceNameMustBeLessThanS, {PH1: EmulationModel.DeviceModeModel.MaxDeviceNameLength}); } else if (value.length === 0) { errorMessage = i18nString(UIStrings.deviceNameCannotBeEmpty); } else { valid = true; } return {valid, errorMessage}; } function widthValidator( _item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return EmulationModel.DeviceModeModel.DeviceModeModel.widthValidator(input.value); } function heightValidator( _item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return EmulationModel.DeviceModeModel.DeviceModeModel.heightValidator(input.value); } function scaleValidator( _item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return EmulationModel.DeviceModeModel.DeviceModeModel.scaleValidator(input.value); } function nonNegativeIntegerValue(controlName: string): number|null { return parseOptionalNonNegativeInteger(editor.control(controlName).value); } function positiveIntegerValue(controlName: string): number|null { const value = nonNegativeIntegerValue(controlName); return value && value > 0 ? value : null; } function requiredIntegerValue(controlName: string): number|null { const value = editor.control(controlName).value.trim(); return value ? parseOptionalNonNegativeInteger(value) : null; } function controlLabel(input: UI.ListWidget.EditorControl): string { return input.getAttribute('aria-label') || input.getAttribute('placeholder') || ''; } function safeAreaValidator(controlPrefix: string, orientationLabel: string, widthControlName: string, heightControlName: string, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { const ownValue = parseOptionalNonNegativeInteger(input.value); if (ownValue === null) { return { valid: false, errorMessage: i18nString(UIStrings.safeAreaValueMustBeInRange, { PH1: orientationLabel, PH2: input.getAttribute('aria-label') || input.getAttribute('placeholder') || '', PH3: EmulationModel.DeviceModeModel.MaxDeviceSize, }), }; } const left = nonNegativeIntegerValue(`${controlPrefix}safe-area-left`); const top = nonNegativeIntegerValue(`${controlPrefix}safe-area-top`); const right = nonNegativeIntegerValue(`${controlPrefix}safe-area-right`); const bottom = nonNegativeIntegerValue(`${controlPrefix}safe-area-bottom`); const width = positiveIntegerValue(widthControlName); const height = positiveIntegerValue(heightControlName); if (input === editor.control(`${controlPrefix}safe-area-right`) && left !== null && right !== null && width !== null && left + right > width) { return { valid: false, errorMessage: i18nString(UIStrings.safeAreaHorizontalInsetsExceedWidth, {PH1: orientationLabel}), }; } if (input === editor.control(`${controlPrefix}safe-area-bottom`) && top !== null && bottom !== null && height !== null && top + bottom > height) { return { valid: false, errorMessage: i18nString(UIStrings.safeAreaVerticalInsetsExceedHeight, {PH1: orientationLabel}), }; } return {valid: true}; } function portraitSafeAreaValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return safeAreaValidator('', i18nString(UIStrings.portraitSafeArea), 'width', 'height', input); } function landscapeSafeAreaValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return safeAreaValidator('landscape-', i18nString(UIStrings.landscapeSafeArea), 'height', 'width', input); } function cutoutShapeValidator(): UI.ListWidget.ValidatorResult { return {valid: true}; } function cutoutValueValidator(input: UI.ListWidget.EditorControl, mustBePositive = false): UI.ListWidget.ValidatorResult { const label = controlLabel(input); const value = input.value.trim(); if (!value) { return {valid: false, errorMessage: i18nString(UIStrings.cutoutFieldRequired, {PH1: label})}; } const numericValue = parseOptionalNonNegativeInteger(value); if (numericValue === null) { return { valid: false, errorMessage: i18nString(UIStrings.cutoutValueMustBeInRange, { PH1: label, PH2: EmulationModel.DeviceModeModel.MaxDeviceSize, }), }; } if (mustBePositive && numericValue === 0) { return {valid: false, errorMessage: i18nString(UIStrings.cutoutValueMustBePositiveInteger, {PH1: label})}; } return {valid: true}; } function isCutoutFieldActive(shape?: EmulationModel.EmulatedDevices.CutoutShape): boolean { const selectedShape = editor.control('cutout-shape').value; return selectedShape !== NO_CUSTOM_CUTOUT && (shape === undefined || selectedShape === shape); } function cutoutXValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return isCutoutFieldActive() ? cutoutValueValidator(input) : {valid: true}; } function cutoutYValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return isCutoutFieldActive() ? cutoutValueValidator(input) : {valid: true}; } function cutoutWidthValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { if (!isCutoutFieldActive()) { return {valid: true}; } const validation = cutoutValueValidator(input, true); if (!validation.valid) { return validation; } const cutoutX = requiredIntegerValue('cutout-x'); const cutoutWidth = positiveIntegerValue('cutout-width'); const width = positiveIntegerValue('width'); if (width !== null && cutoutX !== null && cutoutWidth !== null && cutoutX + cutoutWidth > width) { return {valid: false, errorMessage: i18nString(UIStrings.cutoutXAndWidthExceedDeviceWidth)}; } return {valid: true}; } function cutoutHeightValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { if (!isCutoutFieldActive()) { return {valid: true}; } const validation = cutoutValueValidator(input, true); if (!validation.valid) { return validation; } const cutoutY = requiredIntegerValue('cutout-y'); const cutoutHeight = positiveIntegerValue('cutout-height'); const height = positiveIntegerValue('height'); if (height !== null && cutoutY !== null && cutoutHeight !== null && cutoutY + cutoutHeight > height) { return {valid: false, errorMessage: i18nString(UIStrings.cutoutYAndHeightExceedDeviceHeight)}; } return {valid: true}; } function cutoutPillRadiusValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return isCutoutFieldActive(EmulationModel.EmulatedDevices.CutoutShape.PILL) ? cutoutValueValidator(input) : {valid: true}; } function cutoutNotchUpperRadiusValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return isCutoutFieldActive(EmulationModel.EmulatedDevices.CutoutShape.NOTCH) ? cutoutValueValidator(input) : {valid: true}; } function cutoutNotchLowerRadiusValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return isCutoutFieldActive(EmulationModel.EmulatedDevices.CutoutShape.NOTCH) ? cutoutValueValidator(input) : {valid: true}; } function cutoutCircleCenterXValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return isCutoutFieldActive(EmulationModel.EmulatedDevices.CutoutShape.CIRCLE) ? cutoutValueValidator(input) : {valid: true}; } function cutoutCircleCenterYValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { return isCutoutFieldActive(EmulationModel.EmulatedDevices.CutoutShape.CIRCLE) ? cutoutValueValidator(input) : {valid: true}; } function cutoutCircleRadiusValidator(_item: EmulationModel.EmulatedDevices.EmulatedDevice, _index: number, input: UI.ListWidget.EditorControl): UI.ListWidget.ValidatorResult { if (!isCutoutFieldActive(EmulationModel.EmulatedDevices.CutoutShape.CIRCLE)) { return {valid: true}; } const validation = cutoutValueValidator(input, true); if (!validation.valid) { return validation; } const cutoutX = requiredIntegerValue('cutout-x'); const cutoutY = requiredIntegerValue('cutout-y'); const cutoutWidth = positiveIntegerValue('cutout-width'); const cutoutHeight = positiveIntegerValue('cutout-height'); const centerX = requiredIntegerValue('cutout-cx'); const centerY = requiredIntegerValue('cutout-cy'); const radius = positiveIntegerValue('cutout-radius'); if (cutoutX !== null && cutoutY !== null && cutoutWidth !== null && cutoutHeight !== null && centerX !== null && centerY !== null && radius !== null && (centerX - radius < cutoutX || centerX + radius > cutoutX + cutoutWidth || centerY - radius < cutoutY || centerY + radius > cutoutY + cutoutHeight)) { return {valid: false, errorMessage: i18nString(UIStrings.circleMustFitCutoutBounds)}; } return {valid: true}; } } private appendSafeAreaFields(editor: UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice>, deviceFields: HTMLElement, title: string, controlPrefix: string, safeAreaValidator: (item: EmulationModel.EmulatedDevices.EmulatedDevice, index: number, input: UI.ListWidget.EditorControl) => UI.ListWidget.ValidatorResult): void { const safeAreaGroup = deviceFields.createChild('div', 'devices-edit-safe-area-group'); UI.ARIAUtils.markAsGroup(safeAreaGroup); const heading = safeAreaGroup.createChild('b'); heading.id = UI.ARIAUtils.nextId('safe-area-heading-'); UI.UIUtils.createTextChild(heading, title); safeAreaGroup.setAttribute('aria-labelledby', heading.id); const safeAreaRow = safeAreaGroup.createChild('div', 'hbox'); safeAreaRow.appendChild(editor.createInput(`${controlPrefix}safe-area-left`, 'text', i18nString(UIStrings.safeAreaLeft), safeAreaValidator)); safeAreaRow.appendChild(editor.createInput(`${controlPrefix}safe-area-top`, 'text', i18nString(UIStrings.safeAreaTop), safeAreaValidator)); safeAreaRow.appendChild(editor.createInput(`${controlPrefix}safe-area-right`, 'text', i18nString(UIStrings.safeAreaRight), safeAreaValidator)); safeAreaRow.appendChild(editor.createInput(`${controlPrefix}safe-area-bottom`, 'text', i18nString(UIStrings.safeAreaBottom), safeAreaValidator)); } private appendCutoutFields(editor: UI.ListWidget.Editor<EmulationModel.EmulatedDevices.EmulatedDevice>, content: Element, validators: CutoutFieldValidators): void { const cutoutFields = content.createChild('div', 'devices-edit-fields'); cutoutFields.classList.add('devices-edit-cutout-fields'); UI.ARIAUtils.markAsGroup(cutoutFields); const heading = cutoutFields.createChild('b'); heading.id = UI.ARIAUtils.nextId('cutout-heading-'); UI.UIUtils.createTextChild(heading, i18nString(UIStrings.displayCutout)); cutoutFields.setAttribute('aria-labelledby', heading.id); const shapeOptions = [ NO_CUSTOM_CUTOUT, EmulationModel.EmulatedDevices.CutoutShape.PILL, EmulationModel.EmulatedDevices.CutoutShape.NOTCH, EmulationModel.EmulatedDevices.CutoutShape.CIRCLE, EmulationModel.EmulatedDevices.CutoutShape.RECTANGLE, ]; const shapeControl = editor.createSelect('cutout-shape', shapeOptions, validators.shape, i18nString(UIStrings.displayCutout)); shapeControl.options[0].textContent = i18nString(UIStrings.noDisplayCutout); shapeControl.options[1].textContent = i18nString(UIStrings.pillDisplayCutout); shapeControl.options[2].textContent = i18nString(UIStrings.notchDisplayCutout); shapeControl.options[3].textContent = i18nString(UIStrings.circleDisplayCutout); shapeControl.options[4].textContent = i18nString(UIStrings.rectangleDisplayCutout); cutoutFields.createChild('div', 'hbox').appendChild(shapeControl); shapeControl.addEventListener('input', () => this.updateCutoutFieldsVisibility(editor), false); const rectRow = cutoutFields.createChild('div', 'hbox devices-edit-cutout-rect-row'); rectRow.appendChild(editor.createInput('cutout-x', 'text', i18nString(UIStrings.cutoutX), validators.x)); rectRow.appendChild(editor.createInput('cutout-y', 'text', i18nString(UIStrings.cutoutY), validators.y)); rectRow.appendChild( editor.createInput('cutout-width', 'text', i18nString(UIStrings.cutoutWidth), validators.width)); rectRow.appendChild( editor.createInput('cutout-height', 'text', i18nString(UIStrings.cutoutHeight), validators.height)); const radiusRow = cutoutFields.createChild('div', 'hbox devices-edit-cutout-radius-row'); radiusRow.appendChild(editor.createInput('cutout-border-radius', 'text', i18nString(UIStrings.cutoutBorderRadius), validators.pillRadius)); radiusRow.appendChild(editor.createInput('cutout-upper-radius', 'text', i18nString(UIStrings.cutoutUpperRadius), validators.notchUpperRadius)); radiusRow.appendChild(editor.createInput('cutout-lower-radius', 'text', i18nString(UIStrings.cutoutLowerRadius), validators.notchLowerRadius)); radiusRow.appendChild( editor.createInput('cutout-cx', 'text', i18nString(UIStrings.cutoutCenterX), validators.circleCenterX)); radiusRow.appendChild( editor.createInput('cutout-cy', 'text', i18nString(UIStrings.cutoutCenterY), validators.circleCenterY)); radiusRow.appendChild( editor.createInput('cutout-radius', 'text', i18nString(UIStrings.cutoutRadius), validators.circleRadius)); this.updateCutoutFieldsVisibility(editor); } }