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