lighthouse/report/renderer/report-ui-features.js

367 строки
13 KiB
JavaScript

/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Adds tools button, print, and other dynamic functionality to
* the report.
*/
/** @typedef {import('./dom').DOM} DOM */
import {ElementScreenshotRenderer} from './element-screenshot-renderer.js';
import {toggleDarkTheme} from './features-util.js';
import {openTreemap} from './open-tab.js';
import {TopbarFeatures} from './topbar-features.js';
import {Util} from '../../shared/util.js';
import {getLhrFilenamePrefix} from '../generator/file-namer.js';
import {Globals} from './report-globals.js';
/**
* @param {HTMLTableElement} tableEl
* @return {Array<HTMLElement>}
*/
function getTableRows(tableEl) {
return Array.from(tableEl.tBodies[0].rows);
}
export class ReportUIFeatures {
/**
* @param {DOM} dom
* @param {LH.Renderer.Options} opts
*/
constructor(dom, opts = {}) {
/** @type {LH.Result} */
this.json; // eslint-disable-line no-unused-expressions
/** @type {DOM} */
this._dom = dom;
this._opts = opts;
this._topbar = opts.omitTopbar ? null : new TopbarFeatures(this, dom);
this.onMediaQueryChange = this.onMediaQueryChange.bind(this);
}
/**
* Adds tools button, print, and other functionality to the report. The method
* should be called whenever the report needs to be re-rendered.
* @param {LH.Result} lhr
*/
initFeatures(lhr) {
this.json = lhr;
this._fullPageScreenshot = Util.getFullPageScreenshot(lhr);
if (this._topbar) {
this._topbar.enable(lhr);
this._topbar.resetUIState();
}
this._setupMediaQueryListeners();
this._setupThirdPartyFilter();
this._setupElementScreenshotOverlay(this._dom.rootEl);
// Do not query the system preferences for DevTools - DevTools should only apply dark theme
// if dark is selected in the settings panel.
// TODO: set `disableDarkMode` in devtools and delete this special case.
const disableDarkMode = this._dom.isDevTools() ||
this._opts.disableDarkMode || this._opts.disableAutoDarkModeAndFireworks;
if (!disableDarkMode && window.matchMedia('(prefers-color-scheme: dark)').matches) {
toggleDarkTheme(this._dom, true);
}
// Fireworks!
// To get fireworks you need 100 scores in all core categories.
const fireworksRequiredCategoryIds = ['performance', 'accessibility', 'best-practices', 'seo'];
const scoresAll100 = fireworksRequiredCategoryIds.every(id => {
const cat = lhr.categories[id];
return cat && cat.score === 1;
});
const disableFireworks =
this._opts.disableFireworks || this._opts.disableAutoDarkModeAndFireworks;
if (scoresAll100 && !disableFireworks) {
this._enableFireworks();
// If dark mode is allowed, force it on because it looks so much better.
if (!disableDarkMode) toggleDarkTheme(this._dom, true);
}
// Show the metric descriptions by default when there is an error.
const hasMetricError = lhr.categories.performance && lhr.categories.performance.auditRefs
.some(audit => Boolean(audit.group === 'metrics' && lhr.audits[audit.id].errorMessage));
if (hasMetricError) {
const toggleInputEl = this._dom.find('input.lh-metrics-toggle__input', this._dom.rootEl);
toggleInputEl.checked = true;
}
const showTreemapApp =
this.json.audits['script-treemap-data'] && this.json.audits['script-treemap-data'].details;
if (showTreemapApp) {
this.addButton({
text: Globals.strings.viewTreemapLabel,
icon: 'treemap',
onClick: () => openTreemap(this.json),
});
}
if (this._opts.onViewTrace) {
if (lhr.configSettings.throttlingMethod === 'simulate') {
// eslint-disable-next-line max-len
this._dom.find('a[data-action="view-unthrottled-trace"]', this._dom.rootEl).classList.remove('lh-hidden');
} else {
this.addButton({
text: Globals.strings.viewTraceLabel,
onClick: () => this._opts.onViewTrace?.(),
});
}
}
if (this._opts.getStandaloneReportHTML) {
this._dom.find('a[data-action="save-html"]', this._dom.rootEl).classList.remove('lh-hidden');
}
// Fill in all i18n data.
for (const node of this._dom.findAll('[data-i18n]', this._dom.rootEl)) {
// These strings are guaranteed to (at least) have a default English string in UIStrings,
// so this cannot be undefined as long as `report-ui-features.data-i18n` test passes.
const i18nKey = node.getAttribute('data-i18n');
const i18nAttr = /** @type {keyof typeof Globals.strings} */ (i18nKey);
node.textContent = Globals.strings[i18nAttr];
}
}
/**
* @param {{text: string, icon?: string, onClick: () => void}} opts
*/
addButton(opts) {
// Use qSA directly to as we don't want to throw (if this element is missing).
const metricsEl = this._dom.rootEl.querySelector('.lh-audit-group--metrics');
if (!metricsEl) return;
let buttonsEl = metricsEl.querySelector('.lh-buttons');
if (!buttonsEl) buttonsEl = this._dom.createChildOf(metricsEl, 'div', 'lh-buttons');
const classes = [
'lh-button',
];
if (opts.icon) {
classes.push('lh-report-icon');
classes.push(`lh-report-icon--${opts.icon}`);
}
const buttonEl = this._dom.createChildOf(buttonsEl, 'button', classes.join(' '));
buttonEl.textContent = opts.text;
buttonEl.addEventListener('click', opts.onClick);
return buttonEl;
}
resetUIState() {
if (this._topbar) {
this._topbar.resetUIState();
}
}
/**
* Returns the html that recreates this report.
* @return {string}
*/
getReportHtml() {
if (!this._opts.getStandaloneReportHTML) {
throw new Error('`getStandaloneReportHTML` is not set');
}
this.resetUIState();
return this._opts.getStandaloneReportHTML();
}
/**
* Save json as a gist. Unimplemented in base UI features.
*/
saveAsGist() {
// TODO ?
throw new Error('Cannot save as gist from base report');
}
_enableFireworks() {
const scoresContainer = this._dom.find('.lh-scores-container', this._dom.rootEl);
scoresContainer.classList.add('lh-score100');
}
_setupMediaQueryListeners() {
const mediaQuery = self.matchMedia('(max-width: 500px)');
mediaQuery.addListener(this.onMediaQueryChange);
// Ensure the handler is called on init
this.onMediaQueryChange(mediaQuery);
}
/**
* Resets the state of page before capturing the page for export.
* When the user opens the exported HTML page, certain UI elements should
* be in their closed state (not opened) and the templates should be unstamped.
*/
_resetUIState() {
if (this._topbar) {
this._topbar.resetUIState();
}
}
/**
* Handle media query change events.
* @param {MediaQueryList|MediaQueryListEvent} mql
*/
onMediaQueryChange(mql) {
this._dom.rootEl.classList.toggle('lh-narrow', mql.matches);
}
_setupThirdPartyFilter() {
// Some audits should not display the third party filter option.
const thirdPartyFilterAuditExclusions = [
// These audits deal explicitly with third party resources.
'uses-rel-preconnect',
'third-party-facades',
];
// Some audits should hide third party by default.
const thirdPartyFilterAuditHideByDefault = [
// Only first party resources are actionable.
'legacy-javascript',
];
// Get all tables with a text url column.
const tables = Array.from(this._dom.rootEl.querySelectorAll('table.lh-table'));
const tablesWithUrls = tables
.filter(el =>
el.querySelector('td.lh-table-column--url, td.lh-table-column--source-location'))
.filter(el => {
const containingAudit = el.closest('.lh-audit');
if (!containingAudit) throw new Error('.lh-table not within audit');
return !thirdPartyFilterAuditExclusions.includes(containingAudit.id);
});
tablesWithUrls.forEach((tableEl) => {
const rowEls = getTableRows(tableEl);
const nonSubItemRows = rowEls.filter(rowEl => !rowEl.classList.contains('lh-sub-item-row'));
const thirdPartyRowEls = this._getThirdPartyRows(nonSubItemRows,
Util.getFinalDisplayedUrl(this.json));
// Entity-grouped tables don't have zebra lines.
const hasZebraStyle = rowEls.some(rowEl => rowEl.classList.contains('lh-row--even'));
// create input box
const filterTemplate = this._dom.createComponent('3pFilter');
const filterInput = this._dom.find('input', filterTemplate);
filterInput.addEventListener('change', e => {
const shouldHideThirdParty = e.target instanceof HTMLInputElement && !e.target.checked;
let even = true;
let rowEl = nonSubItemRows[0];
while (rowEl) {
const shouldHide = shouldHideThirdParty && thirdPartyRowEls.includes(rowEl);
// Iterate subsequent associated sub item rows.
do {
rowEl.classList.toggle('lh-row--hidden', shouldHide);
if (hasZebraStyle) {
// Adjust for zebra styling.
rowEl.classList.toggle('lh-row--even', !shouldHide && even);
rowEl.classList.toggle('lh-row--odd', !shouldHide && !even);
}
rowEl = /** @type {HTMLElement} */ (rowEl.nextElementSibling);
} while (rowEl && rowEl.classList.contains('lh-sub-item-row'));
if (!shouldHide) even = !even;
}
});
// thirdPartyRowEls contains both heading and item rows.
// Filter out heading rows to get third party resource count.
const thirdPartyResourceCount = thirdPartyRowEls.filter(
rowEl => !rowEl.classList.contains('lh-row--group')).length;
this._dom.find('.lh-3p-filter-count', filterTemplate).textContent =
`${thirdPartyResourceCount}`;
this._dom.find('.lh-3p-ui-string', filterTemplate).textContent =
Globals.strings.thirdPartyResourcesLabel;
const allThirdParty = thirdPartyRowEls.length === nonSubItemRows.length;
const allFirstParty = !thirdPartyRowEls.length;
// If all or none of the rows are 3rd party, hide the control.
if (allThirdParty || allFirstParty) {
this._dom.find('div.lh-3p-filter', filterTemplate).hidden = true;
}
// Add checkbox to the DOM.
if (!tableEl.parentNode) return; // Keep tsc happy.
tableEl.parentNode.insertBefore(filterTemplate, tableEl);
// Hide third-party rows for some audits by default.
const containingAudit = tableEl.closest('.lh-audit');
if (!containingAudit) throw new Error('.lh-table not within audit');
if (thirdPartyFilterAuditHideByDefault.includes(containingAudit.id) && !allThirdParty) {
filterInput.click();
}
});
}
/**
* @param {Element} rootEl
*/
_setupElementScreenshotOverlay(rootEl) {
if (!this._fullPageScreenshot) return;
ElementScreenshotRenderer.installOverlayFeature({
dom: this._dom,
rootEl: rootEl,
overlayContainerEl: rootEl,
fullPageScreenshot: this._fullPageScreenshot,
});
}
/**
* From a table with URL entries, finds the rows containing third-party URLs
* and returns them.
* @param {HTMLElement[]} rowEls
* @param {string} finalDisplayedUrl
* @return {Array<HTMLElement>}
*/
_getThirdPartyRows(rowEls, finalDisplayedUrl) {
const finalDisplayedUrlEntity = Util.getEntityFromUrl(finalDisplayedUrl, this.json.entities);
const firstPartyEntityName = this.json.entities?.find(e => e.isFirstParty === true)?.name;
/** @type {Array<HTMLElement>} */
const thirdPartyRowEls = [];
for (const rowEl of rowEls) {
if (firstPartyEntityName) {
// We rely on entity-classification for new LHRs that support it.
if (!rowEl.dataset.entity || rowEl.dataset.entity === firstPartyEntityName) continue;
} else {
// Without 10.0's entity classification, fallback to the older root domain-based filtering.
const urlItem = rowEl.querySelector('div.lh-text__url');
if (!urlItem) continue;
const datasetUrl = urlItem.dataset.url;
if (!datasetUrl) continue;
const isThirdParty =
Util.getEntityFromUrl(datasetUrl, this.json.entities) !== finalDisplayedUrlEntity;
if (!isThirdParty) continue;
}
thirdPartyRowEls.push(rowEl);
}
return thirdPartyRowEls;
}
/**
* @param {Blob|File} blob
*/
_saveFile(blob) {
const ext = blob.type.match('json') ? '.json' : '.html';
const filename = getLhrFilenamePrefix({
finalDisplayedUrl: Util.getFinalDisplayedUrl(this.json),
fetchTime: this.json.fetchTime,
}) + ext;
if (this._opts.onSaveFileOverride) {
this._opts.onSaveFileOverride(blob, filename);
} else {
this._dom.saveFile(blob, filename);
}
}
}