mirror of
https://github.com/react/react-native-devtools-frontend.git
synced 2026-10-10 13:44:39 +08:00
The LocalizedString type alias in UIString.js is a so-called "branded type". A branded type alias allows us to construct special types in type-casts and enforce that these specific alias are used. The following properties hold: 1. A string can be force-casted into a LocalizedString 2. A LocalizedString can be enforced as argument type 3. A LocalizedString can be downcasted/used as regular string 4. A LocalizedString can normally not be concatenated, unless force-casts are added (which is strongly discouraged, since localization can be structure-dependent) These properties are for example used to enforce that all titles of the ViewRegistration declarations are localized strings. I have manually verified that passing in a regular string will correctly cause TypeScript to error. The new i18n localization system uses the same force-cast to make sure that the new localized strings also adhere to the same structure. As a result of this change, I discovered several inconsistencies in how types were used. For example, strings were being passed in and assumed to be localized, but not enforced. Therefore, this CL contains several cleanups that require the LocalizedString itself and hence have to be bundled in this CL. R=petermarshall@chromium.org Change-Id: I2faffde47069c900e2e5e7c0ebb460d0792d2935 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/2612861 Reviewed-by: Peter Marshall <petermarshall@chromium.org> Commit-Queue: Tim van der Lippe <tvanderlippe@chromium.org>
1062 lines
33 KiB
JavaScript
1062 lines
33 KiB
JavaScript
// 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 {ls} from '../platform/platform.js';
|
|
import * as Root from '../root/root.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<string,!Set<number>>}
|
|
*/
|
|
// @ts-ignore typedef
|
|
export let NodeStyleStats;
|
|
|
|
/**
|
|
* @typedef {{
|
|
* nodeId: number,
|
|
* contrastRatio: number,
|
|
* textColor: Common.Color.Color,
|
|
* backgroundColor: Common.Color.Color,
|
|
* thresholdsViolated: !{aa: boolean, aaa:boolean, apca: boolean},
|
|
* }}
|
|
*/
|
|
// @ts-ignore typedef
|
|
export let ContrastIssue;
|
|
|
|
/**
|
|
* @typedef {{
|
|
* backgroundColors: NodeStyleStats,
|
|
* textColors: NodeStyleStats,
|
|
* textColorContrastIssues: !Map<string, !Array<!ContrastIssue>>,
|
|
* 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<string, !Array<!Protocol.CSS.CSSMedia>>,
|
|
* unusedDeclarations: !Map<string, !Array<!UnusedDeclaration>>,
|
|
* }}
|
|
*/
|
|
// @ts-ignore typedef
|
|
export let OverviewData;
|
|
|
|
/**
|
|
* @typedef {!Map<string, !Map<string, !Map<string, !Array<number>>>>}
|
|
*/
|
|
// @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}%)`;
|
|
}
|
|
|
|
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', {enableLegacyPatching: false});
|
|
|
|
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', {enableLegacyPatching: true});
|
|
|
|
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();
|
|
CSSOverviewCompletedView.pushedNodes.clear();
|
|
this._sideBar.select('summary');
|
|
}
|
|
|
|
/**
|
|
* @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<number>} */ (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<!HTMLElement>} */ (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`
|
|
<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>
|
|
|
|
${textColorContrastIssues.size > 0 ? this._contrastIssuesToFragment(textColorContrastIssues) : ''}
|
|
|
|
<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.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.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.Fragment.build`<div class="horizontally-padded">${ls`There are no media queries.`}</div>`}
|
|
</div>
|
|
</div>`;
|
|
|
|
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
|
|
`<section class="font-family"><h2>${font}</h2> ${this._fontMetricsToFragment(font, fontMetrics)}</section>`;
|
|
})}
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* @param {string} font
|
|
* @param {!Map<string, !Map<string, !Array<number>>>} fontMetrics
|
|
*/
|
|
_fontMetricsToFragment(font, fontMetrics) {
|
|
const fontMetricInfo = Array.from(fontMetrics.entries());
|
|
|
|
return UI.Fragment.Fragment.build`
|
|
<div class="font-metric">
|
|
${fontMetricInfo.map(([label, values]) => {
|
|
const sanitizedPath = `${font}/${label}`;
|
|
return UI.Fragment.Fragment.build`
|
|
<div>
|
|
<h3>${label}</h3>
|
|
${this._groupToFragment(values, 'font-info', 'value', sanitizedPath)}
|
|
</div>`;
|
|
})}
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* @param {!Map<string, !Array<number|!UnusedDeclaration|!Protocol.CSS.CSSMedia>>} 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`<ul>
|
|
${values.map(([title, nodes]) => {
|
|
const width = 100 * nodes.length / total;
|
|
const itemLabel = nodes.length === 1 ? ls`occurrence` : ls`occurrences`;
|
|
|
|
return UI.Fragment.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>`;
|
|
}
|
|
|
|
/**
|
|
* @param {!Map<string, !Array<!ContrastIssue>>} issues
|
|
*/
|
|
_contrastIssuesToFragment(issues) {
|
|
return UI.Fragment.Fragment.build`
|
|
<h2>${ls`Contrast issues: ${issues.size}`}</h2>
|
|
<ul>
|
|
${[...issues.entries()].map(([key, value]) => this._contrastIssueToFragment(key, value))}
|
|
</ul>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* @param {string} key
|
|
* @param {!Array<!ContrastIssue>} issues
|
|
*/
|
|
_contrastIssueToFragment(key, issues) {
|
|
console.assert(issues.length > 0);
|
|
|
|
let minContrastIssue = issues[0];
|
|
for (const issue of issues) {
|
|
// APCA contrast can be a negative value that is to be displayed. But the
|
|
// absolute value is used to compare against the threshold. Therefore, the min
|
|
// absolute value is the worst contrast.
|
|
if (Math.abs(issue.contrastRatio) < Math.abs(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 showAPCA = Root.Runtime.experiments.isEnabled('APCA');
|
|
|
|
const blockFragment = UI.Fragment.Fragment.build`<li>
|
|
<button
|
|
title="${
|
|
ls`Text color ${color} over ${backgroundColor} background results in low contrast for ${
|
|
issues.length} elements`}"
|
|
data-type="contrast" data-key="${key}" data-section="contrast" class="block" $="color">
|
|
Text
|
|
</button>
|
|
<div class="block-title">
|
|
<div class="contrast-warning hidden" $="aa"><span class="threshold-label">${ls`AA`}</span></div>
|
|
<div class="contrast-warning hidden" $="aaa"><span class="threshold-label">${ls`AAA`}</span></div>
|
|
<div class="contrast-warning hidden" $="apca"><span class="threshold-label">${ls`APCA`}</span></div>
|
|
</div>
|
|
</li>`;
|
|
|
|
if (showAPCA) {
|
|
const apca = /** @type {!HTMLElement} */ (blockFragment.$('apca'));
|
|
if (minContrastIssue.thresholdsViolated.apca) {
|
|
apca.appendChild(UI.Icon.Icon.create('smallicon-no'));
|
|
} else {
|
|
apca.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square'));
|
|
}
|
|
apca.classList.remove('hidden');
|
|
} else {
|
|
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'));
|
|
}
|
|
aa.classList.remove('hidden');
|
|
aaa.classList.remove('hidden');
|
|
}
|
|
|
|
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`<li>
|
|
<button data-type="color" data-color="${color}" data-section="${section}" class="block" $="color"></button>
|
|
<div class="block-title">${color}</div>
|
|
</li>`;
|
|
|
|
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: '150px',
|
|
fixedWidth: true,
|
|
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<!HTMLElement>} */ (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<!Object<string, *>>} data
|
|
*/
|
|
async populateNodes(data) {
|
|
this._elementGrid.rootNode().removeChildren();
|
|
|
|
if (!data.length) {
|
|
return;
|
|
}
|
|
|
|
const [firstItem] = data;
|
|
const visibility = new Set();
|
|
firstItem.nodeId && visibility.add('nodeId');
|
|
firstItem.declaration && visibility.add('declaration');
|
|
firstItem.sourceURL && visibility.add('sourceURL');
|
|
firstItem.contrastRatio && visibility.add('contrastRatio');
|
|
|
|
let relatedNodesMap;
|
|
if (visibility.has('nodeId')) {
|
|
// Grab the nodes from the frontend, but only those that have not been
|
|
// retrieved already.
|
|
const nodeIds = /** @type {!Set<number>} */ (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.has('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<!ElementNode>}
|
|
*/
|
|
export class ElementNode extends DataGrid.SortableDataGrid.SortableDataGridNode {
|
|
/**
|
|
* @param {!DataGrid.SortableDataGrid.SortableDataGrid<!ElementNode>} dataGrid
|
|
* @param {!Object<string,*>} 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');
|
|
UI.Tooltip.Tooltip.install(button, 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 showAPCA = Root.Runtime.experiments.isEnabled('APCA');
|
|
const contrastRatioString =
|
|
showAPCA ? this.data.contrastRatio.toFixed(2) + '%' : this.data.contrastRatio.toFixed(2);
|
|
const contrastFragment = UI.Fragment.Fragment.build`
|
|
<div class="contrast-container-in-grid" $="container">
|
|
<span class="contrast-preview" style="border: ${getBorderString(this.data.backgroundColor)}; color: ${
|
|
this.data.textColor.asString()}; background-color: ${this.data.backgroundColor.asString()};">Aa</span>
|
|
<span>${contrastRatioString}</span>
|
|
</div>
|
|
`;
|
|
const container = contrastFragment.$('container');
|
|
if (showAPCA) {
|
|
container.append(UI.Fragment.Fragment.build`<span>${ls`APCA`}</span>`.element());
|
|
if (this.data.thresholdsViolated.apca) {
|
|
container.appendChild(UI.Icon.Icon.create('smallicon-no'));
|
|
} else {
|
|
container.appendChild(UI.Icon.Icon.create('smallicon-checkmark-square'));
|
|
}
|
|
} else {
|
|
container.append(UI.Fragment.Fragment.build`<span>${ls`AA`}</span>`.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`<span>${ls`AAA`}</span>`.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);
|
|
}
|
|
}
|