// Copyright 2019 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import * as Common from '../common/common.js'; import * as Components from '../components/components.js'; import * as DataGrid from '../data_grid/data_grid.js'; import * as SDK from '../sdk/sdk.js'; import * as TextUtils from '../text_utils/text_utils.js'; import * as UI from '../ui/ui.js'; import {Events, OverviewController} from './CSSOverviewController.js'; // eslint-disable-line no-unused-vars import {CSSOverviewSidebarPanel, SidebarEvents} from './CSSOverviewSidebarPanel.js'; import {UnusedDeclaration} from './CSSOverviewUnusedDeclarations.js'; // eslint-disable-line no-unused-vars /** * @typedef {!Map>} */ // @ts-ignore typedef export let NodeStyleStats; /** * @typedef {{ * nodeId: number, * contrastRatio: number, * textColor: Common.Color.Color, * backgroundColor: Common.Color.Color, * thresholdsViolated: !{aa: boolean, aaa:boolean}, * }} */ // @ts-ignore typedef export let ContrastIssue; /** * @typedef {{ * backgroundColors: NodeStyleStats, * textColors: NodeStyleStats, * textColorContrastIssues: !Map>, * fillColors: NodeStyleStats, * borderColors: NodeStyleStats, * globalStyleStats: !{ * styleRules: number, * inlineStyles: number, * externalSheets: number, * stats: !{ * type: number, * class: number, * id: number, * universal: number, * attribute: number, * nonSimple: number * } * }, * fontInfo: FontInfo, * elementCount: number, * mediaQueries: !Map>, * unusedDeclarations: !Map>, * }} */ // @ts-ignore typedef export let OverviewData; /** * @typedef {!Map>>>} */ // @ts-ignore typedef export let FontInfo; /** * @param {!Common.Color.Color} color */ function getBorderString(color) { let [h, s, l] = color.hsla(); h = Math.round(h * 360); s = Math.round(s * 100); l = Math.round(l * 100); // Reduce the lightness of the border to make sure that there's always a visible outline. l = Math.max(0, l - 15); return `1px solid hsl(${h}deg ${s}% ${l}%)`; } /** * @unrestricted */ export class CSSOverviewCompletedView extends UI.Panel.PanelWithSidebar { /** * @param {!OverviewController} controller * @param {!SDK.SDKModel.Target} target */ constructor(controller, target) { super('css_overview_completed_view'); this.registerRequiredCSS('css_overview/cssOverviewCompletedView.css'); this._controller = controller; this._formatter = new Intl.NumberFormat('en-US'); this._mainContainer = new UI.SplitWidget.SplitWidget(true, true); this._resultsContainer = new UI.Widget.VBox(); this._elementContainer = new DetailsView(); // If closing the last tab, collapse the sidebar. this._elementContainer.addEventListener(UI.TabbedPane.Events.TabClosed, evt => { if (evt.data === 0) { this._mainContainer.setSidebarMinimized(true); } }); // Dupe the styles into the main container because of the shadow root will prevent outer styles. this._mainContainer.registerRequiredCSS('css_overview/cssOverviewCompletedView.css'); this._mainContainer.setMainWidget(this._resultsContainer); this._mainContainer.setSidebarWidget(this._elementContainer); this._mainContainer.setVertical(false); this._mainContainer.setSecondIsSidebar(true); this._mainContainer.setSidebarMinimized(true); this._sideBar = new CSSOverviewSidebarPanel(); this.splitWidget().setSidebarWidget(this._sideBar); this.splitWidget().setMainWidget(this._mainContainer); const cssModel = target.model(SDK.CSSModel.CSSModel); const domModel = target.model(SDK.DOMModel.DOMModel); if (!cssModel || !domModel) { throw new Error('Target must provide CSS and DOM models'); } this._cssModel = cssModel; this._domModel = domModel; this._domAgent = target.domAgent(); this._linkifier = new Components.Linkifier.Linkifier(/* maxLinkLength */ 20, /* useLinkDecorator */ true); this._viewMap = new Map(); this._sideBar.addItem(ls`Overview summary`, 'summary'); this._sideBar.addItem(ls`Colors`, 'colors'); this._sideBar.addItem(ls`Font info`, 'font-info'); this._sideBar.addItem(ls`Unused declarations`, 'unused-declarations'); this._sideBar.addItem(ls`Media queries`, 'media-queries'); this._sideBar.select('summary'); this._sideBar.addEventListener(SidebarEvents.ItemSelected, this._sideBarItemSelected, this); this._sideBar.addEventListener(SidebarEvents.Reset, this._sideBarReset, this); this._controller.addEventListener(Events.Reset, this._reset, this); this._controller.addEventListener(Events.PopulateNodes, this._createElementsView, this); this._resultsContainer.element.addEventListener('click', this._onClick.bind(this)); this._data = null; } /** * @override */ wasShown() { super.wasShown(); // TODO(paullewis): update the links in the panels in case source has been . } /** * @param {!Common.EventTarget.EventTargetEvent} event */ _sideBarItemSelected(event) { const data = /** @type {string} */ (event.data); const section = /** @type {!UI.Fragment.Fragment}*/ (this._fragment).$(data); if (!section) { return; } section.scrollIntoView(); } _sideBarReset() { this._controller.dispatchEventToListeners(Events.Reset); } _reset() { this._resultsContainer.element.removeChildren(); this._mainContainer.setSidebarMinimized(true); this._elementContainer.closeTabs(); this._viewMap = new Map(); } /** * @param {!Event} evt */ _onClick(evt) { if (!evt.target) { return; } const target = /** @type {!HTMLElement} */ (evt.target); const dataset = target.dataset; const type = dataset.type; if (!type || !this._data) { return; } let payload; switch (type) { case 'contrast': { const section = dataset.section; const key = dataset.key; if (!key) { return; } // Remap the Set to an object that is the same shape as the unused declarations. const nodes = this._data.textColorContrastIssues.get(key) || []; payload = {type, key, nodes, section}; break; } case 'color': { const color = dataset.color; const section = dataset.section; if (!color) { return; } let nodes; switch (section) { case 'text': nodes = this._data.textColors.get(color); break; case 'background': nodes = this._data.backgroundColors.get(color); break; case 'fill': nodes = this._data.fillColors.get(color); break; case 'border': nodes = this._data.borderColors.get(color); break; } if (!nodes) { return; } // Remap the Set to an object that is the same shape as the unused declarations. nodes = Array.from(nodes).map(nodeId => ({nodeId})); payload = {type, color, nodes, section}; break; } case 'unused-declarations': { const declaration = dataset.declaration; if (!declaration) { return; } const nodes = this._data.unusedDeclarations.get(declaration); if (!nodes) { return; } payload = {type, declaration, nodes}; break; } case 'media-queries': { const text = dataset.text; if (!text) { return; } const nodes = this._data.mediaQueries.get(text); if (!nodes) { return; } payload = {type, text, nodes}; break; } case 'font-info': { const value = dataset.value; if (!dataset.path) { return; } const [fontFamily, fontMetric] = dataset.path.split('/'); if (!value) { return; } const fontFamilyInfo = this._data.fontInfo.get(fontFamily); if (!fontFamilyInfo) { return; } const fontMetricInfo = fontFamilyInfo.get(fontMetric); if (!fontMetricInfo) { return; } const nodesIds = /** @type {!Array} */ (fontMetricInfo.get(value)); if (!nodesIds) { return; } const nodes = nodesIds.map(nodeId => ({nodeId})); const name = `${value} (${fontFamily}, ${fontMetric})`; payload = {type, name, nodes}; break; } default: return; } evt.consume(); this._controller.dispatchEventToListeners(Events.PopulateNodes, payload); this._mainContainer.setSidebarMinimized(false); } /** * @param {!Event} evt */ _onMouseOver(evt) { // Traverse the event path on the grid to find the nearest element with a backend node ID attached. Use // that for the highlighting. const node = (/** @type {!Array} */ (evt.composedPath())).find(el => el.dataset && el.dataset.backendNodeId); if (!node) { return; } const backendNodeId = Number(node.dataset.backendNodeId); this._controller.dispatchEventToListeners(Events.RequestNodeHighlight, backendNodeId); } /** * @param {!OverviewData} data */ async _render(data) { if (!data || !('backgroundColors' in data) || !('textColors' in data)) { return; } this._data = data; const { elementCount, backgroundColors, textColors, textColorContrastIssues, fillColors, borderColors, globalStyleStats, mediaQueries, unusedDeclarations, fontInfo } = this._data; // Convert rgb values from the computed styles to either undefined or HEX(A) strings. const sortedBackgroundColors = this._sortColorsByLuminance(backgroundColors); const sortedTextColors = this._sortColorsByLuminance(textColors); const sortedFillColors = this._sortColorsByLuminance(fillColors); const sortedBorderColors = this._sortColorsByLuminance(borderColors); this._fragment = UI.Fragment.Fragment.build`

