From b2e1569a54ba7cccab9b2d645bae6f3711d825fa Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Fri, 15 May 2026 22:12:03 -0700 Subject: [PATCH] Implement device emulation support in browser view --- .../browserView/common/browserView.ts | 73 +++++++ .../browserView/electron-main/browserView.ts | 189 +++++++++++++++++- .../electron-main/browserViewDebugger.ts | 5 - .../electron-main/browserViewMainService.ts | 10 +- .../contrib/browserView/common/browserView.ts | 24 ++- .../electron-browser/browserEditor.ts | 16 ++ .../browserView.contribution.ts | 1 + .../features/browserEditorDeviceFeature.ts | 138 +++++++++++++ .../electron-browser/media/browser.css | 24 +++ 9 files changed, 463 insertions(+), 17 deletions(-) create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorDeviceFeature.ts diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index cfff846b44301..ee7ac109ffa4b 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -156,6 +156,7 @@ export interface IBrowserViewState { storageScope: BrowserViewStorageScope; browserZoomIndex: number; isElementSelectionActive: boolean; + deviceEmulation: IBrowserDeviceEmulation | undefined; } export interface IBrowserViewNavigationEvent { @@ -255,6 +256,70 @@ export function browserZoomAccessibilityLabel(zoomFactor: number): string { return localize('browserZoomAccessibilityLabel', "Page Zoom: {0}%", Math.round(zoomFactor * 100)); } +/** + * Device emulation profile applied via CDP. When set on a browser view, the + * page is rendered with the device's metrics, user agent, touch, and media + * features regardless of the actual container size. + */ +export interface IBrowserDeviceEmulation { + readonly id: string; + readonly label: string; + readonly width: number; + readonly height: number; + readonly deviceScaleFactor: number; + readonly mobile: boolean; + readonly userAgent?: string; + readonly hasTouch?: boolean; +} + +/** + * Curated set of device presets surfaced in the device picker. Order matters + * (most-common-first). Kept short on purpose — additional sizes are rarely + * useful in practice. + */ +export const BROWSER_DEVICE_PRESETS: readonly IBrowserDeviceEmulation[] = [ + { + id: 'iphone-15-pro', + label: 'iPhone 15 Pro', + width: 393, + height: 852, + deviceScaleFactor: 3, + mobile: true, + hasTouch: true, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + }, + { + id: 'iphone-se', + label: 'iPhone SE', + width: 375, + height: 667, + deviceScaleFactor: 2, + mobile: true, + hasTouch: true, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + }, + { + id: 'pixel-8', + label: 'Pixel 8', + width: 412, + height: 915, + deviceScaleFactor: 2.625, + mobile: true, + hasTouch: true, + userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36', + }, + { + id: 'ipad-mini', + label: 'iPad Mini', + width: 768, + height: 1024, + deviceScaleFactor: 2, + mobile: true, + hasTouch: true, + userAgent: 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + }, +]; + /** * This should match the isolated world ID defined in `preload-browserView.ts`. */ @@ -281,6 +346,7 @@ export interface IBrowserViewService { onDynamicDidClose(id: string): Event; onDynamicDidSelectElement(id: string): Event; onDynamicDidChangeElementSelectionActive(id: string): Event; + onDynamicDidChangeDeviceEmulation(id: string): Event; /** * Get all known browser views with their ownership and state information. @@ -431,6 +497,13 @@ export interface IBrowserViewService { /** Set the browser zoom index (independent from VS Code zoom). */ setBrowserZoomIndex(id: string, zoomIndex: number): Promise; + /** + * Set or clear a device emulation profile for a browser view. Passing + * `undefined` returns the view to the default "Responsive" behavior where + * the page mirrors the container size. + */ + setDeviceEmulation(id: string, device: IBrowserDeviceEmulation | undefined): Promise; + /** * Trust a certificate for a given host in the browser view's session. * The page will be automatically reloaded after trusting. diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index e1ae26c8e2626..26fa11c8b399a 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -7,7 +7,7 @@ import { WebContentsView, webContents } from 'electron'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewOpenOptions } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewOpenOptions, IBrowserDeviceEmulation } from '../common/browserView.js'; import { BrowserViewElementInspector } from './browserViewElementInspector.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { ICodeWindow, LoadReason } from '../../window/electron-main/window.js'; @@ -80,6 +80,11 @@ export class BrowserView extends Disposable { private readonly _onDidClose = this._register(new Emitter()); readonly onDidClose: Event = this._onDidClose.event; + private readonly _onDidChangeDeviceEmulation = this._register(new Emitter()); + readonly onDidChangeDeviceEmulation: Event = this._onDidChangeDeviceEmulation.event; + + private _deviceEmulation: IBrowserDeviceEmulation | undefined; + constructor( public readonly id: string, public readonly owner: IBrowserViewOwner, @@ -273,13 +278,17 @@ export class BrowserView extends Disposable { // Loading state events webContents.on('did-start-loading', () => { this._lastError = undefined; + this.traceDeviceEmulation('did-start-loading', webContents.getURL()); // Don't fire loading events for e.g. same-document navigations if (webContents.isLoadingMainFrame()) { fireLoadingEvent(true); } }); - webContents.on('did-stop-loading', () => fireLoadingEvent(false)); + webContents.on('did-stop-loading', () => { + this.traceDeviceEmulation('did-stop-loading'); + fireLoadingEvent(false); + }); webContents.on('did-fail-load', (e, errorCode, errorDescription, validatedURL, isMainFrame) => { if (isMainFrame) { // Ignore ERR_ABORTED (-3) which is the expected error when user stops a page load. @@ -306,7 +315,10 @@ export class BrowserView extends Disposable { }); } }); - webContents.on('did-finish-load', () => fireLoadingEvent(false)); + webContents.on('did-finish-load', () => { + this.traceDeviceEmulation('did-finish-load', webContents.getURL()); + fireLoadingEvent(false); + }); this.session.trust.installCertErrorHandler(webContents); @@ -324,12 +336,36 @@ export class BrowserView extends Disposable { webContents.on('did-navigate', fireNavigationEvent); webContents.on('did-navigate-in-page', fireNavigationEvent); + webContents.on('did-start-navigation', (_e, url, isInPlace, isMainFrame) => { + if (isMainFrame) { + this.traceDeviceEmulation('did-start-navigation', `${isInPlace ? 'in-page' : 'document'} ${url}`); + } + }); + webContents.on('did-redirect-navigation', (_e, url, _isInPlace, isMainFrame) => { + if (isMainFrame) { + this.traceDeviceEmulation('did-redirect-navigation', url); + } + }); + webContents.on('did-navigate', (_e, url) => this.traceDeviceEmulation('did-navigate', url)); + webContents.on('did-navigate-in-page', (_e, url, isMainFrame) => { + if (isMainFrame) { + this.traceDeviceEmulation('did-navigate-in-page', url); + } + }); + webContents.on('did-navigate', () => { // Chromium resets the zoom factor to its per-origin default (100%) when // navigating to a new document. Re-apply our stored zoom to override it. this._consoleLogs.length = 0; // Clear console logs on navigation since they are per-page this._view.webContents.setZoomFactor(browserZoomFactors[this._browserZoomIndex]); + // CDP overrides can be reset by Chromium on cross-process navigation; reapply + // unless we initiated this navigation ourselves and already applied them. + if (this._deviceEmulation && !this._skipNextNavigateEmulationReapply) { + void this.applyDeviceEmulation(); + } + this._skipNextNavigateEmulationReapply = false; + // Enable pinch-to-zoom void this._view.webContents.setVisualZoomLevelLimits(1, 3).catch(error => { this.logService.error('Failed to set visual zoom level limits for browser view webContents.', error); @@ -472,7 +508,8 @@ export class BrowserView extends Disposable { certificateError: this.session.trust.getCertificateError(url), storageScope: this.session.storageScope, browserZoomIndex: this._browserZoomIndex, - isElementSelectionActive: this.inspector.isElementSelectionActive + isElementSelectionActive: this.inspector.isElementSelectionActive, + deviceEmulation: this._deviceEmulation }; } @@ -497,12 +534,144 @@ export class BrowserView extends Disposable { } this._view.setBorderRadius(Math.round(bounds.cornerRadius * bounds.zoomFactor)); - this._view.setBounds({ - x: Math.round(bounds.x * bounds.zoomFactor), - y: Math.round(bounds.y * bounds.zoomFactor), - width: Math.round(bounds.width * bounds.zoomFactor), - height: Math.round(bounds.height * bounds.zoomFactor) - }); + + // When a device profile is active, clamp the view to the device dimensions and + // center it inside the pane so the editor background fills the empty area instead + // of the WebContentsView painting an opaque (black) backdrop. + const z = bounds.zoomFactor; + let viewWidth = Math.round(bounds.width * z); + let viewHeight = Math.round(bounds.height * z); + let viewX = Math.round(bounds.x * z); + const viewY = Math.round(bounds.y * z); + const device = this._deviceEmulation; + if (device) { + const deviceWidth = Math.round(device.width * z); + const deviceHeight = Math.round(device.height * z); + if (deviceWidth < viewWidth) { + viewX += Math.round((viewWidth - deviceWidth) / 2); + viewWidth = deviceWidth; + } + if (deviceHeight < viewHeight) { + viewHeight = deviceHeight; + } + } + this._view.setBounds({ x: viewX, y: viewY, width: viewWidth, height: viewHeight }); + + this._lastBounds = bounds; + this.syncDeviceMetrics(bounds); + } + + private _lastBounds: IBrowserViewBounds | undefined; + private _lastDeviceMetrics: { width: number; height: number } | undefined; + private _skipNextNavigateEmulationReapply = false; + private _deviceEmulationTraceStart = 0; + + private traceDeviceEmulation(event: string, extra?: string): void { + if (!this._deviceEmulationTraceStart) { + return; + } + const dt = Date.now() - this._deviceEmulationTraceStart; + this.logService.info(`[BrowserView][trace +${dt}ms] ${event}${extra ? ' ' + extra : ''}`); + } + + // Mirror container size to the page's layout viewport so CSS media queries see the actual width. + // Skipped when an explicit device profile is active — that profile owns the viewport. + private syncDeviceMetrics(bounds: IBrowserViewBounds): void { + if (this._deviceEmulation) { + return; + } + const width = Math.max(1, Math.round(bounds.width)); + const height = Math.max(1, Math.round(bounds.height)); + if (this._lastDeviceMetrics?.width === width && this._lastDeviceMetrics.height === height) { + return; + } + this._lastDeviceMetrics = { width, height }; + + this.debugger.sendCommand('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor: 0, + mobile: false, + }).catch(err => this.logService.warn('[BrowserView] setDeviceMetricsOverride failed', err)); + } + + get deviceEmulation(): IBrowserDeviceEmulation | undefined { + return this._deviceEmulation; + } + + async setDeviceEmulation(device: IBrowserDeviceEmulation | undefined): Promise { + const previous = this._deviceEmulation; + this._deviceEmulation = device; + this._lastDeviceMetrics = undefined; + + // Trace window: keep tracing webContents/network events for 5s after a device switch + // so we can see exactly what the page does (client-side nav, refetches, etc). + this._deviceEmulationTraceStart = Date.now(); + setTimeout(() => { this._deviceEmulationTraceStart = 0; }, 5000); + this.traceDeviceEmulation('setDeviceEmulation:start', device ? `${device.id} ${device.width}x${device.height}` : 'off'); + + // Use Electron's native API for UA. CDP's setUserAgentOverride doesn't reliably + // stick across webContents.reload() and is also scoped to the current target only. + this._view.webContents.setUserAgent(device?.userAgent ?? ''); + this.traceDeviceEmulation('setUserAgent', `"${(device?.userAgent ?? '').slice(0, 60)}…"`); + + this.logService.info('[BrowserView] setDeviceEmulation', device ? `${device.id} (${device.width}x${device.height})` : 'off'); + + // Resize the WebContentsView BEFORE applying CDP overrides so the view is the + // right size when the page's viewport shrinks — avoids a one-frame flash where + // the page paints at device width inside the still-full-pane-sized view. + if (this._lastBounds) { + this.layout(this._lastBounds); + this.traceDeviceEmulation('clampedLayout'); + } + + await this.applyDeviceEmulation(); + this.traceDeviceEmulation('applyDeviceEmulation:done'); + + // Restore the responsive mirror when going back to default so CSS media queries + // continue to reflect the actual pane width. + if (!device && this._lastBounds) { + this.syncDeviceMetrics(this._lastBounds); + } + + // Do not reload here. Matches Chrome DevTools Device Mode: metrics/touch/media apply + // instantly to the current page, and the new UA takes effect on the next navigation. + // Sites that serve different HTML for mobile UAs will only switch once the user reloads. + void previous; + + this._onDidChangeDeviceEmulation.fire(device); + } + + private async applyDeviceEmulation(): Promise { + const device = this._deviceEmulation; + try { + if (device) { + await this.debugger.sendCommand('Emulation.setDeviceMetricsOverride', { + width: device.width, + height: device.height, + deviceScaleFactor: device.deviceScaleFactor, + mobile: device.mobile, + screenOrientation: device.mobile + ? { type: 'portraitPrimary', angle: 0 } + : undefined, + }); + await this.debugger.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: !!device.hasTouch, + maxTouchPoints: device.hasTouch ? 5 : 0, + }); + await this.debugger.sendCommand('Emulation.setEmulatedMedia', { + features: [{ name: 'pointer', value: device.hasTouch ? 'coarse' : 'fine' }], + }); + this.logService.info('[BrowserView] device emulation applied'); + } else { + await this.debugger.sendCommand('Emulation.clearDeviceMetricsOverride', {}); + await this.debugger.sendCommand('Emulation.setTouchEmulationEnabled', { enabled: false, maxTouchPoints: 1 }); + await this.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [] }); + this.logService.info('[BrowserView] device emulation cleared'); + } + } catch (err) { + this.logService.error('[BrowserView] applyDeviceEmulation failed', err); + } } setBrowserZoomIndex(zoomIndex: number): void { diff --git a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts index 30f0c1d805377..94e0a226b515f 100644 --- a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts +++ b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts @@ -105,11 +105,6 @@ export class BrowserViewDebugger extends Disposable { * Send a CDP command. Handles Electron-specific workarounds in a single place. */ sendCommand(method: string, params?: unknown, sessionId?: string): Promise { - // This crashes Electron. Don't pass it through. - if (method === 'Emulation.setDeviceMetricsOverride') { - return Promise.resolve({}); - } - this.ensureAttached(); const resultPromise = this._electronDebugger.sendCommand(method, params, sessionId); diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 62fdb0cb098e8..bcb134ebc1cc7 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions, IBrowserViewTheme, IBrowserViewConfiguration } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions, IBrowserViewTheme, IBrowserViewConfiguration, IBrowserDeviceEmulation } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -187,6 +187,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).inspector.onDidChangeElementSelectionActive; } + onDynamicDidChangeDeviceEmulation(id: string) { + return this._getBrowserView(id).onDidChangeDeviceEmulation; + } + async getState(id: string): Promise { return this._getBrowserView(id).getState(); } @@ -263,6 +267,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).setBrowserZoomIndex(zoomIndex); } + async setDeviceEmulation(id: string, device: IBrowserDeviceEmulation | undefined): Promise { + return this._getBrowserView(id).setDeviceEmulation(device); + } + async trustCertificate(id: string, host: string, fingerprint: string): Promise { return this._getBrowserView(id).trustCertificate(host, fingerprint); } diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 14fcb2ddc275e..9c536a68e3c5e 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -35,7 +35,8 @@ import { IBrowserViewOwner, browserZoomDefaultIndex, browserZoomFactors, - IBrowserViewState + IBrowserViewState, + IBrowserDeviceEmulation } from '../../../../platform/browserView/common/browserView.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; @@ -213,6 +214,7 @@ export interface IBrowserViewModel extends IDisposable { readonly canZoomIn: boolean; readonly canZoomOut: boolean; readonly isElementSelectionActive: boolean; + readonly deviceEmulation: IBrowserDeviceEmulation | undefined; readonly onDidChangeSharingState: Event; readonly onDidChangeZoom: Event; @@ -229,6 +231,7 @@ export interface IBrowserViewModel extends IDisposable { readonly onWillDispose: Event; readonly onDidSelectElement: Event; readonly onDidChangeElementSelectionActive: Event; + readonly onDidChangeDeviceEmulation: Event; layout(bounds: IBrowserViewBounds): Promise; setVisible(visible: boolean): Promise; @@ -251,6 +254,7 @@ export interface IBrowserViewModel extends IDisposable { resetZoom(): Promise; getConsoleLogs(): Promise; toggleElementSelection(enabled?: boolean): Promise; + setDeviceEmulation(device: IBrowserDeviceEmulation | undefined): Promise; } export class BrowserViewModel extends Disposable implements IBrowserViewModel { @@ -272,6 +276,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _sharedWithAgent: boolean = false; private _browserZoomIndex: number = browserZoomDefaultIndex; private _isElementSelectionActive: boolean = false; + private _deviceEmulation: IBrowserDeviceEmulation | undefined; private readonly _onDidChangeSharingState = this._register(new Emitter()); readonly onDidChangeSharingState: Event = this._onDidChangeSharingState.event; @@ -314,6 +319,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._storageScope = initialState.storageScope; this._browserZoomIndex = initialState.browserZoomIndex; this._isElementSelectionActive = initialState.isElementSelectionActive; + this._deviceEmulation = initialState.deviceEmulation; this._isEphemeral = this._storageScope === BrowserViewStorageScope.Ephemeral; this._zoomHost = parseZoomHost(this._url); @@ -394,6 +400,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._isElementSelectionActive = active; })); + this._register(this.onDidChangeDeviceEmulation(device => { + this._deviceEmulation = device; + })); + this._register(this.playwrightService.onDidChangeTrackedPages(ids => { this._setSharedWithAgent(ids.includes(this.id)); })); @@ -599,6 +609,18 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.onDynamicDidChangeElementSelectionActive(this.id); } + get deviceEmulation(): IBrowserDeviceEmulation | undefined { + return this._deviceEmulation; + } + + get onDidChangeDeviceEmulation(): Event { + return this.browserViewService.onDynamicDidChangeDeviceEmulation(this.id); + } + + async setDeviceEmulation(device: IBrowserDeviceEmulation | undefined): Promise { + return this.browserViewService.setDeviceEmulation(this.id, device); + } + private static readonly SHARE_DONT_ASK_KEY = 'browserView.shareWithAgent.dontAskAgain'; async setSharedWithAgent(shared: boolean): Promise { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 7fbbfad8b4c0e..1784189fd730e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -575,6 +575,9 @@ export class BrowserEditor extends EditorPane { // Start / stop screenshots when the model visibility changes this._inputDisposables.add(this._model.onDidChangeVisibility(() => this.doScreenshot())); + // Re-clip the placeholder to match the clamped WebContentsView when device emulation toggles. + this._inputDisposables.add(this._model.onDidChangeDeviceEmulation(() => this.layoutBrowserContainer())); + // Listen to model events for UI updates this._inputDisposables.add(this._model.onDidKeyCommand(keyEvent => { // Handle like webview does - convert to webview KeyEvent format @@ -1027,6 +1030,19 @@ export class BrowserEditor extends EditorPane { zoomFactor: getZoomFactor(this.window), cornerRadius: parseFloat(cornerRadius) }); + + // Clip the placeholder screenshot to match the area the WebContentsView occupies. + // During device emulation the live view is clamped to the device width and centered, + // so the placeholder must follow to avoid bleeding an outdated screenshot on the sides. + const device = this._model.deviceEmulation; + if (device && containerRect.width > device.width) { + const offset = Math.floor((containerRect.width - device.width) / 2); + this._placeholderScreenshot.style.left = `${offset}px`; + this._placeholderScreenshot.style.right = `${offset}px`; + } else { + this._placeholderScreenshot.style.left = ''; + this._placeholderScreenshot.style.right = ''; + } } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index e8fc1087cc783..2167000b2cd98 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -25,6 +25,7 @@ import './features/browserDataStorageFeatures.js'; import './features/browserDevToolsFeature.js'; import './features/browserEditorChatFeatures.js'; import './features/browserEditorZoomFeature.js'; +import './features/browserEditorDeviceFeature.js'; import './features/browserEditorFindFeature.js'; import './features/browserTabManagementFeatures.js'; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorDeviceFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorDeviceFeature.ts new file mode 100644 index 0000000000000..ef0a89fa212bc --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorDeviceFeature.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { BROWSER_DEVICE_PRESETS, IBrowserDeviceEmulation } from '../../../../../platform/browserView/common/browserView.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IBrowserViewModel } from '../../../browserView/common/browserView.js'; +import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, IBrowserEditorWidgetContribution } from '../browserEditor.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; + +/** + * Small pill shown in the URL bar when a device emulation profile is active. + */ +class BrowserDevicePill extends Disposable { + readonly element: HTMLElement; + private readonly _label: HTMLElement; + + constructor() { + super(); + this.element = $('.browser-device-pill'); + this.element.style.display = 'none'; + const icon = $('span'); + icon.className = ThemeIcon.asClassName(Codicon.deviceMobile); + this._label = $('span'); + this.element.appendChild(icon); + this.element.appendChild(this._label); + } + + update(device: IBrowserDeviceEmulation | undefined): void { + if (device) { + this._label.textContent = device.label; + this.element.title = localize('browser.deviceEmulationActive', "Emulating {0} ({1}×{2})", device.label, device.width, device.height); + this.element.style.display = ''; + this.element.classList.add('visible'); + } else { + this.element.classList.remove('visible'); + this.element.style.display = 'none'; + } + } +} + +export class BrowserEditorDeviceSupport extends BrowserEditorContribution { + private readonly _pill: BrowserDevicePill; + + constructor(editor: BrowserEditor) { + super(editor); + this._pill = this._register(new BrowserDevicePill()); + } + + override get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { + return [{ element: this._pill.element, order: 10 }]; + } + + protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + this._pill.update(model.deviceEmulation); + store.add(model.onDidChangeDeviceEmulation(device => this._pill.update(device))); + } + + override clear(): void { + this._pill.update(undefined); + } +} + +BrowserEditor.registerContribution(BrowserEditorDeviceSupport); + +interface IDevicePickItem extends IQuickPickItem { + readonly device: IBrowserDeviceEmulation | undefined; +} + +class PickBrowserDeviceAction extends Action2 { + static readonly ID = 'workbench.action.browser.pickDevice'; + + constructor() { + super({ + id: PickBrowserDeviceAction.ID, + title: localize2('browser.pickDevice', 'Emulate Device…'), + category: BrowserActionCategory, + icon: Codicon.deviceMobile, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Page, + order: 10, + }, + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (!(browserEditor instanceof BrowserEditor)) { + return; + } + const model = browserEditor.model; + if (!model) { + return; + } + + const quickInputService = accessor.get(IQuickInputService); + const current = model.deviceEmulation; + + const items: (IDevicePickItem | IQuickPickSeparator)[] = [ + { + label: localize('browser.device.responsive', "Responsive (default)"), + description: localize('browser.device.responsiveDescription', "Page mirrors the pane size"), + device: undefined, + picked: !current, + }, + { type: 'separator', label: localize('browser.device.presets', "Presets") }, + ...BROWSER_DEVICE_PRESETS.map(device => ({ + label: device.label, + description: `${device.width} × ${device.height} @ ${device.deviceScaleFactor}x`, + device, + picked: current?.id === device.id, + })), + ]; + + const picked = await quickInputService.pick(items, { + placeHolder: localize('browser.device.placeholder', "Select a device to emulate"), + activeItem: items.find((item): item is IDevicePickItem => item.type !== 'separator' && !!item.picked), + }); + + if (picked) { + await model.setDeviceEmulation(picked.device); + } + } +} + +registerAction2(PickBrowserDeviceAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index d90a81b267bfe..8a11fdc75688d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -115,6 +115,30 @@ } } + .browser-device-pill { + display: none; + align-items: center; + gap: 4px; + margin: 0; + padding: 1px 6px; + flex-shrink: 0; + border-radius: 9999px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + font-size: 11px; + line-height: 1; + white-space: nowrap; + pointer-events: none; + + &.visible { + display: flex; + } + + .codicon { + font-size: 12px; + } + } + .browser-link-opened-hint-pill { display: none; align-items: center;