| // Copyright 2019 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import '../../ui/legacy/components/data_grid/data_grid.js'; |
| import '../../ui/kit/kit.js'; |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as i18n from '../../core/i18n/i18n.js'; |
| import * as Platform from '../../core/platform/platform.js'; |
| import * as Root from '../../core/root/root.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import type * as Protocol from '../../generated/protocol.js'; |
| import * as Geometry from '../../models/geometry/geometry.js'; |
| import * as TextUtils from '../../models/text_utils/text_utils.js'; |
| import * as Components from '../../ui/legacy/components/utils/utils.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| import {Directives, html, type LitTemplate, nothing, render, type TemplateResult} from '../../ui/lit/lit.js'; |
| import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; |
| import * as PanelsCommon from '../common/common.js'; |
| |
| import cssOverviewCompletedViewStyles from './cssOverviewCompletedView.css.js'; |
| import type {GlobalStyleStats} from './CSSOverviewModel.js'; |
| import {CSSOverviewSidebarPanel} from './CSSOverviewSidebarPanel.js'; |
| import type {UnusedDeclaration} from './CSSOverviewUnusedDeclarations.js'; |
| |
| const {styleMap, ref} = Directives; |
| const {widgetConfig} = UI.Widget; |
| |
| const UIStrings = { |
| /** |
| * @description Label for the summary in the CSS overview report |
| */ |
| overviewSummary: 'Overview summary', |
| /** |
| * @description Title of colors subsection in the CSS overview panel |
| */ |
| colors: 'Colors', |
| /** |
| * @description Title of font info subsection in the CSS overview panel |
| */ |
| fontInfo: 'Font info', |
| /** |
| * @description Label to denote unused declarations in the target page |
| */ |
| unusedDeclarations: 'Unused declarations', |
| /** |
| * @description Label for the number of media queries in the CSS overview report |
| */ |
| mediaQueries: 'Media queries', |
| /** |
| * @description Title of the Elements Panel |
| */ |
| elements: 'Elements', |
| /** |
| * @description Label for the number of External stylesheets in the CSS overview report |
| */ |
| externalStylesheets: 'External stylesheets', |
| /** |
| * @description Label for the number of inline style elements in the CSS overview report |
| */ |
| inlineStyleElements: 'Inline style elements', |
| /** |
| * @description Label for the number of style rules in CSS overview report |
| */ |
| styleRules: 'Style rules', |
| /** |
| * @description Label for the number of type selectors in the CSS overview report |
| */ |
| typeSelectors: 'Type selectors', |
| /** |
| * @description Label for the number of ID selectors in the CSS overview report |
| */ |
| idSelectors: 'ID selectors', |
| /** |
| * @description Label for the number of class selectors in the CSS overview report |
| */ |
| classSelectors: 'Class selectors', |
| /** |
| * @description Label for the number of universal selectors in the CSS overview report |
| */ |
| universalSelectors: 'Universal selectors', |
| /** |
| * @description Label for the number of Attribute selectors in the CSS overview report |
| */ |
| attributeSelectors: 'Attribute selectors', |
| /** |
| * @description Label for the number of non-simple selectors in the CSS overview report |
| */ |
| nonsimpleSelectors: 'Non-simple selectors', |
| /** |
| * @description Label for unique background colors in the CSS overview panel |
| * @example {32} PH1 |
| */ |
| backgroundColorsS: 'Background colors: {PH1}', |
| /** |
| * @description Label for unique text colors in the CSS overview panel |
| * @example {32} PH1 |
| */ |
| textColorsS: 'Text colors: {PH1}', |
| /** |
| * @description Label for unique fill colors in the CSS overview panel |
| * @example {32} PH1 |
| */ |
| fillColorsS: 'Fill colors: {PH1}', |
| /** |
| * @description Label for unique border colors in the CSS overview panel |
| * @example {32} PH1 |
| */ |
| borderColorsS: 'Border colors: {PH1}', |
| /** |
| * @description Label to indicate that there are no fonts in use |
| */ |
| thereAreNoFonts: 'There are no fonts.', |
| /** |
| * @description Message to show when no unused declarations in the target page |
| */ |
| thereAreNoUnusedDeclarations: 'There are no unused declarations.', |
| /** |
| * @description Message to show when no media queries are found in the target page |
| */ |
| thereAreNoMediaQueries: 'There are no media queries.', |
| /** |
| * @description Title of the Drawer for contrast issues in the CSS overview panel |
| */ |
| contrastIssues: 'Contrast issues', |
| /** |
| * @description Text to indicate how many times this CSS rule showed up. |
| */ |
| nOccurrences: '{n, plural, =1 {# occurrence} other {# occurrences}}', |
| /** |
| * @description Section header for contrast issues in the CSS overview panel |
| * @example {1} PH1 |
| */ |
| contrastIssuesS: 'Contrast issues: {PH1}', |
| /** |
| * @description Title of the button for a contrast issue in the CSS overview panel |
| * @example {#333333} PH1 |
| * @example {#333333} PH2 |
| * @example {2} PH3 |
| */ |
| textColorSOverSBackgroundResults: 'Text color {PH1} over {PH2} background results in low contrast for {PH3} elements', |
| /** |
| * @description Label aa text content in Contrast Details of the Color Picker |
| */ |
| aa: 'AA', |
| /** |
| * @description Label aaa text content in Contrast Details of the Color Picker |
| */ |
| aaa: 'AAA', |
| /** |
| * @description Label for the APCA contrast in Color Picker |
| */ |
| apca: 'APCA', |
| /** |
| * @description Label for the column in the element list in the CSS overview report |
| */ |
| element: 'Element', |
| /** |
| * @description Column header title denoting which declaration is unused |
| */ |
| declaration: 'Declaration', |
| /** |
| * @description Text for the source of something |
| */ |
| source: 'Source', |
| /** |
| * @description Text of a DOM element in Contrast Details of the Color Picker |
| */ |
| contrastRatio: 'Contrast ratio', |
| /** |
| * @description Accessible title of a table in the CSS overview elements. |
| */ |
| cssOverviewElements: 'CSS overview elements', |
| /** |
| * @description Title of the button to show the element in the CSS overview panel |
| */ |
| showElement: 'Show element', |
| /** |
| * @description Text to show in a table if the link to the style could not be created. |
| */ |
| unableToLink: '(unable to link)', |
| /** |
| * @description Text to show in a table if the link to the inline style could not be created. |
| */ |
| unableToLinkToInlineStyle: '(unable to link to inline style)', |
| } as const; |
| const str_ = i18n.i18n.registerUIStrings('panels/css_overview/CSSOverviewCompletedView.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| |
| export type NodeStyleStats = Map<string, Set<number>>; |
| |
| export interface ContrastIssue { |
| nodeId: Protocol.DOM.BackendNodeId; |
| contrastRatio: number; |
| textColor: Common.Color.Color; |
| backgroundColor: Common.Color.Color; |
| thresholdsViolated: { |
| aa: boolean, |
| aaa: boolean, |
| apca: boolean, |
| }; |
| } |
| export interface OverviewData { |
| 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>>; |
| globalStyleStats: { |
| styleRules: number, |
| inlineStyles: number, |
| externalSheets: number, |
| stats: {type: number, class: number, id: number, universal: number, attribute: number, nonSimple: number}, |
| }; |
| fontInfo: Map<string, Map<string, Map<string, Protocol.DOM.BackendNodeId[]>>>; |
| elementCount: number; |
| mediaQueries: Map<string, Protocol.CSS.CSSMedia[]>; |
| unusedDeclarations: Map<string, UnusedDeclaration[]>; |
| } |
| |
| export type FontInfo = Map<string, Map<string, Map<string, number[]>>>; |
| interface FontMetric { |
| label: string; |
| values: Array<{title: string, nodes: number[]}>; |
| } |
| |
| function getBorderString(color: Common.Color.Color): string { |
| let {h, s, l} = color.as(Common.Color.Format.HSL); |
| 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}%)`; |
| } |
| |
| interface ViewInput { |
| elementCount: number; |
| backgroundColors: string[]; |
| textColors: string[]; |
| textColorContrastIssues: Map<string, ContrastIssue[]>; |
| fillColors: string[]; |
| borderColors: string[]; |
| globalStyleStats: GlobalStyleStats; |
| mediaQueries: Array<{title: string, nodes: Protocol.CSS.CSSMedia[]}>; |
| unusedDeclarations: Array<{title: string, nodes: UnusedDeclaration[]}>; |
| fontInfo: Array<{font: string, fontMetrics: FontMetric[]}>; |
| selectedSection: string; |
| onClick: (evt: Event) => void; |
| onSectionSelected: (section: string, withKeyboard: boolean) => void; |
| onReset: () => void; |
| } |
| |
| interface ViewOutput { |
| revealSection: Map<string, (setFocus: boolean) => void>; |
| closeAllTabs: () => void; |
| addTab: (id: string, tabTitle: string, view: UI.Widget.Widget, jslogContext: string) => void; |
| } |
| |
| const formatter = new Intl.NumberFormat('en-US'); |
| |
| type View = (input: ViewInput, output: ViewOutput, target: HTMLElement) => void; |
| |
| export const DEFAULT_VIEW: View = (input, output, target) => { |
| function revealSection(section: Element|undefined, setFocus: boolean): void { |
| if (!section) { |
| return; |
| } |
| section.scrollIntoView(); |
| // Set focus for keyboard invoked event |
| if (setFocus) { |
| const focusableElement: HTMLElement|null = section.querySelector('button, [tabindex="0"]'); |
| focusableElement?.focus(); |
| } |
| } |
| |
| // clang-format off |
| render(html` |
| <style>${cssOverviewCompletedViewStyles}</style> |
| <devtools-split-view direction="column" sidebar-position="first" sidebar-initial-size="200"> |
| <devtools-widget slot="sidebar" .widgetConfig=${widgetConfig(CSSOverviewSidebarPanel, { |
| minimumSize: new Geometry.Size(100, 25), |
| items: [ |
| {name: i18nString(UIStrings.overviewSummary), id: 'summary'}, |
| {name: i18nString(UIStrings.colors), id: 'colors'}, |
| {name: i18nString(UIStrings.fontInfo), id: 'font-info'}, |
| {name: i18nString(UIStrings.unusedDeclarations), id: 'unused-declarations'}, |
| {name: i18nString(UIStrings.mediaQueries), id: 'media-queries'} |
| ], |
| selectedId: input.selectedSection, |
| onItemSelected: input.onSectionSelected, |
| onReset: input.onReset, |
| })}> |
| </devtools-widget> |
| <devtools-split-view sidebar-position="second" slot="main" direction="row" sidebar-initial-size="minimized"> |
| <div class="vbox overview-completed-view" slot="main" @click=${input.onClick}> |
| <!-- Dupe the styles into the main container because of the shadow root will prevent outer styles. --> |
| <style>${cssOverviewCompletedViewStyles}</style> |
| <div class="results-section horizontally-padded summary" |
| ${ref(e => { output.revealSection.set('summary', revealSection.bind(null, e));})}> |
| <h1>${i18nString(UIStrings.overviewSummary)}</h1> |
| ${renderSummary(input.elementCount, input.globalStyleStats, input.mediaQueries)} |
| </div> |
| <div class="results-section horizontally-padded colors" |
| ${ref(e => { output.revealSection.set('colors', revealSection.bind(null, e));})}> |
| <h1>${i18nString(UIStrings.colors)}</h1> |
| ${renderColors(input.backgroundColors, input.textColors, input.textColorContrastIssues, input.fillColors, input.borderColors)} |
| </div> |
| <div class="results-section font-info" |
| ${ref(e => { output.revealSection.set('font-info', revealSection.bind(null, e));})}> |
| <h1>${i18nString(UIStrings.fontInfo)}</h1> |
| ${renderFontInfo(input.fontInfo)} |
| </div> |
| <div class="results-section unused-declarations" |
| ${ref(e => { output.revealSection.set('unused-declarations', revealSection.bind(null, e));})}> |
| <h1>${i18nString(UIStrings.unusedDeclarations)}</h1> |
| ${renderUnusedDeclarations(input.unusedDeclarations)} |
| </div> |
| <div class="results-section media-queries" |
| ${ref(e => { output.revealSection.set('media-queries', revealSection.bind(null, e));})}> |
| <h1>${i18nString(UIStrings.mediaQueries)}</h1> |
| ${renderMediaQueries(input.mediaQueries)} |
| </div> |
| </div> |
| <devtools-widget slot="sidebar" .widgetConfig=${widgetConfig(e => { |
| const tabbedPane = new UI.TabbedPane.TabbedPane(e); |
| output.closeAllTabs = () => { tabbedPane.closeTabs(tabbedPane.tabIds()); }; |
| output.addTab = (id: string, tabTitle: string, view: UI.Widget.Widget, jslogContext: string) => { |
| if (!tabbedPane.hasTab(id)) { |
| tabbedPane.appendTab(id, tabTitle, view, undefined, undefined, |
| /* isCloseable */ true, undefined, undefined, jslogContext); |
| } |
| tabbedPane.selectTab(id); |
| const splitView = tabbedPane.parentWidget() as UI.SplitWidget.SplitWidget; |
| splitView.setSidebarMinimized(false); |
| }; |
| tabbedPane.addEventListener(UI.TabbedPane.Events.TabClosed, _ => { |
| if (tabbedPane.tabIds().length === 0) { |
| const splitView = tabbedPane.parentWidget() as UI.SplitWidget.SplitWidget; |
| splitView.setSidebarMinimized(true); |
| } |
| }); |
| return tabbedPane; |
| })}> |
| </devtools-widget> |
| </devtools-split-view> |
| </devtools-split-view>`, |
| target); |
| // clang-format on |
| }; |
| |
| function renderSummary( |
| elementCount: number, globalStyleStats: GlobalStyleStats, |
| mediaQueries: Array<{title: string, nodes: Protocol.CSS.CSSMedia[]}>): TemplateResult { |
| const renderSummaryItem = (label: string, value: number): TemplateResult => html` |
| <li> |
| <div class="label">${label}</div> |
| <div class="value">${formatter.format(value)}</div> |
| </li>`; |
| return html`<ul> |
| ${renderSummaryItem(i18nString(UIStrings.elements), elementCount)} |
| ${renderSummaryItem(i18nString(UIStrings.externalStylesheets), globalStyleStats.externalSheets)} |
| ${renderSummaryItem(i18nString(UIStrings.inlineStyleElements), globalStyleStats.inlineStyles)} |
| ${renderSummaryItem(i18nString(UIStrings.styleRules), globalStyleStats.styleRules)} |
| ${renderSummaryItem(i18nString(UIStrings.mediaQueries), mediaQueries.length)} |
| ${renderSummaryItem(i18nString(UIStrings.typeSelectors), globalStyleStats.stats.type)} |
| ${renderSummaryItem(i18nString(UIStrings.idSelectors), globalStyleStats.stats.id)} |
| ${renderSummaryItem(i18nString(UIStrings.classSelectors), globalStyleStats.stats.class)} |
| ${renderSummaryItem(i18nString(UIStrings.universalSelectors), globalStyleStats.stats.universal)} |
| ${renderSummaryItem(i18nString(UIStrings.attributeSelectors), globalStyleStats.stats.attribute)} |
| ${renderSummaryItem(i18nString(UIStrings.nonsimpleSelectors), globalStyleStats.stats.nonSimple)} |
| </ul>`; |
| } |
| |
| function renderColors( |
| backgroundColors: string[], textColors: string[], textColorContrastIssues: Map<string, ContrastIssue[]>, |
| fillColors: string[], borderColors: string[]): TemplateResult { |
| // clang-format off |
| return html` |
| <h2>${i18nString(UIStrings.backgroundColorsS, {PH1: backgroundColors.length})}</h2> |
| <ul>${backgroundColors.map(c => renderColor('background', c))}</ul> |
| |
| <h2>${i18nString(UIStrings.textColorsS, {PH1: textColors.length})}</h2> |
| <ul>${textColors.map(c => renderColor('text', c))}</ul> |
| |
| ${textColorContrastIssues.size > 0 ? renderContrastIssues(textColorContrastIssues) : ''} |
| |
| <h2>${i18nString(UIStrings.fillColorsS, {PH1: fillColors.length})}</h2> |
| <ul>${fillColors.map(c => renderColor('fill', c))}</ul> |
| |
| <h2>${i18nString(UIStrings.borderColorsS, {PH1: borderColors.length})}</h2> |
| <ul>${borderColors.map(c => renderColor('border', c))}</ul>`; |
| // clang-format on |
| } |
| |
| function renderUnusedDeclarations(unusedDeclarations: Array<{title: string, nodes: UnusedDeclaration[]}>): |
| TemplateResult { |
| return unusedDeclarations.length > 0 ? |
| renderGroup(unusedDeclarations, 'unused-declarations') : |
| html`<div class="horizontally-padded">${i18nString(UIStrings.thereAreNoUnusedDeclarations)}</div>`; |
| } |
| |
| function renderMediaQueries(mediaQueries: Array<{title: string, nodes: Protocol.CSS.CSSMedia[]}>): TemplateResult { |
| return mediaQueries.length > 0 ? |
| renderGroup(mediaQueries, 'media-queries') : |
| html`<div class="horizontally-padded">${i18nString(UIStrings.thereAreNoMediaQueries)}</div>`; |
| } |
| |
| function renderFontInfo(fonts: Array<{font: string, fontMetrics: FontMetric[]}>): TemplateResult { |
| return fonts.length > 0 ? html`${fonts.map(({font, fontMetrics}) => html` |
| <section class="font-family"> |
| <h2>${font}</h2> |
| ${renderFontMetrics(font, fontMetrics)} |
| </section>`)}` : |
| html`<div>${i18nString(UIStrings.thereAreNoFonts)}</div>`; |
| } |
| |
| function renderFontMetrics(font: string, fontMetricInfo: FontMetric[]): TemplateResult { |
| return html` |
| <div class="font-metric"> |
| ${fontMetricInfo.map(({label, values}) => html` |
| <div> |
| <h3>${label}</h3> |
| ${renderGroup(values, 'font-info', `${font}/${label}`)} |
| </div>`)} |
| </div>`; |
| } |
| |
| function renderGroup( |
| values: Array<{title: string, nodes: Array<number|UnusedDeclaration|Protocol.CSS.CSSMedia>}>, type: string, |
| path = ''): TemplateResult { |
| const total = values.reduce((prev, curr) => prev + curr.nodes.length, 0); |
| |
| // clang-format off |
| return html` |
| <ul aria-label=${type}> |
| ${values.map(({title, nodes}) => { |
| const width = 100 * nodes.length / total; |
| const itemLabel = i18nString(UIStrings.nOccurrences, {n: nodes.length}); |
| |
| return html`<li> |
| <div class="title">${title}</div> |
| <button data-type=${type} data-path=${path} data-label=${title} |
| jslog=${VisualLogging.action().track({click: true}).context(`css-overview.${type}`)} |
| aria-label=${`${title}: ${itemLabel}`}> |
| <div class="details">${itemLabel}</div> |
| <div class="bar-container"> |
| <div class="bar" style=${styleMap({width})}></div> |
| </div> |
| </button> |
| </li>`; |
| })} |
| </ul>`; |
| // clang-format on |
| } |
| |
| function renderContrastIssues(issues: Map<string, ContrastIssue[]>): TemplateResult { |
| // clang-format off |
| return html` |
| <h2>${i18nString(UIStrings.contrastIssuesS, {PH1: issues.size})}</h2> |
| <ul> |
| ${[...issues.entries()].map(([key, value]) => renderContrastIssue(key, value))} |
| </ul>`; |
| // clang-format on |
| } |
| |
| function renderContrastIssue(key: string, issues: ContrastIssue[]): TemplateResult { |
| console.assert(issues.length > 0); |
| |
| let minContrastIssue: ContrastIssue = 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 = (minContrastIssue.textColor.asString(Common.Color.Format.HEXA)); |
| const backgroundColor = (minContrastIssue.backgroundColor.asString(Common.Color.Format.HEXA)); |
| |
| const showAPCA = Root.Runtime.experiments.isEnabled('apca'); |
| |
| const title = i18nString(UIStrings.textColorSOverSBackgroundResults, { |
| PH1: color, |
| PH2: backgroundColor, |
| PH3: issues.length, |
| }); |
| const border = getBorderString(minContrastIssue.backgroundColor.asLegacyColor()); |
| |
| // clang-format off |
| return html`<li> |
| <button |
| title=${title} aria-label=${title} |
| data-type="contrast" data-key=${key} data-section="contrast" class="block" |
| style=${styleMap({color, backgroundColor, border})} |
| jslog=${VisualLogging.action('css-overview.contrast').track({click: true})}> |
| Text |
| </button> |
| <div class="block-title"> |
| ${showAPCA ? html` |
| <div class="contrast-warning hidden"> |
| <span class="threshold-label">${i18nString(UIStrings.apca)}</span> |
| ${minContrastIssue.thresholdsViolated.apca ? createClearIcon() : createCheckIcon()} |
| </div>` : html` |
| <div class="contrast-warning hidden"> |
| <span class="threshold-label">${i18nString(UIStrings.aa)}</span> |
| ${minContrastIssue.thresholdsViolated.aa ? createClearIcon() : createCheckIcon()} |
| </div> |
| <div class="contrast-warning hidden"> |
| <span class="threshold-label">${i18nString(UIStrings.aaa)}</span> |
| ${minContrastIssue.thresholdsViolated.aaa ? createClearIcon() : createCheckIcon()} |
| </div>`} |
| </div> |
| </li>`; |
| // clang-format on |
| } |
| |
| function renderColor(section: string, color: string): LitTemplate { |
| const borderColor = Common.Color.parse(color)?.asLegacyColor(); |
| if (!borderColor) { |
| return nothing; |
| } |
| // clang-format off |
| return html`<li> |
| <button title=${color} data-type="color" data-color=${color} |
| data-section=${section} class="block" |
| style=${styleMap({backgroundColor: color, border: getBorderString(borderColor)})} |
| jslog=${VisualLogging.action('css-overview.color').track({click: true})}> |
| </button> |
| <div class="block-title color-text">${color}</div> |
| </li>`; |
| // clang-format on |
| } |
| |
| type PopulateNodesEvent = { |
| type: 'contrast', |
| key: string, |
| section: string|undefined, |
| nodes: ContrastIssue[], |
| }|{ |
| type: 'color', |
| color: string, |
| section: string | undefined, |
| nodes: Array<{nodeId: Protocol.DOM.BackendNodeId}>, |
| }|{ |
| type: 'unused-declarations', |
| declaration: string, |
| nodes: UnusedDeclaration[], |
| }|{ |
| type: 'media-queries', |
| text: string, |
| nodes: Protocol.CSS.CSSMedia[], |
| }|{ |
| type: 'font-info', |
| name: string, |
| nodes: Array<{nodeId: Protocol.DOM.BackendNodeId}>, |
| }; |
| |
| export type PopulateNodesEventNodes = PopulateNodesEvent['nodes']; |
| export type PopulateNodesEventNodeTypes = PopulateNodesEventNodes[0]; |
| |
| export class CSSOverviewCompletedView extends UI.Widget.VBox { |
| onReset = (): void => {}; |
| #selectedSection = 'summary'; |
| #cssModel?: SDK.CSSModel.CSSModel; |
| #domModel?: SDK.DOMModel.DOMModel; |
| #linkifier: Components.Linkifier.Linkifier; |
| #viewMap: Map<string, ElementDetailsView>; |
| #data: OverviewData|null; |
| #view: View; |
| #viewOutput: ViewOutput = { |
| revealSection: new Map(), |
| closeAllTabs: () => {}, |
| addTab: (_id, _tabTitle, _view, _jslogContext) => {} |
| }; |
| |
| constructor(element?: HTMLElement, view = DEFAULT_VIEW) { |
| super(element); |
| this.#view = view; |
| this.registerRequiredCSS(cssOverviewCompletedViewStyles); |
| this.#linkifier = new Components.Linkifier.Linkifier(/* maxLinkLength */ 20, /* useLinkDecorator */ true); |
| this.#viewMap = new Map(); |
| this.#data = null; |
| } |
| |
| set target(target: SDK.Target.Target|undefined) { |
| if (!target) { |
| return; |
| } |
| 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; |
| } |
| |
| #onSectionSelected(sectionId: string, withKeyboard: boolean): void { |
| const revealSection = this.#viewOutput.revealSection.get(sectionId); |
| if (!revealSection) { |
| return; |
| } |
| |
| revealSection(withKeyboard); |
| } |
| |
| #onReset(): void { |
| this.#reset(); |
| this.onReset(); |
| } |
| |
| #reset(): void { |
| this.#viewOutput.closeAllTabs(); |
| this.#viewMap = new Map(); |
| CSSOverviewCompletedView.pushedNodes.clear(); |
| this.#selectedSection = 'summary'; |
| this.requestUpdate(); |
| } |
| |
| #onClick(evt: Event): void { |
| if (!evt.target) { |
| return; |
| } |
| const target = (evt.target as HTMLElement); |
| const dataset = target.dataset; |
| |
| const type = dataset.type; |
| if (!type || !this.#data) { |
| return; |
| } |
| |
| let payload: PopulateNodesEvent; |
| 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.label; |
| if (!declaration) { |
| return; |
| } |
| const nodes = this.#data.unusedDeclarations.get(declaration); |
| if (!nodes) { |
| return; |
| } |
| |
| payload = {type, declaration, nodes}; |
| break; |
| } |
| |
| case 'media-queries': { |
| const text = dataset.label; |
| if (!text) { |
| return; |
| } |
| const nodes = this.#data.mediaQueries.get(text); |
| if (!nodes) { |
| return; |
| } |
| |
| payload = {type, text, nodes}; |
| break; |
| } |
| |
| case 'font-info': { |
| const value = dataset.label; |
| 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 = 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.#createElementsView(payload); |
| this.requestUpdate(); |
| } |
| |
| override performUpdate(): void { |
| if (!this.#data || !('backgroundColors' in this.#data) || !('textColors' in this.#data)) { |
| return; |
| } |
| |
| const viewInput = { |
| elementCount: this.#data.elementCount, |
| backgroundColors: this.#sortColorsByLuminance(this.#data.backgroundColors), |
| textColors: this.#sortColorsByLuminance(this.#data.textColors), |
| textColorContrastIssues: this.#data.textColorContrastIssues, |
| fillColors: this.#sortColorsByLuminance(this.#data.fillColors), |
| borderColors: this.#sortColorsByLuminance(this.#data.borderColors), |
| globalStyleStats: this.#data.globalStyleStats, |
| mediaQueries: this.#sortGroupBySize(this.#data.mediaQueries), |
| unusedDeclarations: this.#sortGroupBySize(this.#data.unusedDeclarations), |
| fontInfo: this.#sortFontInfo(this.#data.fontInfo), |
| selectedSection: this.#selectedSection, |
| onClick: this.#onClick.bind(this), |
| onSectionSelected: this.#onSectionSelected.bind(this), |
| onReset: this.#onReset.bind(this), |
| }; |
| this.#view(viewInput, this.#viewOutput, this.element); |
| } |
| |
| #createElementsView(payload: PopulateNodesEvent): void { |
| let id = ''; |
| let tabTitle = ''; |
| |
| switch (payload.type) { |
| case 'contrast': { |
| const {section, key} = payload; |
| id = `${section}-${key}`; |
| tabTitle = i18nString(UIStrings.contrastIssues); |
| break; |
| } |
| |
| case 'color': { |
| const {section, color} = payload; |
| id = `${section}-${color}`; |
| tabTitle = `${color.toUpperCase()} (${section})`; |
| break; |
| } |
| |
| case 'unused-declarations': { |
| const {declaration} = payload; |
| id = `${declaration}`; |
| tabTitle = `${declaration}`; |
| break; |
| } |
| |
| case 'media-queries': { |
| const {text} = payload; |
| id = `${text}`; |
| tabTitle = `${text}`; |
| break; |
| } |
| |
| case 'font-info': { |
| const {name} = payload; |
| id = `${name}`; |
| tabTitle = `${name}`; |
| break; |
| } |
| } |
| |
| let view = this.#viewMap.get(id); |
| if (!view) { |
| if (!this.#domModel || !this.#cssModel) { |
| throw new Error('Unable to initialize CSS overview, missing models'); |
| } |
| view = new ElementDetailsView(this.#domModel, this.#cssModel, this.#linkifier); |
| view.data = payload.nodes; |
| this.#viewMap.set(id, view); |
| } |
| |
| this.#viewOutput.addTab(id, tabTitle, view, payload.type); |
| } |
| |
| #sortColorsByLuminance(srcColors: Map<string, Set<number>>): string[] { |
| return Array.from(srcColors.keys()).sort((colA, colB) => { |
| const colorA = Common.Color.parse(colA)?.asLegacyColor(); |
| const colorB = Common.Color.parse(colB)?.asLegacyColor(); |
| if (!colorA || !colorB) { |
| return 0; |
| } |
| return Common.ColorUtils.luminance(colorB.rgba()) - Common.ColorUtils.luminance(colorA.rgba()); |
| }); |
| } |
| |
| #sortFontInfo(fontInfo: Map<string, Map<string, Map<string, number[]>>>): |
| Array<{font: string, fontMetrics: FontMetric[]}> { |
| const fonts = Array.from(fontInfo.entries()); |
| return fonts.map(([font, fontMetrics]) => { |
| const fontMetricInfo = Array.from(fontMetrics.entries()); |
| return { |
| font, |
| fontMetrics: fontMetricInfo.map(([label, values]) => { |
| return {label, values: this.#sortGroupBySize(values)}; |
| }) |
| }; |
| }); |
| } |
| |
| #sortGroupBySize<T extends number|UnusedDeclaration|Protocol.CSS.CSSMedia>(items: Map<string, T[]>): |
| Array<{title: string, nodes: T[]}> { |
| // Sort by number of items descending. |
| return Array.from(items.entries()) |
| .sort((d1, d2) => { |
| const v1Nodes = d1[1]; |
| const v2Nodes = d2[1]; |
| return v2Nodes.length - v1Nodes.length; |
| }) |
| .map(([title, nodes]) => ({title, nodes})); |
| } |
| |
| set overviewData(data: OverviewData) { |
| this.#data = data; |
| this.requestUpdate(); |
| } |
| |
| static readonly pushedNodes = new Set<Protocol.DOM.BackendNodeId>(); |
| } |
| |
| interface ElementDetailsViewInput { |
| items: Array<{ |
| data: PopulateNodesEventNodeTypes, |
| link?: HTMLElement, |
| showNode?: () => void, |
| }>; |
| visibility: Set<string>; |
| } |
| type ElementDetailsViewFunction = (input: ElementDetailsViewInput, output: object, target: HTMLElement) => void; |
| |
| export const ELEMENT_DETAILS_DEFAULT_VIEW: ElementDetailsViewFunction = (input, _output, target) => { |
| const {items, visibility} = input; |
| // clang-format off |
| render(html` |
| <div> |
| <devtools-data-grid class="element-grid" striped inline |
| name=${i18nString(UIStrings.cssOverviewElements)}> |
| <table> |
| <tr> |
| ${visibility.has('node-id') ? html` |
| <th id="node-id" weight="50" sortable> |
| ${i18nString(UIStrings.element)} |
| </th>` : nothing} |
| ${visibility.has('declaration') ? html` |
| <th id="declaration" weight="50" sortable> |
| ${i18nString(UIStrings.declaration)} |
| </th>` : nothing} |
| ${visibility.has('source-url') ? html` |
| <th id="source-url" weight="100"> |
| ${i18nString(UIStrings.source)} |
| </th>` : nothing} |
| ${visibility.has('contrast-ratio') ? html` |
| <th id="contrast-ratio" weight="25" width="150px" sortable fixed> |
| ${i18nString(UIStrings.contrastRatio)} |
| </th>` : nothing} |
| </tr> |
| ${items.map(({data, link, showNode}) => html` |
| <tr> |
| ${visibility.has('node-id') ? renderNode(data, link, showNode) : nothing} |
| ${visibility.has('declaration') ? renderDeclaration(data) : nothing} |
| ${visibility.has('source-url') ? renderSourceURL(data, link) : nothing} |
| ${visibility.has('contrast-ratio') ? renderContrastRatio(data) : nothing} |
| </tr>`)} |
| </table> |
| </devtools-data-grid> |
| </div>`, |
| target); |
| // clang-format on |
| }; |
| |
| export class ElementDetailsView extends UI.Widget.Widget { |
| #domModel: SDK.DOMModel.DOMModel; |
| readonly #cssModel: SDK.CSSModel.CSSModel; |
| readonly #linkifier: Components.Linkifier.Linkifier; |
| #data: PopulateNodesEventNodes; |
| readonly #view: ElementDetailsViewFunction; |
| |
| constructor( |
| domModel: SDK.DOMModel.DOMModel, cssModel: SDK.CSSModel.CSSModel, linkifier: Components.Linkifier.Linkifier, |
| view: ElementDetailsViewFunction = ELEMENT_DETAILS_DEFAULT_VIEW) { |
| super(); |
| |
| this.#domModel = domModel; |
| this.#cssModel = cssModel; |
| this.#linkifier = linkifier; |
| this.#view = view; |
| this.#data = []; |
| } |
| |
| set data(data: PopulateNodesEventNodes) { |
| this.#data = data; |
| this.requestUpdate(); |
| } |
| |
| override async performUpdate(): Promise<void> { |
| const visibility = new Set<string>(); |
| if (!this.#data.length) { |
| this.#view({items: [], visibility}, {}, this.element); |
| return; |
| } |
| |
| const [firstItem] = this.#data; |
| 'nodeId' in firstItem && firstItem.nodeId && visibility.add('node-id'); |
| 'declaration' in firstItem && firstItem.declaration && visibility.add('declaration'); |
| 'sourceURL' in firstItem && firstItem.sourceURL && visibility.add('source-url'); |
| 'contrastRatio' in firstItem && firstItem.contrastRatio && visibility.add('contrast-ratio'); |
| |
| let relatedNodesMap: Map<Protocol.DOM.BackendNodeId, SDK.DOMModel.DOMNode|null>|null|undefined; |
| if ('nodeId' in firstItem && visibility.has('node-id')) { |
| // Grab the nodes from the frontend, but only those that have not been |
| // retrieved already. |
| const nodeIds = (this.#data as Array<{nodeId: Protocol.DOM.BackendNodeId}>).reduce((prev, curr) => { |
| const nodeId = curr.nodeId; |
| if (CSSOverviewCompletedView.pushedNodes.has(nodeId)) { |
| return prev; |
| } |
| CSSOverviewCompletedView.pushedNodes.add(nodeId); |
| return prev.add(nodeId); |
| }, new Set<Protocol.DOM.BackendNodeId>()); |
| relatedNodesMap = await this.#domModel.pushNodesByBackendIdsToFrontend(nodeIds); |
| } |
| |
| const items = await Promise.all(this.#data.map(async item => { |
| let link, showNode; |
| if ('nodeId' in item && visibility.has('node-id')) { |
| const frontendNode = relatedNodesMap?.get(item.nodeId) ?? null; |
| if (frontendNode) { |
| link = PanelsCommon.DOMLinkifier.Linkifier.instance().linkify(frontendNode); |
| showNode = () => frontendNode.scrollIntoView(); |
| } |
| } |
| if ('range' in item && item.range && item.styleSheetId && visibility.has('source-url')) { |
| const ruleLocation = TextUtils.TextRange.TextRange.fromObject(item.range); |
| const styleSheetHeader = this.#cssModel.styleSheetHeaderForId(item.styleSheetId); |
| if (styleSheetHeader) { |
| const lineNumber = styleSheetHeader.lineNumberInSource(ruleLocation.startLine); |
| const columnNumber = styleSheetHeader.columnNumberInSource(ruleLocation.startLine, ruleLocation.startColumn); |
| const matchingSelectorLocation = new SDK.CSSModel.CSSLocation(styleSheetHeader, lineNumber, columnNumber); |
| link = this.#linkifier.linkifyCSSLocation(matchingSelectorLocation) as HTMLElement; |
| } |
| } |
| |
| return {data: item, link, showNode}; |
| })); |
| |
| this.#view({items, visibility}, {}, this.element); |
| } |
| } |
| |
| function renderNode(data: PopulateNodesEventNodeTypes, link?: HTMLElement, showNode?: () => void): LitTemplate { |
| if (!link) { |
| return nothing; |
| } |
| return html` |
| <td> |
| ${link} |
| <devtools-icon part="show-element" name="select-element" |
| title=${i18nString(UIStrings.showElement)} tabindex="0" |
| @click=${() => showNode?.()}></devtools-icon> |
| </td>`; |
| } |
| |
| function renderDeclaration(data: PopulateNodesEventNodeTypes): TemplateResult { |
| if (!('declaration' in data)) { |
| throw new Error('Declaration entry is missing a declaration.'); |
| } |
| return html`<td>${data.declaration}</td>`; |
| } |
| |
| function renderSourceURL(data: PopulateNodesEventNodeTypes, link?: HTMLElement): TemplateResult { |
| if ('range' in data && data.range) { |
| if (!link) { |
| return html`<td>${i18nString(UIStrings.unableToLink)}</td>`; |
| } |
| return html`<td>${link}</td>`; |
| } |
| return html`<td>${i18nString(UIStrings.unableToLinkToInlineStyle)}</td>`; |
| } |
| |
| function renderContrastRatio(data: PopulateNodesEventNodeTypes): TemplateResult { |
| if (!('contrastRatio' in data)) { |
| throw new Error('Contrast ratio entry is missing a contrast ratio.'); |
| } |
| const showAPCA = Root.Runtime.experiments.isEnabled('apca'); |
| const contrastRatio = Platform.NumberUtilities.floor(data.contrastRatio, 2); |
| const contrastRatioString = showAPCA ? contrastRatio + '%' : contrastRatio; |
| const border = getBorderString(data.backgroundColor); |
| const color = data.textColor.asString(); |
| const backgroundColor = data.backgroundColor.asString(); |
| |
| // clang-format off |
| return html` |
| <td> |
| <div class="contrast-container-in-grid"> |
| <span class="contrast-preview" style=${styleMap({border, color, backgroundColor})}>Aa</span> |
| <span>${contrastRatioString}</span> |
| ${showAPCA ? |
| html` |
| <span>${i18nString(UIStrings.apca)}</span>${data.thresholdsViolated.apca ? createClearIcon() : createCheckIcon()}` |
| : html` |
| <span>${i18nString(UIStrings.aa)}</span>${data.thresholdsViolated.aa ? createClearIcon() : createCheckIcon()} |
| <span>${i18nString(UIStrings.aaa)}</span>${data.thresholdsViolated.aaa ? createClearIcon() : createCheckIcon()}` |
| } |
| </div> |
| </td>`; |
| // clang-format on |
| } |
| |
| function createClearIcon(): TemplateResult { |
| return html` |
| <devtools-icon name="clear" class="small" style="color:var(--icon-error);"></devtools-icon>`; |
| } |
| |
| function createCheckIcon(): TemplateResult { |
| return html` |
| <devtools-icon name="checkmark" class="small" |
| style="color:var(--icon-checkmark-green);"></devtools-icon>`; |
| } |