${ls`Overview summary`}

  • ${ls`Elements`}
    ${this._formatter.format(elementCount)}
  • ${ls`External stylesheets`}
    ${this._formatter.format(globalStyleStats.externalSheets)}
  • ${ls`Inline style elements`}
    ${this._formatter.format(globalStyleStats.inlineStyles)}
  • ${ls`Style rules`}
    ${this._formatter.format(globalStyleStats.styleRules)}
  • ${ls`Media queries`}
    ${this._formatter.format(mediaQueries.size)}
  • ${ls`Type selectors`}
    ${this._formatter.format(globalStyleStats.stats.type)}
  • ${ls`ID selectors`}
    ${this._formatter.format(globalStyleStats.stats.id)}
  • ${ls`Class selectors`}
    ${this._formatter.format(globalStyleStats.stats.class)}
  • ${ls`Universal selectors`}
    ${this._formatter.format(globalStyleStats.stats.universal)}
  • ${ls`Attribute selectors`}
    ${this._formatter.format(globalStyleStats.stats.attribute)}
  • ${ls`Non-simple selectors`}
    ${this._formatter.format(globalStyleStats.stats.nonSimple)}

${ls`Colors`}

${ls`Background colors: ${sortedBackgroundColors.length}`}

    ${sortedBackgroundColors.map(this._colorsToFragment.bind(this, 'background'))}

