Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/browserView/common/browserZoomService.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Emitter, Event } from '../../../../base/common/event.js';
7
import { Disposable } from '../../../../base/common/lifecycle.js';
8
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
9
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
10
import { browserZoomDefaultIndex, browserZoomFactors } from '../../../../platform/browserView/common/browserView.js';
11
import { zoomLevelToZoomFactor } from '../../../../platform/window/common/window.js';
12
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
13
14
export const IBrowserZoomService = createDecorator<IBrowserZoomService>('browserZoomService');
15
16
/** Storage key for the per-host persistent zoom map. */
17
const BROWSER_ZOOM_PER_HOST_STORAGE_KEY = 'browserView.zoomPerHost';
18
19
/**
20
* Special value for the default zoom level setting that instructs the browser view
21
* to dynamically match the closest zoom level to the application's current UI zoom.
22
*/
23
export const MATCH_WINDOW_ZOOM_LABEL = 'Match Window';
24
25
export interface IBrowserZoomChangeEvent {
26
/**
27
* The host (e.g. `"example.com"`) whose zoom changed, or `undefined`
28
* when the global default zoom level changed.
29
*/
30
readonly host: string | undefined;
31
32
/**
33
* Whether the change came from an ephemeral session.
34
* - `true` → only ephemeral views need to react.
35
* - `false` → all views (ephemeral and non-ephemeral) for the host may be affected.
36
*/
37
readonly isEphemeralChange: boolean;
38
}
39
40
/**
41
* Manages two independent cascading zoom hierarchies for integrated browser views:
42
*
43
* Normal views: `persistent per-host override` ?? `configured default`
44
* Ephemeral views: `ephemeral per-host override` ?? `configured default`
45
*
46
* Ephemeral views never see persistent overrides directly. Instead, when a persistent
47
* value changes, it is copied into the ephemeral map so that ephemeral views
48
* immediately reflect the new level. Conversely, ephemeral changes never affect
49
* normal views.
50
*
51
* Per-host values that equal the current default are always removed (both persistent
52
* and ephemeral), so the view tracks the default going forward.
53
*/
54
export interface IBrowserZoomService {
55
readonly _serviceBrand: undefined;
56
57
/** Fired whenever the effective zoom for a host may have changed. */
58
readonly onDidChangeZoom: Event<IBrowserZoomChangeEvent>;
59
60
/**
61
* Returns the effective zoom index for the given host and session type.
62
* Pass `host = undefined` to obtain only the configured default zoom index.
63
*/
64
getEffectiveZoomIndex(host: string | undefined, isEphemeral: boolean): number;
65
66
/**
67
* Set the zoom for a host.
68
*
69
* Non-ephemeral: persisted to storage. Also propagated into
70
* the ephemeral map so ephemeral views immediately reflect the change.
71
*
72
* Ephemeral: stored in memory only, dropped on restart.
73
*
74
* In both cases, if the value equals the current default, the entry is removed so the
75
* view tracks the default going forward.
76
*/
77
setHostZoomIndex(host: string, zoomIndex: number, isEphemeral: boolean): void;
78
79
/**
80
* Notifies the service of the application's current UI zoom factor.
81
* Must be called once on startup and again whenever the window zoom changes.
82
* Only relevant when the default zoom level is set to `MATCH_WINDOW_LABEL`.
83
*/
84
notifyWindowZoomChanged(windowZoomFactor: number): void;
85
}
86
87
// ---------------------------------------------------------------------------
88
// Implementation
89
// ---------------------------------------------------------------------------
90
91
/** Pre-computed map from percentage label (e.g. "125%") to index into browserZoomFactors. */
92
const ZOOM_LABEL_TO_INDEX = new Map<string, number>(
93
browserZoomFactors.map((f, i) => [`${Math.round(f * 100)}%`, i])
94
);
95
96
export class BrowserZoomService extends Disposable implements IBrowserZoomService {
97
declare readonly _serviceBrand: undefined;
98
99
private readonly _onDidChangeZoom = this._register(new Emitter<IBrowserZoomChangeEvent>());
100
readonly onDidChangeZoom: Event<IBrowserZoomChangeEvent> = this._onDidChangeZoom.event;
101
102
/**
103
* In-memory cache of the persistent per-host map.
104
* Backed by IStorageService.
105
*/
106
private _persistentZoomMap: Record<string, number>;
107
108
/** In-memory only; dropped on restart. */
109
private readonly _ephemeralZoomMap = new Map<string, number>();
110
111
private _windowZoomFactor: number = zoomLevelToZoomFactor(0); // default: zoom level 0 → factor 1.0
112
113
constructor(
114
@IConfigurationService private readonly configurationService: IConfigurationService,
115
@IStorageService private readonly storageService: IStorageService,
116
) {
117
super();
118
119
this._persistentZoomMap = this._readPersistentZoomMap();
120
121
this._register(this.configurationService.onDidChangeConfiguration(e => {
122
if (e.affectsConfiguration('workbench.browser.pageZoom')) {
123
this._onDidChangeZoom.fire({ host: undefined, isEphemeralChange: false });
124
}
125
}));
126
}
127
128
getEffectiveZoomIndex(host: string | undefined, isEphemeral: boolean): number {
129
if (host !== undefined) {
130
if (isEphemeral) {
131
const ephemeralIndex = this._ephemeralZoomMap.get(host);
132
if (ephemeralIndex !== undefined) {
133
return this._clamp(ephemeralIndex);
134
}
135
} else {
136
const persistentIndex = this._persistentZoomMap[host];
137
if (persistentIndex !== undefined) {
138
return this._clamp(persistentIndex);
139
}
140
}
141
}
142
143
return this._getDefaultZoomIndex();
144
}
145
146
setHostZoomIndex(host: string, zoomIndex: number, isEphemeral: boolean): void {
147
const clamped = this._clamp(zoomIndex);
148
const defaultIndex = this._getDefaultZoomIndex();
149
const matchesDefault = clamped === defaultIndex;
150
151
if (isEphemeral) {
152
if (matchesDefault) {
153
if (!this._ephemeralZoomMap.has(host)) {
154
return;
155
}
156
this._ephemeralZoomMap.delete(host);
157
} else {
158
if (this._ephemeralZoomMap.get(host) === clamped) {
159
return;
160
}
161
this._ephemeralZoomMap.set(host, clamped);
162
}
163
this._onDidChangeZoom.fire({ host, isEphemeralChange: true });
164
} else {
165
let persistentChanged = false;
166
if (matchesDefault) {
167
if (Object.prototype.hasOwnProperty.call(this._persistentZoomMap, host)) {
168
delete this._persistentZoomMap[host];
169
persistentChanged = true;
170
}
171
} else if (this._persistentZoomMap[host] !== clamped) {
172
this._persistentZoomMap[host] = clamped;
173
persistentChanged = true;
174
}
175
176
// Propagate to ephemeral map so ephemeral views immediately reflect the new level.
177
let ephemeralChanged = false;
178
if (matchesDefault) {
179
ephemeralChanged = this._ephemeralZoomMap.delete(host);
180
} else if (this._ephemeralZoomMap.get(host) !== clamped) {
181
this._ephemeralZoomMap.set(host, clamped);
182
ephemeralChanged = true;
183
}
184
185
if (!persistentChanged && !ephemeralChanged) {
186
return;
187
}
188
if (persistentChanged) {
189
this._writePersistentZoomMap();
190
}
191
this._onDidChangeZoom.fire({ host, isEphemeralChange: false });
192
}
193
}
194
195
notifyWindowZoomChanged(windowZoomFactor: number): void {
196
this._windowZoomFactor = windowZoomFactor;
197
const label = this.configurationService.getValue<string>('workbench.browser.pageZoom');
198
if (label === MATCH_WINDOW_ZOOM_LABEL) {
199
this._onDidChangeZoom.fire({ host: undefined, isEphemeralChange: false });
200
}
201
}
202
203
// ---------------------------------------------------------------------------
204
// Helpers
205
// ---------------------------------------------------------------------------
206
207
private _getDefaultZoomIndex(): number {
208
const label = this.configurationService.getValue<string>('workbench.browser.pageZoom');
209
if (label === MATCH_WINDOW_ZOOM_LABEL) {
210
return this._getMatchWindowZoomIndex();
211
}
212
return ZOOM_LABEL_TO_INDEX.get(label) ?? browserZoomDefaultIndex;
213
}
214
215
/**
216
* Finds the browser zoom index whose factor is closest to the application's current UI zoom
217
* factor, measuring distance on a log scale (since window zoom levels are powers of 1.2).
218
*/
219
private _getMatchWindowZoomIndex(): number {
220
const windowFactor = this._windowZoomFactor;
221
let bestIndex = browserZoomDefaultIndex;
222
let bestDist = Infinity;
223
for (let i = 0; i < browserZoomFactors.length; i++) {
224
const dist = Math.abs(Math.log(browserZoomFactors[i]) - Math.log(windowFactor));
225
if (dist < bestDist) {
226
bestDist = dist;
227
bestIndex = i;
228
}
229
}
230
return bestIndex;
231
}
232
233
/**
234
* Reads the persistent per-host zoom map from storage.
235
* The stored format is a JSON object mapping host strings to zoom indices.
236
*/
237
private _readPersistentZoomMap(): Record<string, number> {
238
const raw = this.storageService.get(BROWSER_ZOOM_PER_HOST_STORAGE_KEY, StorageScope.PROFILE);
239
if (!raw) {
240
return {};
241
}
242
try {
243
const parsed = JSON.parse(raw);
244
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
245
return {};
246
}
247
const result: Record<string, number> = {};
248
for (const [host, index] of Object.entries(parsed)) {
249
if (typeof index === 'number' && index >= 0 && index < browserZoomFactors.length) {
250
result[host] = index;
251
}
252
}
253
return result;
254
} catch {
255
return {};
256
}
257
}
258
259
private _writePersistentZoomMap(): void {
260
const hasEntries = Object.keys(this._persistentZoomMap).length > 0;
261
if (hasEntries) {
262
this.storageService.store(BROWSER_ZOOM_PER_HOST_STORAGE_KEY, JSON.stringify(this._persistentZoomMap), StorageScope.PROFILE, StorageTarget.MACHINE);
263
} else {
264
this.storageService.remove(BROWSER_ZOOM_PER_HOST_STORAGE_KEY, StorageScope.PROFILE);
265
}
266
}
267
268
private _clamp(index: number): number {
269
return Math.max(0, Math.min(Math.trunc(index), browserZoomFactors.length - 1));
270
}
271
}
272
273