blob: 70cf81405ac7003be673148c4d2f9cf39673fe58 [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.
import * as Common from '../../core/common/common.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as ColorPicker from '../../ui/legacy/components/color_picker/color_picker.js';
import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js';
import * as Protocol from '../../generated/protocol.js';
import type {ContrastIssue} from './CSSOverviewCompletedView.js';
import type {UnusedDeclaration} from './CSSOverviewUnusedDeclarations.js';
import {CSSOverviewUnusedDeclarations} from './CSSOverviewUnusedDeclarations.js';
interface NodeStyleStats {
elementCount: number;
backgroundColors: Map<string, Set<Protocol.DOM.BackendNodeId>>;
textColors: Map<string, Set<Protocol.DOM.BackendNodeId>>;
textColorContrastIssues: Map<string, ContrastIssue[]>;
fillColors: Map<string, Set<Protocol.DOM.BackendNodeId>>;
borderColors: Map<string, Set<Protocol.DOM.BackendNodeId>>;
fontInfo: Map<string, Map<string, Map<string, Protocol.DOM.BackendNodeId[]>>>;
unusedDeclarations: Map<string, UnusedDeclaration[]>;
}
export interface GlobalStyleStats {
styleRules: number;
inlineStyles: number;
externalSheets: number;
stats: {
// Simple.
type: number,
class: number,
id: number,
universal: number,
attribute: number,
// Non-simple.
nonSimple: number,
};
}
export class CSSOverviewModel extends SDK.SDKModel.SDKModel<void> {
readonly #runtimeAgent: ProtocolProxyApi.RuntimeApi;
readonly #cssAgent: ProtocolProxyApi.CSSApi;
readonly #domAgent: ProtocolProxyApi.DOMApi;
readonly #domSnapshotAgent: ProtocolProxyApi.DOMSnapshotApi;
readonly #overlayAgent: ProtocolProxyApi.OverlayApi;
constructor(target: SDK.Target.Target) {
super(target);
this.#runtimeAgent = target.runtimeAgent();
this.#cssAgent = target.cssAgent();
this.#domAgent = target.domAgent();
this.#domSnapshotAgent = target.domsnapshotAgent();
this.#overlayAgent = target.overlayAgent();
}
highlightNode(node: Protocol.DOM.BackendNodeId): void {
const highlightConfig = {
contentColor: Common.Color.PageHighlight.Content.toProtocolRGBA(),
showInfo: true,
contrastAlgorithm: Root.Runtime.experiments.isEnabled('APCA') ? Protocol.Overlay.ContrastAlgorithm.Apca :
Protocol.Overlay.ContrastAlgorithm.Aa,
};
this.#overlayAgent.invoke_hideHighlight();
this.#overlayAgent.invoke_highlightNode({backendNodeId: node, highlightConfig});
}
async getNodeStyleStats(): Promise<NodeStyleStats> {
const backgroundColors = new Map();
const textColors = new Map();
const textColorContrastIssues = new Map();
const fillColors = new Map();
const borderColors = new Map();
const fontInfo = new Map();
const unusedDeclarations = new Map();
const snapshotConfig = {
computedStyles: [
'background-color',
'color',
'fill',
'border-top-width',
'border-top-color',
'border-bottom-width',
'border-bottom-color',
'border-left-width',
'border-left-color',
'border-right-width',
'border-right-color',
'font-family',
'font-size',
'font-weight',
'line-height',
'position',
'top',
'right',
'bottom',
'left',
'display',
'width',
'height',
'vertical-align',
],
includeTextColorOpacities: true,
includeBlendedBackgroundColors: true,
};
const formatColor = (color: Common.Color.Color): string|null => {
return color.hasAlpha() ? color.asString(Common.Color.Format.HEXA) : color.asString(Common.Color.Format.HEX);
};
const storeColor = (id: number, nodeId: number, target: Map<string, Set<number>>): Common.Color.Color|undefined => {
if (id === -1) {
return;
}
// Parse the color, discard transparent ones.
const colorText = strings[id];
if (!colorText) {
return;
}
const color = Common.Color.Color.parse(colorText);
if (!color || color.rgba()[3] === 0) {
return;
}
// Format the color and use as the key.
const colorFormatted = formatColor(color);
if (!colorFormatted) {
return;
}
// Get the existing set of nodes with the color, or create a new set.
const colorValues = target.get(colorFormatted) || new Set();
colorValues.add(nodeId);
// Store.
target.set(colorFormatted, colorValues);
return color;
};
const isSVGNode = (nodeName: string): boolean => {
const validNodes = new Set([
'altglyph',
'circle',
'ellipse',
'path',
'polygon',
'polyline',
'rect',
'svg',
'text',
'textpath',
'tref',
'tspan',
]);
return validNodes.has(nodeName.toLowerCase());
};
const isReplacedContent = (nodeName: string): boolean => {
const validNodes = new Set(['iframe', 'video', 'embed', 'img']);
return validNodes.has(nodeName.toLowerCase());
};
const isTableElementWithDefaultStyles = (nodeName: string, display: string): boolean => {
const validNodes = new Set(['tr', 'td', 'thead', 'tbody']);
return validNodes.has(nodeName.toLowerCase()) && display.startsWith('table');
};
let elementCount = 0;
const {documents, strings} = await this.#domSnapshotAgent.invoke_captureSnapshot(snapshotConfig);
for (const {nodes, layout} of documents) {
// Track the number of elements in the documents.
elementCount += layout.nodeIndex.length;
for (let idx = 0; idx < layout.styles.length; idx++) {
const styles = layout.styles[idx];
const nodeIdx = layout.nodeIndex[idx];
if (!nodes.backendNodeId || !nodes.nodeName) {
continue;
}
const nodeId = nodes.backendNodeId[nodeIdx];
const nodeName = nodes.nodeName[nodeIdx];
const [backgroundColorIdx, textColorIdx, fillIdx, borderTopWidthIdx, borderTopColorIdx, borderBottomWidthIdx, borderBottomColorIdx, borderLeftWidthIdx, borderLeftColorIdx, borderRightWidthIdx, borderRightColorIdx, fontFamilyIdx, fontSizeIdx, fontWeightIdx, lineHeightIdx, positionIdx, topIdx, rightIdx, bottomIdx, leftIdx, displayIdx, widthIdx, heightIdx, verticalAlignIdx] =
styles;
storeColor(backgroundColorIdx, nodeId, backgroundColors);
const textColor = storeColor(textColorIdx, nodeId, textColors);
if (isSVGNode(strings[nodeName])) {
storeColor(fillIdx, nodeId, fillColors);
}
if (strings[borderTopWidthIdx] !== '0px') {
storeColor(borderTopColorIdx, nodeId, borderColors);
}
if (strings[borderBottomWidthIdx] !== '0px') {
storeColor(borderBottomColorIdx, nodeId, borderColors);
}
if (strings[borderLeftWidthIdx] !== '0px') {
storeColor(borderLeftColorIdx, nodeId, borderColors);
}
if (strings[borderRightWidthIdx] !== '0px') {
storeColor(borderRightColorIdx, nodeId, borderColors);
}
/**
* Create a structure like this for font info:
*
* / size (Map) -- nodes (Array)
* /
* Font family (Map) ----- weight (Map) -- nodes (Array)
* \
* \ line-height (Map) -- nodes (Array)
*/
if (fontFamilyIdx && fontFamilyIdx !== -1) {
const fontFamily = strings[fontFamilyIdx];
const fontFamilyInfo = fontInfo.get(fontFamily) || new Map();
const sizeLabel = 'font-size';
const weightLabel = 'font-weight';
const lineHeightLabel = 'line-height';
const size = fontFamilyInfo.get(sizeLabel) || new Map();
const weight = fontFamilyInfo.get(weightLabel) || new Map();
const lineHeight = fontFamilyInfo.get(lineHeightLabel) || new Map();
if (fontSizeIdx !== -1) {
const fontSizeValue = strings[fontSizeIdx];
const nodes = size.get(fontSizeValue) || [];
nodes.push(nodeId);
size.set(fontSizeValue, nodes);
}
if (fontWeightIdx !== -1) {
const fontWeightValue = strings[fontWeightIdx];
const nodes = weight.get(fontWeightValue) || [];
nodes.push(nodeId);
weight.set(fontWeightValue, nodes);
}
if (lineHeightIdx !== -1) {
const lineHeightValue = strings[lineHeightIdx];
const nodes = lineHeight.get(lineHeightValue) || [];
nodes.push(nodeId);
lineHeight.set(lineHeightValue, nodes);
}
// Set the data back.
fontFamilyInfo.set(sizeLabel, size);
fontFamilyInfo.set(weightLabel, weight);
fontFamilyInfo.set(lineHeightLabel, lineHeight);
fontInfo.set(fontFamily, fontFamilyInfo);
}
const blendedBackgroundColor =
textColor && layout.blendedBackgroundColors && layout.blendedBackgroundColors[idx] !== -1 ?
Common.Color.Color.parse(strings[layout.blendedBackgroundColors[idx]]) :
null;
if (textColor && blendedBackgroundColor) {
const contrastInfo = new ColorPicker.ContrastInfo.ContrastInfo({
backgroundColors: [blendedBackgroundColor.asString(Common.Color.Format.HEXA) as string],
computedFontSize: fontSizeIdx !== -1 ? strings[fontSizeIdx] : '',
computedFontWeight: fontWeightIdx !== -1 ? strings[fontWeightIdx] : '',
});
const blendedTextColor =
textColor.blendWithAlpha(layout.textColorOpacities ? layout.textColorOpacities[idx] : 1);
contrastInfo.setColor(blendedTextColor);
const formattedTextColor = formatColor(blendedTextColor);
const formattedBackgroundColor = formatColor(blendedBackgroundColor);
const key = `${formattedTextColor}_${formattedBackgroundColor}`;
if (Root.Runtime.experiments.isEnabled('APCA')) {
const contrastRatio = contrastInfo.contrastRatioAPCA();
const threshold = contrastInfo.contrastRatioAPCAThreshold();
const passes = contrastRatio && threshold ? Math.abs(contrastRatio) >= threshold : false;
if (!passes) {
const issue = {
nodeId,
contrastRatio,
textColor: blendedTextColor,
backgroundColor: blendedBackgroundColor,
thresholdsViolated: {
aa: false,
aaa: false,
apca: true,
},
};
if (textColorContrastIssues.has(key)) {
textColorContrastIssues.get(key).push(issue);
} else {
textColorContrastIssues.set(key, [issue]);
}
}
} else {
const aaThreshold = contrastInfo.contrastRatioThreshold('aa') || 0;
const aaaThreshold = contrastInfo.contrastRatioThreshold('aaa') || 0;
const contrastRatio = contrastInfo.contrastRatio() || 0;
if (aaThreshold > contrastRatio || aaaThreshold > contrastRatio) {
const issue = {
nodeId,
contrastRatio,
textColor: blendedTextColor,
backgroundColor: blendedBackgroundColor,
thresholdsViolated: {
aa: aaThreshold > contrastRatio,
aaa: aaaThreshold > contrastRatio,
apca: false,
},
};
if (textColorContrastIssues.has(key)) {
textColorContrastIssues.get(key).push(issue);
} else {
textColorContrastIssues.set(key, [issue]);
}
}
}
}
CSSOverviewUnusedDeclarations.checkForUnusedPositionValues(
unusedDeclarations, nodeId, strings, positionIdx, topIdx, leftIdx, rightIdx, bottomIdx);
// Ignore SVG elements as, despite being inline by default, they can have width & height specified.
// Also ignore replaced content, for similar reasons.
if (!isSVGNode(strings[nodeName]) && !isReplacedContent(strings[nodeName])) {
CSSOverviewUnusedDeclarations.checkForUnusedWidthAndHeightValues(
unusedDeclarations, nodeId, strings, displayIdx, widthIdx, heightIdx);
}
if (verticalAlignIdx !== -1 && !isTableElementWithDefaultStyles(strings[nodeName], strings[displayIdx])) {
CSSOverviewUnusedDeclarations.checkForInvalidVerticalAlignment(
unusedDeclarations, nodeId, strings, displayIdx, verticalAlignIdx);
}
}
}
return {
backgroundColors,
textColors,
textColorContrastIssues,
fillColors,
borderColors,
fontInfo,
unusedDeclarations,
elementCount,
};
}
getComputedStyleForNode(nodeId: Protocol.DOM.NodeId): Promise<Protocol.CSS.GetComputedStyleForNodeResponse> {
return this.#cssAgent.invoke_getComputedStyleForNode({nodeId});
}
async getMediaQueries(): Promise<Map<string, Protocol.CSS.CSSMedia[]>> {
const queries = await this.#cssAgent.invoke_getMediaQueries();
const queryMap = new Map<string, Protocol.CSS.CSSMedia[]>();
if (!queries) {
return queryMap;
}
for (const query of queries.medias) {
// Ignore media queries applied to stylesheets; instead only use declared media rules.
if (query.source === 'linkedSheet') {
continue;
}
const entries = queryMap.get(query.text) || ([] as Protocol.CSS.CSSMedia[]);
entries.push(query);
queryMap.set(query.text, entries);
}
return queryMap;
}
async getGlobalStylesheetStats(): Promise<GlobalStyleStats|void> {
// There are no ways to pull CSSOM values directly today, due to its unserializable format,
// so instead we execute some JS within the page that extracts the relevant data and send that instead.
const expression = `(function() {
let styleRules = 0;
let inlineStyles = 0;
let externalSheets = 0;
const stats = {
// Simple.
type: new Set(),
class: new Set(),
id: new Set(),
universal: new Set(),
attribute: new Set(),
// Non-simple.
nonSimple: new Set()
};
for (const styleSheet of document.styleSheets) {
if (styleSheet.href) {
externalSheets++;
} else {
inlineStyles++;
}
// Attempting to grab rules can trigger a DOMException.
// Try it and if it fails skip to the next stylesheet.
let rules;
try {
rules = styleSheet.rules;
} catch (err) {
continue;
}
for (const rule of rules) {
if ('selectorText' in rule) {
styleRules++;
// Each group that was used.
for (const selectorGroup of rule.selectorText.split(',')) {
// Each selector in the group.
for (const selector of selectorGroup.split(\/[\\t\\n\\f\\r ]+\/g)) {
if (selector.startsWith('.')) {
// Class.
stats.class.add(selector);
} else if (selector.startsWith('#')) {
// Id.
stats.id.add(selector);
} else if (selector.startsWith('*')) {
// Universal.
stats.universal.add(selector);
} else if (selector.startsWith('[')) {
// Attribute.
stats.attribute.add(selector);
} else {
// Type or non-simple selector.
const specialChars = \/[#\.:\\[\\]|\\+>~]\/;
if (specialChars.test(selector)) {
stats.nonSimple.add(selector);
} else {
stats.type.add(selector);
}
}
}
}
}
}
}
return {
styleRules,
inlineStyles,
externalSheets,
stats: {
// Simple.
type: stats.type.size,
class: stats.class.size,
id: stats.id.size,
universal: stats.universal.size,
attribute: stats.attribute.size,
// Non-simple.
nonSimple: stats.nonSimple.size
}
}
})()`;
const {result} = await this.#runtimeAgent.invoke_evaluate({expression, returnByValue: true});
// TODO(paullewis): Handle errors properly.
if (result.type !== 'object') {
return;
}
return result.value;
}
}
SDK.SDKModel.SDKModel.register(CSSOverviewModel, {capabilities: SDK.Target.Capability.DOM, autostart: false});