${ls`Text colors: ${sortedTextColors.length}`}

    ${sortedTextColors.map(this._colorsToFragment.bind(this, 'text'))}
${textColorContrastIssues.size > 0 ? this._contrastIssuesToFragment(textColorContrastIssues) : ''}

${ls`Fill colors: ${sortedFillColors.length}`}

    ${sortedFillColors.map(this._colorsToFragment.bind(this, 'fill'))}

${ls`Border colors: ${sortedBorderColors.length}`}

    ${sortedBorderColors.map(this._colorsToFragment.bind(this, 'border'))}

${ls`Font info`}

${ fontInfo.size > 0 ? this._fontInfoToFragment(fontInfo) : UI.Fragment.Fragment.build`
${ls`There are no fonts.`}
`}

${ls`Unused declarations`}

${ unusedDeclarations.size > 0 ? this._groupToFragment(unusedDeclarations, 'unused-declarations', 'declaration') : UI.Fragment.Fragment.build`
${ls`There are no unused declarations.`}
`}

${ls`Media queries`}

${ mediaQueries.size > 0 ? this._groupToFragment(mediaQueries, 'media-queries', 'text') : UI.Fragment.Fragment.build`
${ls`There are no media queries.`}
`}
`; this._resultsContainer.element.appendChild(this._fragment.element()); } /** * @param {!Common.EventTarget.EventTargetEvent} evt */ _createElementsView(evt) { const {type, nodes} = evt.data; let id = ''; let tabTitle = ''; switch (type) { case 'contrast': { const {section, key} = evt.data; id = `${section}-${key}`; tabTitle = ls`Contrast issues`; break; } case 'color': { const {section, color} = evt.data; id = `${section}-${color}`; tabTitle = `${color.toUpperCase()} (${section})`; break; } case 'unused-declarations': { const {declaration} = evt.data; id = `${declaration}`; tabTitle = `${declaration}`; break; } case 'media-queries': { const {text} = evt.data; id = `${text}`; tabTitle = `${text}`; break; } case 'font-info': { const {name} = evt.data; id = `${name}`; tabTitle = `${name}`; break; } } let view = this._viewMap.get(id); if (!view) { view = new ElementDetailsView(this._controller, this._domModel, this._cssModel, this._linkifier); view.populateNodes(nodes); this._viewMap.set(id, view); } this._elementContainer.appendTab(id, tabTitle, view, true); } /** * @param {!FontInfo} fontInfo */ _fontInfoToFragment(fontInfo) { const fonts = Array.from(fontInfo.entries()); return UI.Fragment.Fragment.build` ${fonts.map(([font, fontMetrics]) => { return UI.Fragment.Fragment.build `

${font}

${this._fontMetricsToFragment(font, fontMetrics)}
`; })} `; } /** * @param {string} font * @param {!Map>>} fontMetrics */ _fontMetricsToFragment(font, fontMetrics) { const fontMetricInfo = Array.from(fontMetrics.entries()); return UI.Fragment.Fragment.build`
${fontMetricInfo.map(([label, values]) => { const sanitizedPath = `${font}/${label}`; return UI.Fragment.Fragment.build`

${label}

${this._groupToFragment(values, 'font-info', 'value', sanitizedPath)}
`; })}
`; } /** * @param {!Map>} items * @param {string} type * @param {string} dataLabel * @param {string} path */ _groupToFragment(items, type, dataLabel, path = '') { // Sort by number of items descending. const values = Array.from(items.entries()).sort((d1, d2) => { const v1Nodes = d1[1]; const v2Nodes = d2[1]; return v2Nodes.length - v1Nodes.length; }); const total = values.reduce((prev, curr) => prev + curr[1].length, 0); return UI.Fragment.Fragment.build`
    ${values.map(([title, nodes]) => { const width = 100 * nodes.length / total; const itemLabel = nodes.length === 1 ? ls`occurrence` : ls`occurrences`; return UI.Fragment.Fragment.build`
  • ${title}
  • `; })}
