Path: blob/main/src/vs/workbench/contrib/browserView/common/browserZoomService.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Emitter, Event } from '../../../../base/common/event.js';6import { Disposable } from '../../../../base/common/lifecycle.js';7import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';8import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';9import { browserZoomDefaultIndex, browserZoomFactors } from '../../../../platform/browserView/common/browserView.js';10import { zoomLevelToZoomFactor } from '../../../../platform/window/common/window.js';11import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';1213export const IBrowserZoomService = createDecorator<IBrowserZoomService>('browserZoomService');1415/** Storage key for the per-host persistent zoom map. */16const BROWSER_ZOOM_PER_HOST_STORAGE_KEY = 'browserView.zoomPerHost';1718/**19* Special value for the default zoom level setting that instructs the browser view20* to dynamically match the closest zoom level to the application's current UI zoom.21*/22export const MATCH_WINDOW_ZOOM_LABEL = 'Match Window';2324export interface IBrowserZoomChangeEvent {25/**26* The host (e.g. `"example.com"`) whose zoom changed, or `undefined`27* when the global default zoom level changed.28*/29readonly host: string | undefined;3031/**32* Whether the change came from an ephemeral session.33* - `true` → only ephemeral views need to react.34* - `false` → all views (ephemeral and non-ephemeral) for the host may be affected.35*/36readonly isEphemeralChange: boolean;37}3839/**40* Manages two independent cascading zoom hierarchies for integrated browser views:41*42* Normal views: `persistent per-host override` ?? `configured default`43* Ephemeral views: `ephemeral per-host override` ?? `configured default`44*45* Ephemeral views never see persistent overrides directly. Instead, when a persistent46* value changes, it is copied into the ephemeral map so that ephemeral views47* immediately reflect the new level. Conversely, ephemeral changes never affect48* normal views.49*50* Per-host values that equal the current default are always removed (both persistent51* and ephemeral), so the view tracks the default going forward.52*/53export interface IBrowserZoomService {54readonly _serviceBrand: undefined;5556/** Fired whenever the effective zoom for a host may have changed. */57readonly onDidChangeZoom: Event<IBrowserZoomChangeEvent>;5859/**60* Returns the effective zoom index for the given host and session type.61* Pass `host = undefined` to obtain only the configured default zoom index.62*/63getEffectiveZoomIndex(host: string | undefined, isEphemeral: boolean): number;6465/**66* Set the zoom for a host.67*68* Non-ephemeral: persisted to storage. Also propagated into69* the ephemeral map so ephemeral views immediately reflect the change.70*71* Ephemeral: stored in memory only, dropped on restart.72*73* In both cases, if the value equals the current default, the entry is removed so the74* view tracks the default going forward.75*/76setHostZoomIndex(host: string, zoomIndex: number, isEphemeral: boolean): void;7778/**79* Notifies the service of the application's current UI zoom factor.80* Must be called once on startup and again whenever the window zoom changes.81* Only relevant when the default zoom level is set to `MATCH_WINDOW_LABEL`.82*/83notifyWindowZoomChanged(windowZoomFactor: number): void;84}8586// ---------------------------------------------------------------------------87// Implementation88// ---------------------------------------------------------------------------8990/** Pre-computed map from percentage label (e.g. "125%") to index into browserZoomFactors. */91const ZOOM_LABEL_TO_INDEX = new Map<string, number>(92browserZoomFactors.map((f, i) => [`${Math.round(f * 100)}%`, i])93);9495export class BrowserZoomService extends Disposable implements IBrowserZoomService {96declare readonly _serviceBrand: undefined;9798private readonly _onDidChangeZoom = this._register(new Emitter<IBrowserZoomChangeEvent>());99readonly onDidChangeZoom: Event<IBrowserZoomChangeEvent> = this._onDidChangeZoom.event;100101/**102* In-memory cache of the persistent per-host map.103* Backed by IStorageService.104*/105private _persistentZoomMap: Record<string, number>;106107/** In-memory only; dropped on restart. */108private readonly _ephemeralZoomMap = new Map<string, number>();109110private _windowZoomFactor: number = zoomLevelToZoomFactor(0); // default: zoom level 0 → factor 1.0111112constructor(113@IConfigurationService private readonly configurationService: IConfigurationService,114@IStorageService private readonly storageService: IStorageService,115) {116super();117118this._persistentZoomMap = this._readPersistentZoomMap();119120this._register(this.configurationService.onDidChangeConfiguration(e => {121if (e.affectsConfiguration('workbench.browser.pageZoom')) {122this._onDidChangeZoom.fire({ host: undefined, isEphemeralChange: false });123}124}));125}126127getEffectiveZoomIndex(host: string | undefined, isEphemeral: boolean): number {128if (host !== undefined) {129if (isEphemeral) {130const ephemeralIndex = this._ephemeralZoomMap.get(host);131if (ephemeralIndex !== undefined) {132return this._clamp(ephemeralIndex);133}134} else {135const persistentIndex = this._persistentZoomMap[host];136if (persistentIndex !== undefined) {137return this._clamp(persistentIndex);138}139}140}141142return this._getDefaultZoomIndex();143}144145setHostZoomIndex(host: string, zoomIndex: number, isEphemeral: boolean): void {146const clamped = this._clamp(zoomIndex);147const defaultIndex = this._getDefaultZoomIndex();148const matchesDefault = clamped === defaultIndex;149150if (isEphemeral) {151if (matchesDefault) {152if (!this._ephemeralZoomMap.has(host)) {153return;154}155this._ephemeralZoomMap.delete(host);156} else {157if (this._ephemeralZoomMap.get(host) === clamped) {158return;159}160this._ephemeralZoomMap.set(host, clamped);161}162this._onDidChangeZoom.fire({ host, isEphemeralChange: true });163} else {164let persistentChanged = false;165if (matchesDefault) {166if (Object.prototype.hasOwnProperty.call(this._persistentZoomMap, host)) {167delete this._persistentZoomMap[host];168persistentChanged = true;169}170} else if (this._persistentZoomMap[host] !== clamped) {171this._persistentZoomMap[host] = clamped;172persistentChanged = true;173}174175// Propagate to ephemeral map so ephemeral views immediately reflect the new level.176let ephemeralChanged = false;177if (matchesDefault) {178ephemeralChanged = this._ephemeralZoomMap.delete(host);179} else if (this._ephemeralZoomMap.get(host) !== clamped) {180this._ephemeralZoomMap.set(host, clamped);181ephemeralChanged = true;182}183184if (!persistentChanged && !ephemeralChanged) {185return;186}187if (persistentChanged) {188this._writePersistentZoomMap();189}190this._onDidChangeZoom.fire({ host, isEphemeralChange: false });191}192}193194notifyWindowZoomChanged(windowZoomFactor: number): void {195this._windowZoomFactor = windowZoomFactor;196const label = this.configurationService.getValue<string>('workbench.browser.pageZoom');197if (label === MATCH_WINDOW_ZOOM_LABEL) {198this._onDidChangeZoom.fire({ host: undefined, isEphemeralChange: false });199}200}201202// ---------------------------------------------------------------------------203// Helpers204// ---------------------------------------------------------------------------205206private _getDefaultZoomIndex(): number {207const label = this.configurationService.getValue<string>('workbench.browser.pageZoom');208if (label === MATCH_WINDOW_ZOOM_LABEL) {209return this._getMatchWindowZoomIndex();210}211return ZOOM_LABEL_TO_INDEX.get(label) ?? browserZoomDefaultIndex;212}213214/**215* Finds the browser zoom index whose factor is closest to the application's current UI zoom216* factor, measuring distance on a log scale (since window zoom levels are powers of 1.2).217*/218private _getMatchWindowZoomIndex(): number {219const windowFactor = this._windowZoomFactor;220let bestIndex = browserZoomDefaultIndex;221let bestDist = Infinity;222for (let i = 0; i < browserZoomFactors.length; i++) {223const dist = Math.abs(Math.log(browserZoomFactors[i]) - Math.log(windowFactor));224if (dist < bestDist) {225bestDist = dist;226bestIndex = i;227}228}229return bestIndex;230}231232/**233* Reads the persistent per-host zoom map from storage.234* The stored format is a JSON object mapping host strings to zoom indices.235*/236private _readPersistentZoomMap(): Record<string, number> {237const raw = this.storageService.get(BROWSER_ZOOM_PER_HOST_STORAGE_KEY, StorageScope.PROFILE);238if (!raw) {239return {};240}241try {242const parsed = JSON.parse(raw);243if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {244return {};245}246const result: Record<string, number> = {};247for (const [host, index] of Object.entries(parsed)) {248if (typeof index === 'number' && index >= 0 && index < browserZoomFactors.length) {249result[host] = index;250}251}252return result;253} catch {254return {};255}256}257258private _writePersistentZoomMap(): void {259const hasEntries = Object.keys(this._persistentZoomMap).length > 0;260if (hasEntries) {261this.storageService.store(BROWSER_ZOOM_PER_HOST_STORAGE_KEY, JSON.stringify(this._persistentZoomMap), StorageScope.PROFILE, StorageTarget.MACHINE);262} else {263this.storageService.remove(BROWSER_ZOOM_PER_HOST_STORAGE_KEY, StorageScope.PROFILE);264}265}266267private _clamp(index: number): number {268return Math.max(0, Math.min(Math.trunc(index), browserZoomFactors.length - 1));269}270}271272273