| // 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. |
| |
| /** |
| * @unrestricted |
| */ |
| export default class CSSOverviewCompletedView extends UI.PanelWithSidebar { |
| 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(true, true); |
| this._resultsContainer = new UI.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 CssOverview.CSSOverviewSidebarPanel(); |
| this.splitWidget().setSidebarWidget(this._sideBar); |
| this.splitWidget().setMainWidget(this._mainContainer); |
| |
| this._cssModel = target.model(SDK.CSSModel); |
| this._domModel = target.model(SDK.DOMModel); |
| this._domAgent = target.domAgent(); |
| this._linkifier = new Components.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(CssOverview.SidebarEvents.ItemSelected, this._sideBarItemSelected, this); |
| this._sideBar.addEventListener(CssOverview.SidebarEvents.Reset, this._sideBarReset, this); |
| this._controller.addEventListener(CssOverview.Events.Reset, this._reset, this); |
| this._controller.addEventListener(CssOverview.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 . |
| } |
| |
| _sideBarItemSelected(event) { |
| const section = this._fragment.$(event.data); |
| if (!section) { |
| return; |
| } |
| |
| section.scrollIntoView(); |
| } |
| |
| _sideBarReset() { |
| this._controller.dispatchEventToListeners(CssOverview.Events.Reset); |
| } |
| |
| _reset() { |
| this._resultsContainer.element.removeChildren(); |
| this._mainContainer.setSidebarMinimized(true); |
| this._elementContainer.closeTabs(); |
| this._viewMap = new Map(); |
| } |
| |
| _onClick(evt) { |
| const type = evt.target.dataset.type; |
| if (!type) { |
| return; |
| } |
| |
| let payload; |
| switch (type) { |
| case 'color': { |
| const color = evt.target.dataset.color; |
| const section = evt.target.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 = evt.target.dataset.declaration; |
| const nodes = this._data.unusedDeclarations.get(declaration); |
| if (!nodes) { |
| return; |
| } |
| |
| payload = {type, declaration, nodes}; |
| break; |
| } |
| |
| case 'media-queries': { |
| const text = evt.target.dataset.text; |
| const nodes = this._data.mediaQueries.get(text); |
| if (!nodes) { |
| return; |
| } |
| |
| payload = {type, text, nodes}; |
| break; |
| } |
| |
| case 'font-info': { |
| const value = evt.target.dataset.value; |
| const [fontFamily, fontMetric] = evt.target.dataset.path.split('/'); |
| const nodesIds = this._data.fontInfo.get(fontFamily).get(fontMetric).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(CssOverview.Events.PopulateNodes, payload); |
| this._mainContainer.setSidebarMinimized(false); |
| } |
| |
| _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 = evt.path.find(el => el.dataset && el.dataset.backendNodeId); |
| if (!node) { |
| return; |
| } |
| |
| const backendNodeId = Number(node.dataset.backendNodeId); |
| this._controller.dispatchEventToListeners(CssOverview.Events.RequestNodeHighlight, backendNodeId); |
| } |
| |
| async _render(data) { |
| if (!data || !('backgroundColors' in data) || !('textColors' in data)) { |
| return; |
| } |
| |
| this._data = data; |
| const { |
| elementCount, |
| backgroundColors, |
| textColors, |
| 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.build` |
| <div class="vbox overview-completed-view"> |
| <div $="summary" class="results-section horizontally-padded summary"> |
| <h1>${ls`Overview summary`}</h1> |
| |
| <ul> |
| <li> |
| <div class="label">${ls`Elements`}</div> |
| <div class="value">${this._formatter.format(elementCount)}</div> |
| </li> |
| <li> |
| <div class="label">${ls`External stylesheets`}</div> |
| <div class="value">${this._formatter.format(globalStyleStats.externalSheets)}</div> |
| </li> |
| <li> |
| <div class="label">${ls`Inline style elements`}</div> |
| <div class="value">${this._formatter.format(globalStyleStats.inlineStyles)}</div> |
| </li> |
| <li> |
| <div class="label">${ls`Style rules`}</div> |
| <div class="value">${this._formatter.format(globalStyleStats.styleRules)}</div> |
| </li> |
| <li> |
| <div class="label">${ls`Media queries`}</div> |
| <div class="value">${this._formatter.format(mediaQueries.size)}</div> |
| </li> |
| <li> |
| <div class="label">${ls`Type selectors`}</div> |
| <div class="value">${this._formatter.format(globalStyleStats.stats.type)}</div> |
| </li> |
| <li> |
| <div class="label">${ls`ID selectors`}</div> |
| <div class="value">${this._formatter.format(globalStyleStats.stats.id)}</div> |
| </li> |
| <li> |
| <div class="label">${ls`Class selectors`}</div> |
| <div class="value">${this._formatter.format(globalStyleStats.stats.class)}</div> |
| </li> |
| <li> |
| <div class="label">${ls`Universal selectors`}</div> |
| <div class="value">${this._formatter.format(globalStyleStats.stats.universal)}</div> |
| </li> |
| <li> |
| <div class="label">${ls`Attribute selectors`}</div> |
| <div class="value">${this._formatter.format(globalStyleStats.stats.attribute)}</div> |
| </li> |
| <li> |
| <div class="label">${ls`Non-simple selectors`}</div> |
| <div class="value">${this._formatter.format(globalStyleStats.stats.nonSimple)}</div> |
| </li> |
| </ul> |
| </div> |
| |
| <div $="colors" class="results-section horizontally-padded colors"> |
| <h1>${ls`Colors`}</h1> |
| <h2>${ls`Background colors: ${sortedBackgroundColors.length}`}</h2> |
| <ul> |
| ${sortedBackgroundColors.map(this._colorsToFragment.bind(this, 'background'))} |
| </ul> |
| |
| <h2>${ls`Text colors: ${sortedTextColors.length}`}</h2> |
| <ul> |
| ${sortedTextColors.map(this._colorsToFragment.bind(this, 'text'))} |
| </ul> |
| |
| <h2>${ls`Fill colors: ${sortedFillColors.length}`}</h2> |
| <ul> |
| ${sortedFillColors.map(this._colorsToFragment.bind(this, 'fill'))} |
| </ul> |
| |
| <h2>${ls`Border colors: ${sortedBorderColors.length}`}</h2> |
| <ul> |
| ${sortedBorderColors.map(this._colorsToFragment.bind(this, 'border'))} |
| </ul> |
| </div> |
| |
| <div $="font-info" class="results-section font-info"> |
| <h1>${ls`Font info`}</h1> |
| ${ |
| fontInfo.size > 0 ? this._fontInfoToFragment(fontInfo) : |
| UI.Fragment.build`<div>${ls`There are no fonts.`}</div>`} |
| </div> |
| |
| <div $="unused-declarations" class="results-section unused-declarations"> |
| <h1>${ls`Unused declarations`}</h1> |
| ${ |
| unusedDeclarations.size > 0 ? |
| this._groupToFragment(unusedDeclarations, 'unused-declarations', 'declaration') : |
| UI.Fragment.build`<div class="horizontally-padded">${ls`There are no unused declarations.`}</div>`} |
| </div> |
| |
| <div $="media-queries" class="results-section media-queries"> |
| <h1>${ls`Media queries`}</h1> |
| ${ |
| mediaQueries.size > 0 ? |
| this._groupToFragment(mediaQueries, 'media-queries', 'text') : |
| UI.Fragment.build`<div class="horizontally-padded">${ls`There are no media queries.`}</div>`} |
| </div> |
| </div>`; |
| |
| this._resultsContainer.element.appendChild(this._fragment.element()); |
| } |
| |
| _createElementsView(evt) { |
| const {type, nodes} = evt.data; |
| |
| let id = ''; |
| let tabTitle = ''; |
| |
| switch (type) { |
| 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); |
| } |
| |
| _fontInfoToFragment(fontInfo) { |
| const fonts = Array.from(fontInfo.entries()); |
| return UI.Fragment.build` |
| ${fonts.map(([font, fontMetrics]) => { |
| return UI.Fragment.build |
| `<section class="font-family"><h2>${font}</h2> ${this._fontMetricsToFragment(font, fontMetrics)}</section>`; |
| })} |
| `; |
| } |
| |
| _fontMetricsToFragment(font, fontMetrics) { |
| const fontMetricInfo = Array.from(fontMetrics.entries()); |
| |
| return UI.Fragment.build` |
| <div class="font-metric"> |
| ${fontMetricInfo.map(([label, values]) => { |
| const sanitizedPath = `${font}/${label}`; |
| return UI.Fragment.build` |
| <div> |
| <h3>${label}</h3> |
| ${this._groupToFragment(values, 'font-info', 'value', sanitizedPath)} |
| </div>`; |
| })} |
| </div>`; |
| } |
| |
| _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.build`<ul> |
| ${values.map(([title, nodes]) => { |
| const width = 100 * nodes.length / total; |
| const itemLabel = nodes.length === 1 ? ls`occurrence` : ls`occurrences`; |
| |
| return UI.Fragment.build`<li> |
| <div class="title">${title}</div> |
| <button data-type="${type}" data-path="${path}" data-${dataLabel}="${title}"> |
| <div class="details">${ls`${nodes.length} ${itemLabel}`}</div> |
| <div class="bar-container"> |
| <div class="bar" style="width: ${width}%"></div> |
| </div> |
| </button> |
| </li>`; |
| })} |
| </ul>`; |
| } |
| |
| _colorsToFragment(section, color) { |
| const blockFragment = UI.Fragment.build`<li> |
| <button data-type="color" data-color="${color}" data-section="${section}" class="block" $="color"></button> |
| <div class="block-title">${color}</div> |
| </li>`; |
| |
| const block = blockFragment.$('color'); |
| block.style.backgroundColor = color; |
| |
| const borderColor = Common.Color.parse(color); |
| let [h, s, l] = borderColor.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); |
| |
| const borderString = `1px solid hsl(${h}, ${s}%, ${l}%)`; |
| block.style.border = borderString; |
| |
| return blockFragment; |
| } |
| |
| _sortColorsByLuminance(srcColors) { |
| return Array.from(srcColors.keys()).sort((colA, colB) => { |
| const colorA = Common.Color.parse(colA); |
| const colorB = Common.Color.parse(colB); |
| return Common.Color.luminance(colorB.rgba()) - Common.Color.luminance(colorA.rgba()); |
| }); |
| } |
| |
| setOverviewData(data) { |
| this._render(data); |
| } |
| } |
| |
| CSSOverviewCompletedView.pushedNodes = new Set(); |
| |
| export class DetailsView extends UI.VBox { |
| constructor() { |
| super(); |
| |
| this._tabbedPane = new UI.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} 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 { |
| 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`, visible: false, sortable: true, hideable: true, weight: 50}, |
| {id: 'declaration', title: ls`Declaration`, visible: false, sortable: true, hideable: true, weight: 50}, |
| {id: 'sourceURL', title: ls`Source`, visible: true, sortable: false, hideable: true, weight: 100} |
| ]; |
| |
| this._elementGrid = new DataGrid.SortableDataGrid(this._elementGridColumns); |
| 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.StringComparator.bind(null, sortColumnId); |
| this._elementGrid.sortNodes(comparator, !this._elementGrid.isSortOrderAscending()); |
| } |
| |
| _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 = evt.path.find(el => el.dataset && el.dataset.backendNodeId); |
| if (!node) { |
| return; |
| } |
| |
| const backendNodeId = Number(node.dataset.backendNodeId); |
| this._controller.dispatchEventToListeners(CssOverview.Events.RequestNodeHighlight, backendNodeId); |
| } |
| |
| 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 |
| }; |
| |
| let relatedNodesMap; |
| if (visibility.nodeId) { |
| // Grab the nodes from the frontend, but only those that have not been |
| // retrieved already. |
| const nodeIds = data.reduce((prev, curr) => { |
| if (CssOverview.CSSOverviewCompletedView.pushedNodes.has(curr.nodeId)) { |
| return prev; |
| } |
| |
| CssOverview.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) { |
| 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(); |
| } |
| } |
| |
| export class ElementNode extends DataGrid.SortableDataGridNode { |
| /** |
| * @param {!DataGrid.SortableDataGrid} dataGrid |
| * @param {!Object<string,*>} data |
| * @param {!Components.Linkifier} linkifier |
| * @param {!SDK.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 {!Element} |
| */ |
| createCell(columnId) { |
| // Nodes. |
| if (columnId === 'nodeId') { |
| const cell = this.createTD(columnId); |
| cell.textContent = '...'; |
| |
| Common.Linkifier.linkify(this.data.node).then(link => { |
| cell.textContent = ''; |
| link.dataset.backendNodeId = this.data.node.backendNodeId(); |
| cell.appendChild(link); |
| }); |
| 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.fromObject(this.data.range)); |
| |
| if (link.textContent !== '') { |
| cell.appendChild(link); |
| } else { |
| cell.textContent = `(unable to link)`; |
| } |
| } else { |
| cell.textContent = '(unable to link to inlined styles)'; |
| } |
| return cell; |
| } |
| |
| return super.createCell(columnId); |
| } |
| |
| _linkifyRuleLocation(cssModel, linkifier, styleSheetId, ruleLocation) { |
| const styleSheetHeader = cssModel.styleSheetHeaderForId(styleSheetId); |
| const lineNumber = styleSheetHeader.lineNumberInSource(ruleLocation.startLine); |
| const columnNumber = styleSheetHeader.columnNumberInSource(ruleLocation.startLine, ruleLocation.startColumn); |
| const matchingSelectorLocation = new SDK.CSSLocation(styleSheetHeader, lineNumber, columnNumber); |
| return linkifier.linkifyCSSLocation(matchingSelectorLocation); |
| } |
| } |
| |
| /* Legacy exported object */ |
| self.CssOverview = self.CssOverview || {}; |
| |
| /* Legacy exported object */ |
| CssOverview = CssOverview || {}; |
| |
| /** |
| * @constructor |
| */ |
| CssOverview.CSSOverviewCompletedView = CSSOverviewCompletedView; |
| |
| |
| /** |
| * @constructor |
| */ |
| CssOverview.CSSOverviewCompletedView.DetailsView = DetailsView; |
| |
| /** |
| * @constructor |
| */ |
| CssOverview.CSSOverviewCompletedView.ElementDetailsView = ElementDetailsView; |
| |
| CssOverview.CSSOverviewCompletedView.ElementNode = ElementNode; |