`; } /** * @param {!Map>} issues */ _contrastIssuesToFragment(issues) { return UI.Fragment.Fragment.build`

${ls`Contrast issues: ${issues.size}`}

    ${[...issues.entries()].map(([key, value]) => this._contrastIssueToFragment(key, value))}
`; } /** * @param {string} key * @param {!Array} issues */ _contrastIssueToFragment(key, issues) { console.assert(issues.length > 0); let minContrastIssue = issues[0]; for (const issue of issues) { if (issue.contrastRatio < minContrastIssue.contrastRatio) { minContrastIssue = issue; } } const color = /** @type { string }*/ (minContrastIssue.textColor.asString(Common.Color.Format.HEXA)); const backgroundColor = /** @type { string }*/ (minContrastIssue.backgroundColor.asString(Common.Color.Format.HEXA)); const blockFragment = UI.Fragment.Fragment.build`
  • ${ls`AA`}
    ${ls`AAA`}
  • `; const aa = /** @type {!HTMLElement} */ (blockFragment.$('aa')); if (minContrastIssue.thresholdsViolated.aa) { aa.appendChild(UI.Icon.Icon.create('smallicon-no')); } else { aa.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square')); } const aaa = /** @type {!HTMLElement} */ (blockFragment.$('aaa')); if (minContrastIssue.thresholdsViolated.aaa) { aaa.appendChild(UI.Icon.Icon.create('smallicon-no')); } else { aaa.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square')); } const block = /** @type {!HTMLElement} */ (blockFragment.$('color')); block.style.backgroundColor = backgroundColor; block.style.color = color; block.style.border = getBorderString(minContrastIssue.backgroundColor); return blockFragment; } /** * @param {string} section * @param {string} color */ _colorsToFragment(section, color) { const blockFragment = UI.Fragment.Fragment.build`
  • ${color}
  • `; const block = /** @type {!HTMLElement} */ (blockFragment.$('color')); block.style.backgroundColor = color; const borderColor = Common.Color.Color.parse(color); if (!borderColor) { return; } block.style.border = getBorderString(borderColor); return blockFragment; } /** * @param {!NodeStyleStats} srcColors */ _sortColorsByLuminance(srcColors) { return Array.from(srcColors.keys()).sort((colA, colB) => { const colorA = Common.Color.Color.parse(colA); const colorB = Common.Color.Color.parse(colB); if (!colorA || !colorB) { return 0; } return Common.ColorUtils.luminance(colorB.rgba()) - Common.ColorUtils.luminance(colorA.rgba()); }); } /** * @param {!OverviewData} data */ setOverviewData(data) { this._render(data); } } CSSOverviewCompletedView.pushedNodes = new Set(); export class DetailsView extends UI.Widget.VBox { constructor() { super(); this._tabbedPane = new UI.TabbedPane.TabbedPane(); this._tabbedPane.show(this.element); this._tabbedPane.addEventListener(UI.TabbedPane.Events.TabClosed, () => { this.dispatchEventToListeners(UI.TabbedPane.Events.TabClosed, this._tabbedPane.tabIds().length); }); } /** * @param {string} id * @param {string} tabTitle * @param {!UI.Widget.Widget} view * @param {boolean=} isCloseable */ appendTab(id, tabTitle, view, isCloseable) { if (!this._tabbedPane.hasTab(id)) { this._tabbedPane.appendTab(id, tabTitle, view, undefined, undefined, isCloseable); } this._tabbedPane.selectTab(id); } closeTabs() { this._tabbedPane.closeTabs(this._tabbedPane.tabIds()); } } export class ElementDetailsView extends UI.Widget.Widget { /** * @param {!OverviewController} controller * @param {!SDK.DOMModel.DOMModel} domModel * @param {!SDK.CSSModel.CSSModel} cssModel * @param {!Components.Linkifier.Linkifier} linkifier */ constructor(controller, domModel, cssModel, linkifier) { super(); this._controller = controller; this._domModel = domModel; this._cssModel = cssModel; this._linkifier = linkifier; this._elementGridColumns = [ { id: 'nodeId', title: ls`Element`, sortable: true, weight: 50, titleDOMFragment: undefined, sort: undefined, align: undefined, width: undefined, fixedWidth: undefined, editable: undefined, nonSelectable: undefined, longText: undefined, disclosure: undefined, allowInSortByEvenWhenHidden: undefined, dataType: undefined, defaultWeight: undefined, }, { id: 'declaration', title: ls`Declaration`, sortable: true, weight: 50, titleDOMFragment: undefined, sort: undefined, align: undefined, width: undefined, fixedWidth: undefined, editable: undefined, nonSelectable: undefined, longText: undefined, disclosure: undefined, allowInSortByEvenWhenHidden: undefined, dataType: undefined, defaultWeight: undefined, }, { id: 'sourceURL', title: ls`Source`, sortable: false, weight: 100, titleDOMFragment: undefined, sort: undefined, align: undefined, width: undefined, fixedWidth: undefined, editable: undefined, nonSelectable: undefined, longText: undefined, disclosure: undefined, allowInSortByEvenWhenHidden: undefined, dataType: undefined, defaultWeight: undefined, }, { id: 'contrastRatio', title: ls`Contrast ratio`, sortable: true, weight: 25, titleDOMFragment: undefined, sort: undefined, align: undefined, width: undefined, fixedWidth: undefined, editable: undefined, nonSelectable: undefined, longText: undefined, disclosure: undefined, allowInSortByEvenWhenHidden: undefined, dataType: undefined, defaultWeight: undefined, }, ]; this._elementGrid = new DataGrid.SortableDataGrid.SortableDataGrid({ displayName: ls`CSS Overview Elements`, columns: this._elementGridColumns, editCallback: undefined, deleteCallback: undefined, refreshCallback: undefined }); this._elementGrid.element.classList.add('element-grid'); this._elementGrid.element.addEventListener('mouseover', this._onMouseOver.bind(this)); this._elementGrid.setStriped(true); this._elementGrid.addEventListener( DataGrid.DataGrid.Events.SortingChanged, this._sortMediaQueryDataGrid.bind(this)); this.element.appendChild(this._elementGrid.element); } _sortMediaQueryDataGrid() { const sortColumnId = this._elementGrid.sortColumnId(); if (!sortColumnId) { return; } const comparator = DataGrid.SortableDataGrid.SortableDataGrid.StringComparator.bind(null, sortColumnId); this._elementGrid.sortNodes(comparator, !this._elementGrid.isSortOrderAscending()); } /** * @param {!Event} evt */ _onMouseOver(evt) { // Traverse the event path on the grid to find the nearest element with a backend node ID attached. Use // that for the highlighting. const node = (/** @type {!Array} */ (evt.composedPath())).find(el => el.dataset && el.dataset.backendNodeId); if (!node) { return; } const backendNodeId = Number(node.dataset.backendNodeId); this._controller.dispatchEventToListeners(Events.RequestNodeHighlight, backendNodeId); } /** * @param {!Array>} data */ async populateNodes(data) { this._elementGrid.rootNode().removeChildren(); if (!data.length) { return; } const [firstItem] = data; const visibility = { 'nodeId': !!firstItem.nodeId, 'declaration': !!firstItem.declaration, 'sourceURL': !!firstItem.sourceURL, 'contrastRatio': !!firstItem.contrastRatio, }; let relatedNodesMap; if (visibility.nodeId) { // Grab the nodes from the frontend, but only those that have not been // retrieved already. const nodeIds = /** @type {!Set} */ (data.reduce((prev, curr) => { if (CSSOverviewCompletedView.pushedNodes.has(curr.nodeId)) { return prev; } CSSOverviewCompletedView.pushedNodes.add(curr.nodeId); return prev.add(curr.nodeId); }, new Set())); relatedNodesMap = await this._domModel.pushNodesByBackendIdsToFrontend(nodeIds); } for (const item of data) { if (visibility.nodeId) { if (!relatedNodesMap) { continue; } const frontendNode = relatedNodesMap.get(item.nodeId); if (!frontendNode) { continue; } item.node = frontendNode; } const node = new ElementNode(this._elementGrid, item, this._linkifier, this._cssModel); node.selectable = false; this._elementGrid.insertChild(node); } this._elementGrid.setColumnsVisiblity(visibility); this._elementGrid.renderInline(); this._elementGrid.wasShown(); } } /** * @extends {DataGrid.SortableDataGrid.SortableDataGridNode} */ export class ElementNode extends DataGrid.SortableDataGrid.SortableDataGridNode { /** * @param {!DataGrid.SortableDataGrid.SortableDataGrid} dataGrid * @param {!Object} data * @param {!Components.Linkifier.Linkifier} linkifier * @param {!SDK.CSSModel.CSSModel} cssModel */ constructor(dataGrid, data, linkifier, cssModel) { super(dataGrid, data.hasChildren); this.data = data; this._linkifier = linkifier; this._cssModel = cssModel; } /** * @override * @param {string} columnId * @return {!HTMLElement} */ createCell(columnId) { // Nodes. if (columnId === 'nodeId') { const cell = this.createTD(columnId); cell.textContent = '...'; Common.Linkifier.Linkifier.linkify(this.data.node).then(link => { cell.textContent = ''; /** @type {!HTMLElement} */ (link).dataset.backendNodeId = this.data.node.backendNodeId(); cell.appendChild(link); const button = document.createElement('button'); button.classList.add('show-element'); button.title = ls`Show element`; button.tabIndex = 0; button.onclick = () => this.data.node.scrollIntoView(); cell.appendChild(button); }); return cell; } // Links to CSS. if (columnId === 'sourceURL') { const cell = this.createTD(columnId); if (this.data.range) { const link = this._linkifyRuleLocation( this._cssModel, this._linkifier, this.data.styleSheetId, TextUtils.TextRange.TextRange.fromObject(this.data.range)); if (!link || link.textContent === '') { cell.textContent = '(unable to link)'; } else { cell.appendChild(link); } } else { cell.textContent = '(unable to link to inlined styles)'; } return cell; } if (columnId === 'contrastRatio') { const cell = this.createTD(columnId); const contrastFragment = UI.Fragment.Fragment.build`
    Aa ${this.data.contrastRatio.toFixed(2)}
    `; const container = contrastFragment.$('container'); container.append(UI.Fragment.Fragment.build`${ls`AA`}`.element()); if (this.data.thresholdsViolated.aa) { container.appendChild(UI.Icon.Icon.create('smallicon-no')); } else { container.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square')); } container.append(UI.Fragment.Fragment.build`${ls`AAA`}`.element()); if (this.data.thresholdsViolated.aaa) { container.appendChild(UI.Icon.Icon.create('smallicon-no')); } else { container.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square')); } cell.appendChild(contrastFragment.element()); return cell; } return super.createCell(columnId); } /** * @param {!SDK.CSSModel.CSSModel} cssModel * @param {!Components.Linkifier.Linkifier} linkifier * @param {!Protocol.CSS.StyleSheetId} styleSheetId * @param {!TextUtils.TextRange.TextRange} ruleLocation */ _linkifyRuleLocation(cssModel, linkifier, styleSheetId, ruleLocation) { const styleSheetHeader = cssModel.styleSheetHeaderForId(styleSheetId); if (!styleSheetHeader) { return; } const lineNumber = styleSheetHeader.lineNumberInSource(ruleLocation.startLine); const columnNumber = styleSheetHeader.columnNumberInSource(ruleLocation.startLine, ruleLocation.startColumn); const matchingSelectorLocation = new SDK.CSSModel.CSSLocation(styleSheetHeader, lineNumber, columnNumber); return linkifier.linkifyCSSLocation(matchingSelectorLocation); } }