blob: 519491595692696a4374c654ec3c1a49977b91d5 [file] [log] [blame]
// 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;