Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts
13397 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 { CancellationToken } from '../../../base/common/cancellation.js';
7
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
8
import { IElementData, IElementAncestor } from '../common/browserView.js';
9
import { ICDPConnection } from '../common/cdp/types.js';
10
import type { BrowserView } from './browserView.js';
11
12
type Quad = [number, number, number, number, number, number, number, number];
13
14
interface IBoxModel {
15
content: Quad;
16
padding: Quad;
17
border: Quad;
18
margin: Quad;
19
width: number;
20
height: number;
21
}
22
23
interface ICSSStyle {
24
cssText?: string;
25
cssProperties: Array<{ name: string; value: string }>;
26
}
27
28
interface ISelectorList {
29
selectors: Array<{ text: string }>;
30
}
31
32
interface ICSSRule {
33
selectorList: ISelectorList;
34
origin: string;
35
style: ICSSStyle;
36
}
37
38
interface IRuleMatch {
39
rule: ICSSRule;
40
}
41
42
interface IInheritedStyleEntry {
43
inlineStyle?: ICSSStyle;
44
matchedCSSRules: IRuleMatch[];
45
}
46
47
interface IMatchedStyles {
48
inlineStyle?: ICSSStyle;
49
matchedCSSRules?: IRuleMatch[];
50
inherited?: IInheritedStyleEntry[];
51
}
52
53
interface INode {
54
nodeId: number;
55
backendNodeId: number;
56
parentId?: number;
57
localName: string;
58
attributes: string[];
59
children?: INode[];
60
pseudoElements?: INode[];
61
}
62
63
interface ILayoutMetricsResult {
64
cssVisualViewport?: {
65
scale?: number;
66
};
67
}
68
69
function useScopedDisposal() {
70
const store = new DisposableStore() as DisposableStore & { [Symbol.dispose](): void };
71
store[Symbol.dispose] = () => store.dispose();
72
return store;
73
}
74
75
/**
76
* Manages element inspection on a browser view.
77
*
78
* Attaches a persistent CDP session in the constructor; methods wait for
79
* it to be ready before issuing commands.
80
*/
81
export class BrowserViewElementInspector extends Disposable {
82
83
private readonly _connectionPromise: Promise<ICDPConnection>;
84
85
constructor(private readonly browser: BrowserView) {
86
super();
87
88
this._connectionPromise = browser.debugger.attach().then(
89
async conn => {
90
try {
91
// Important: don't use `Runtime.*` commands so we can support inspection during debugging.
92
// We also initialize here rather than during selection as CSS.enable will hang if debugging is paused, but works if enabled beforehand.
93
await conn.sendCommand('DOM.enable');
94
await conn.sendCommand('Overlay.enable');
95
await conn.sendCommand('CSS.enable');
96
97
if (this._store.isDisposed) {
98
conn.dispose();
99
throw new Error('Inspector disposed before connection was ready');
100
}
101
this._register(conn);
102
return conn;
103
} catch (error) {
104
conn.dispose();
105
throw error;
106
}
107
}
108
);
109
}
110
111
/**
112
* Start element inspection mode on the browser view. Sets up an
113
* overlay that highlights elements on hover. When the user clicks, the
114
* element data is returned and the overlay is removed.
115
*
116
* @param token Cancellation token to abort the inspection.
117
*/
118
async getElementData(token: CancellationToken): Promise<IElementData | undefined> {
119
const connection = await this._connectionPromise;
120
const store = new DisposableStore();
121
const result = new Promise<IElementData | undefined>((resolve, reject) => {
122
store.add(token.onCancellationRequested(() => {
123
resolve(undefined);
124
}));
125
126
store.add(connection.onEvent(async (event) => {
127
if (event.method !== 'Overlay.inspectNodeRequested') {
128
return;
129
}
130
131
const params = event.params as { backendNodeId: number };
132
if (!params?.backendNodeId) {
133
reject(new Error('Missing backendNodeId in inspectNodeRequested event'));
134
return;
135
}
136
137
try {
138
const nodeData = await extractNodeData(connection, { backendNodeId: params.backendNodeId });
139
resolve({
140
...nodeData,
141
url: this.browser.getURL()
142
});
143
} catch (err) {
144
reject(err);
145
}
146
}));
147
});
148
149
try {
150
await connection.sendCommand('Overlay.setInspectMode', {
151
mode: 'searchForNode',
152
highlightConfig: inspectHighlightConfig,
153
});
154
return await result;
155
} finally {
156
try {
157
await connection.sendCommand('Overlay.setInspectMode', {
158
mode: 'none',
159
highlightConfig: { showInfo: false, showStyles: false }
160
});
161
await connection.sendCommand('Overlay.hideHighlight');
162
} catch {
163
// Best effort cleanup
164
}
165
store.dispose();
166
}
167
}
168
169
/**
170
* Get element data for the currently focused element.
171
*/
172
async getFocusedElementData(): Promise<IElementData | undefined> {
173
const connection = await this._connectionPromise;
174
175
await connection.sendCommand('Runtime.enable');
176
const { result } = await connection.sendCommand('Runtime.evaluate', {
177
expression: 'document.activeElement',
178
returnByValue: false,
179
}) as { result: { objectId?: string } };
180
181
if (!result?.objectId) {
182
return undefined;
183
}
184
185
const nodeData = await extractNodeData(connection, { objectId: result.objectId });
186
return {
187
...nodeData,
188
url: this.browser.getURL()
189
};
190
}
191
192
async getVisualViewportScale(): Promise<number> {
193
try {
194
const connection = await this._connectionPromise;
195
const result = await connection.sendCommand('Page.getLayoutMetrics') as ILayoutMetricsResult;
196
if (typeof result.cssVisualViewport?.scale === 'number') {
197
const scale = Number(result.cssVisualViewport.scale);
198
if (Number.isFinite(scale) && scale > 0) {
199
return scale;
200
}
201
}
202
} catch {
203
// Ignore execution errors while loading and use defaults.
204
}
205
206
return 1;
207
}
208
}
209
210
async function extractNodeData(connection: ICDPConnection, id: { backendNodeId?: number; objectId?: string }): Promise<IElementData> {
211
using store = useScopedDisposal();
212
213
const discoveredNodesByNodeId: Record<number, INode> = {};
214
store.add(connection.onEvent(event => {
215
if (event.method === 'DOM.setChildNodes') {
216
const { nodes } = event.params as { nodes: INode[] };
217
for (const node of nodes) {
218
discoveredNodesByNodeId[node.nodeId] = node;
219
if (node.children) {
220
for (const child of node.children) {
221
discoveredNodesByNodeId[child.nodeId] = {
222
...child,
223
parentId: node.nodeId
224
};
225
}
226
}
227
if (node.pseudoElements) {
228
for (const pseudo of node.pseudoElements) {
229
discoveredNodesByNodeId[pseudo.nodeId] = {
230
...pseudo,
231
parentId: node.nodeId
232
};
233
}
234
}
235
}
236
}
237
}));
238
239
await connection.sendCommand('DOM.getDocument');
240
241
const { node } = await connection.sendCommand('DOM.describeNode', id) as { node: INode };
242
if (!node) {
243
throw new Error('Failed to describe node.');
244
}
245
let nodeId = node.nodeId;
246
if (!nodeId) {
247
const { nodeIds } = await connection.sendCommand('DOM.pushNodesByBackendIdsToFrontend', { backendNodeIds: [node.backendNodeId] }) as { nodeIds: number[] };
248
if (!nodeIds?.length) {
249
throw new Error('Failed to get node ID.');
250
}
251
nodeId = nodeIds[0];
252
}
253
254
const { model } = await connection.sendCommand('DOM.getBoxModel', { nodeId }) as { model: IBoxModel };
255
if (!model) {
256
throw new Error('Failed to get box model.');
257
}
258
259
const content = model.content;
260
const margin = model.margin;
261
const x = Math.min(margin[0], content[0]);
262
const y = Math.min(margin[1], content[1]);
263
const width = Math.max(margin[2] - margin[0], content[2] - content[0]);
264
const height = Math.max(margin[5] - margin[1], content[5] - content[1]);
265
266
const matched = await connection.sendCommand('CSS.getMatchedStylesForNode', { nodeId });
267
if (!matched) {
268
throw new Error('Failed to get matched css.');
269
}
270
271
const computedStyle = formatMatchedStyles(matched as IMatchedStyles);
272
const { outerHTML } = await connection.sendCommand('DOM.getOuterHTML', { nodeId }) as { outerHTML: string };
273
if (!outerHTML) {
274
throw new Error('Failed to get outerHTML.');
275
}
276
277
const attributes = attributeArrayToRecord(node.attributes);
278
279
const ancestors: IElementAncestor[] = [];
280
let currentNode: INode | undefined = discoveredNodesByNodeId[nodeId] ?? node;
281
while (currentNode) {
282
const attributes = attributeArrayToRecord(currentNode.attributes);
283
ancestors.unshift({
284
tagName: currentNode.localName,
285
id: attributes.id,
286
classNames: attributes.class?.trim().split(/\s+/).filter(Boolean)
287
});
288
currentNode = currentNode.parentId ? discoveredNodesByNodeId[currentNode.parentId] : undefined;
289
}
290
291
let computedStyles: Record<string, string> | undefined;
292
try {
293
const { computedStyle: computedStyleArray } = await connection.sendCommand('CSS.getComputedStyleForNode', { nodeId }) as { computedStyle?: Array<{ name: string; value: string }> };
294
if (computedStyleArray) {
295
computedStyles = {};
296
for (const prop of computedStyleArray) {
297
if (prop.name && typeof prop.value === 'string') {
298
computedStyles[prop.name] = prop.value;
299
}
300
}
301
}
302
} catch { }
303
304
return {
305
outerHTML,
306
computedStyle,
307
bounds: { x, y, width, height },
308
ancestors,
309
attributes,
310
computedStyles,
311
dimensions: { top: y, left: x, width, height }
312
};
313
}
314
315
function formatMatchedStyles(matched: IMatchedStyles): string {
316
const lines: string[] = [];
317
318
if (matched.inlineStyle?.cssProperties?.length) {
319
lines.push('/* Inline style */');
320
lines.push('element {');
321
for (const prop of matched.inlineStyle.cssProperties) {
322
if (prop.name && prop.value) {
323
lines.push(` ${prop.name}: ${prop.value};`);
324
}
325
}
326
lines.push('}\n');
327
}
328
329
if (matched.matchedCSSRules?.length) {
330
for (const ruleEntry of matched.matchedCSSRules) {
331
const rule = ruleEntry.rule;
332
const selectors = rule.selectorList.selectors.map((s: { text: string }) => s.text).join(', ');
333
lines.push(`/* Matched Rule from ${rule.origin} */`);
334
lines.push(`${selectors} {`);
335
for (const prop of rule.style.cssProperties) {
336
if (prop.name && prop.value) {
337
lines.push(` ${prop.name}: ${prop.value};`);
338
}
339
}
340
lines.push('}\n');
341
}
342
}
343
344
if (matched.inherited?.length) {
345
let level = 1;
346
for (const inherited of matched.inherited) {
347
if (inherited.inlineStyle) {
348
lines.push(`/* Inherited from ancestor level ${level} (inline) */`);
349
lines.push('element {');
350
lines.push(inherited.inlineStyle.cssText || '');
351
lines.push('}\n');
352
}
353
354
const rules = inherited.matchedCSSRules || [];
355
for (const ruleEntry of rules) {
356
const rule = ruleEntry.rule;
357
const selectors = rule.selectorList.selectors.map((s: { text: string }) => s.text).join(', ');
358
lines.push(`/* Inherited from ancestor level ${level} (${rule.origin}) */`);
359
lines.push(`${selectors} {`);
360
for (const prop of rule.style.cssProperties) {
361
if (prop.name && prop.value) {
362
lines.push(` ${prop.name}: ${prop.value};`);
363
}
364
}
365
lines.push('}\n');
366
}
367
level++;
368
}
369
}
370
371
return '\n' + lines.join('\n');
372
}
373
374
function attributeArrayToRecord(attributes: string[]): Record<string, string> {
375
const record: Record<string, string> = {};
376
for (let i = 0; i < attributes.length; i += 2) {
377
const name = attributes[i];
378
const value = attributes[i + 1];
379
record[name] = value;
380
}
381
return record;
382
}
383
384
/** Slightly customised CDP debugger inspect highlight colours. */
385
const inspectHighlightConfig = {
386
showInfo: true,
387
showRulers: false,
388
showStyles: true,
389
showAccessibilityInfo: true,
390
showExtensionLines: false,
391
contrastAlgorithm: 'aa',
392
contentColor: { r: 173, g: 216, b: 255, a: 0.8 },
393
paddingColor: { r: 150, g: 200, b: 255, a: 0.5 },
394
borderColor: { r: 120, g: 180, b: 255, a: 0.7 },
395
marginColor: { r: 200, g: 220, b: 255, a: 0.4 },
396
eventTargetColor: { r: 130, g: 160, b: 255, a: 0.8 },
397
shapeColor: { r: 130, g: 160, b: 255, a: 0.8 },
398
shapeMarginColor: { r: 130, g: 160, b: 255, a: 0.5 },
399
gridHighlightConfig: {
400
rowGapColor: { r: 140, g: 190, b: 255, a: 0.3 },
401
rowHatchColor: { r: 140, g: 190, b: 255, a: 0.7 },
402
columnGapColor: { r: 140, g: 190, b: 255, a: 0.3 },
403
columnHatchColor: { r: 140, g: 190, b: 255, a: 0.7 },
404
rowLineColor: { r: 120, g: 180, b: 255 },
405
columnLineColor: { r: 120, g: 180, b: 255 },
406
rowLineDash: true,
407
columnLineDash: true
408
},
409
flexContainerHighlightConfig: {
410
containerBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' },
411
itemSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' },
412
lineSeparator: { color: { r: 140, g: 190, b: 255 }, pattern: 'solid' },
413
mainDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } },
414
crossDistributedSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } },
415
rowGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } },
416
columnGapSpace: { hatchColor: { r: 140, g: 190, b: 255, a: 0.7 }, fillColor: { r: 140, g: 190, b: 255, a: 0.4 } },
417
},
418
flexItemHighlightConfig: {
419
baseSizeBox: { hatchColor: { r: 130, g: 170, b: 255, a: 0.6 } },
420
baseSizeBorder: { color: { r: 120, g: 180, b: 255 }, pattern: 'solid' },
421
flexibilityArrow: { color: { r: 130, g: 190, b: 255 } }
422
},
423
};
424
425