chrome-devtools-frontend
Version:
Chrome DevTools UI
641 lines (559 loc) • 23.7 kB
text/typescript
// Copyright 2017 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.
/* eslint-disable rulesdir/no-imperative-dom-api */
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 Trace from '../../models/trace/trace.js';
import * as IconButton from '../../ui/components/icon_button/icon_button.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import {
type TimelineEventOverview,
TimelineEventOverviewCPUActivity,
TimelineEventOverviewNetwork,
TimelineEventOverviewResponsiveness,
} from './TimelineEventOverview.js';
import timelineHistoryManagerStyles from './timelineHistoryManager.css.js';
import type {TimelineMiniMap} from './TimelineMiniMap.js';
/**
* The dropdown works by returning an index which is the trace index; but we
* also need a way to signify that the user picked the "Landing Page" option. We
* represent that as Infinity so we never accidentally collide with an actual
* trace (in reality a large number like 99 would probably suffice...)
*/
export const LANDING_PAGE_INDEX_DROPDOWN_CHOICE = Infinity;
const UIStrings = {
/**
*@description Screen reader label for the Timeline History dropdown button
*@example {example.com #3} PH1
*@example {Show recent timeline sessions} PH2
*/
currentSessionSS: 'Current session: {PH1}. {PH2}',
/**
*@description the title shown when the user is viewing the landing page which is showing live performance metrics that are updated automatically.
*/
landingPageTitle: 'Live metrics',
/**
* @description the title shown when the user is viewing the landing page which can be used to make a new performance recording.
*/
nodeLandingPageTitle: 'New recording',
/**
*@description Text in Timeline History Manager of the Performance panel
*@example {example.com} PH1
*@example {2} PH2
*/
sD: '{PH1} #{PH2}',
/**
*@description Accessible label for the timeline session selection menu
*/
selectTimelineSession: 'Select timeline session',
/**
* @description Text label for a menu item indicating that a specific slowdown multiplier is applied.
* @example {2} PH1
*/
dSlowdown: '{PH1}× slowdown',
/**
* @description Tooltip text that appears when hovering over the Back arrow inside the 'Select Timeline Session' dropdown in the Performance pane.
*/
backButtonTooltip: 'View live metrics page',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/timeline/TimelineHistoryManager.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
/**
* The dropdown includes an option to navigate to the landing page; hence the
* two types for storing recordings. The TimelineHistoryManager automatically
* includes a link to go back to the landing page.
*/
interface TraceRecordingHistoryItem {
type: 'TRACE_INDEX';
// By storing only the index of this trace, the TimelinePanel can then look
// up this trace's data (and metadata) via this index.
parsedTraceIndex: number;
}
interface LandingPageHistoryItem {
type: 'LANDING_PAGE';
}
export type RecordingData = TraceRecordingHistoryItem|LandingPageHistoryItem;
export interface NewHistoryRecordingData {
// The data we will save to restore later.
data: TraceRecordingHistoryItem;
// We do not store this, but need it to build the thumbnail preview.
filmStripForPreview: Trace.Extras.FilmStrip.Data|null;
// Also not stored, but used to create the preview overview for a new trace.
parsedTrace: Trace.Handlers.Types.ParsedTrace;
metadata: Trace.Types.File.MetaData|null;
}
// Lazily instantiate the formatter as the constructor takes 50ms+
// TODO: move me and others like me to i18n module
const listFormatter = (function defineFormatter() {
let intlListFormat: Intl.ListFormat;
return {
format(...args: Parameters<Intl.ListFormat['format']>): ReturnType<Intl.ListFormat['format']> {
if (!intlListFormat) {
const opts: Intl.ListFormatOptions = {type: 'unit', style: 'short'};
intlListFormat = new Intl.ListFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale, opts);
}
return intlListFormat.format(...args);
},
};
})();
export class TimelineHistoryManager {
private recordings: TraceRecordingHistoryItem[];
private readonly action: UI.ActionRegistration.Action;
private readonly nextNumberByDomain: Map<string, number>;
private readonly buttonInternal: ToolbarButton;
private readonly allOverviews: Array<{
constructor: (parsedTrace: Trace.Handlers.Types.ParsedTrace) => TimelineEventOverview,
height: number,
}>;
private totalHeight: number;
private enabled: boolean;
private lastActiveTrace: RecordingData|null = null;
#minimapComponent?: TimelineMiniMap;
#landingPageTitle: Common.UIString.LocalizedString;
constructor(minimapComponent?: TimelineMiniMap, isNode?: boolean) {
this.recordings = [];
this.#minimapComponent = minimapComponent;
this.action = UI.ActionRegistry.ActionRegistry.instance().getAction('timeline.show-history');
this.nextNumberByDomain = new Map();
this.buttonInternal = new ToolbarButton(this.action);
this.#landingPageTitle =
isNode ? i18nString(UIStrings.nodeLandingPageTitle) : i18nString(UIStrings.landingPageTitle);
UI.ARIAUtils.markAsMenuButton(this.buttonInternal.element);
this.clear();
// Attempt to reuse the overviews coming from the panel's minimap
// before creating new instances.
this.allOverviews = [
{
constructor: parsedTrace => {
const responsivenessOverviewFromMinimap =
this.#minimapComponent?.getControls().find(
control => control instanceof TimelineEventOverviewResponsiveness) as
TimelineEventOverviewResponsiveness;
return responsivenessOverviewFromMinimap || new TimelineEventOverviewResponsiveness(parsedTrace);
},
height: 3,
},
{
constructor: parsedTrace => {
const cpuOverviewFromMinimap =
this.#minimapComponent?.getControls().find(
control => control instanceof TimelineEventOverviewCPUActivity) as TimelineEventOverviewCPUActivity;
if (cpuOverviewFromMinimap) {
return cpuOverviewFromMinimap;
}
return new TimelineEventOverviewCPUActivity(parsedTrace);
},
height: 20,
},
{
constructor: parsedTrace => {
const networkOverviewFromMinimap =
this.#minimapComponent?.getControls().find(control => control instanceof TimelineEventOverviewNetwork) as
TimelineEventOverviewNetwork;
return networkOverviewFromMinimap || new TimelineEventOverviewNetwork(parsedTrace);
},
height: 8,
},
];
this.totalHeight = this.allOverviews.reduce((acc, entry) => acc + entry.height, 0);
this.enabled = true;
}
addRecording(newInput: NewHistoryRecordingData): void {
const filmStrip = newInput.filmStripForPreview;
this.lastActiveTrace = newInput.data;
this.recordings.unshift(newInput.data);
// Order is important: this needs to happen first because lots of the
// subsequent code depends on us storing the preview data into the map.
this.#buildAndStorePreviewData(newInput.data.parsedTraceIndex, newInput.parsedTrace, newInput.metadata, filmStrip);
const modelTitle = this.title(newInput.data);
this.buttonInternal.setText(modelTitle);
const buttonTitle = this.action.title();
UI.ARIAUtils.setLabel(
this.buttonInternal.element, i18nString(UIStrings.currentSessionSS, {PH1: modelTitle, PH2: buttonTitle}));
this.updateState();
if (this.recordings.length <= maxRecordings) {
return;
}
const modelUsedMoreTimeAgo =
this.recordings.reduce((a, b) => lastUsedTime(a.parsedTraceIndex) < lastUsedTime(b.parsedTraceIndex) ? a : b);
this.recordings.splice(this.recordings.indexOf(modelUsedMoreTimeAgo), 1);
function lastUsedTime(index: number): number {
const data = TimelineHistoryManager.dataForTraceIndex(index);
if (!data) {
throw new Error('Unable to find data for model');
}
return data.lastUsed;
}
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
this.updateState();
}
button(): ToolbarButton {
return this.buttonInternal;
}
clear(): void {
this.recordings = [];
this.lastActiveTrace = null;
this.updateState();
this.buttonInternal.setText(this.#landingPageTitle);
this.nextNumberByDomain.clear();
}
#getActiveTraceIndexForListControl(): number {
if (!this.lastActiveTrace) {
return -1;
}
if (this.lastActiveTrace.type === 'LANDING_PAGE') {
return LANDING_PAGE_INDEX_DROPDOWN_CHOICE;
}
return this.lastActiveTrace.parsedTraceIndex;
}
async showHistoryDropDown(): Promise<RecordingData|null> {
if (this.recordings.length < 1 || !this.enabled) {
return null;
}
// DropDown.show() function finishes when the dropdown menu is closed via selection or losing focus
const activeTraceIndex = await DropDown.show(
this.recordings.map(recording => recording.parsedTraceIndex), this.#getActiveTraceIndexForListControl(),
this.buttonInternal.element, this.#landingPageTitle);
if (activeTraceIndex === null) {
return null;
}
// The ListControl class that backs the dropdown uses indexes; we represent
// the landing page choice via this special index.
if (activeTraceIndex === LANDING_PAGE_INDEX_DROPDOWN_CHOICE) {
this.#setActiveTrace({type: 'LANDING_PAGE'});
return {type: 'LANDING_PAGE'};
}
const index = this.recordings.findIndex(recording => recording.parsedTraceIndex === activeTraceIndex);
if (index < 0) {
console.assert(false, 'selected recording not found');
return null;
}
this.#setActiveTrace(this.recordings[index]);
return this.recordings[index];
}
cancelIfShowing(): void {
DropDown.cancelIfShowing();
}
/**
* Navigate by 1 in either direction to the next trace.
* Navigating in this way does not include the landing page; it will loop
* over only the traces.
*/
navigate(direction: number): TraceRecordingHistoryItem|null {
if (!this.enabled || this.lastActiveTrace === null) {
return null;
}
if (!this.lastActiveTrace || this.lastActiveTrace.type === 'LANDING_PAGE') {
return null;
}
const index = this.recordings.findIndex(recording => {
return this.lastActiveTrace?.type === 'TRACE_INDEX' && recording.type === 'TRACE_INDEX' &&
recording.parsedTraceIndex === this.lastActiveTrace.parsedTraceIndex;
});
if (index < 0) {
return null;
}
const newIndex = Platform.NumberUtilities.clamp(index + direction, 0, this.recordings.length - 1);
this.#setActiveTrace(this.recordings[newIndex]);
return this.recordings[newIndex];
}
navigateToLandingPage(): void {
this.#setActiveTrace({type: 'LANDING_PAGE'});
}
#setActiveTrace(item: RecordingData): void {
if (item.type === 'TRACE_INDEX') {
const data = TimelineHistoryManager.dataForTraceIndex(item.parsedTraceIndex);
if (!data) {
throw new Error('Unable to find data for model');
}
data.lastUsed = Date.now();
}
this.lastActiveTrace = item;
const modelTitle = this.title(item);
const buttonTitle = this.action.title();
this.buttonInternal.setText(modelTitle);
UI.ARIAUtils.setLabel(
this.buttonInternal.element, i18nString(UIStrings.currentSessionSS, {PH1: modelTitle, PH2: buttonTitle}));
}
private updateState(): void {
this.action.setEnabled(this.recordings.length >= 1 && this.enabled);
}
static previewElement(parsedTraceIndex: number): Element {
const data = TimelineHistoryManager.dataForTraceIndex(parsedTraceIndex);
if (!data) {
throw new Error('Unable to find data for model');
}
return data.preview;
}
private title(item: RecordingData): string {
if (item.type === 'LANDING_PAGE') {
return this.#landingPageTitle;
}
const data = TimelineHistoryManager.dataForTraceIndex(item.parsedTraceIndex);
if (!data) {
throw new Error('Unable to find data for model');
}
return data.title;
}
#buildAndStorePreviewData(
parsedTraceIndex: number, parsedTrace: Trace.Handlers.Types.ParsedTrace, metadata: Trace.Types.File.MetaData|null,
filmStrip: Trace.Extras.FilmStrip.Data|null): HTMLDivElement {
const parsedURL = Common.ParsedURL.ParsedURL.fromString(parsedTrace.Meta.mainFrameURL);
const domain = parsedURL ? parsedURL.host : '';
const sequenceNumber = this.nextNumberByDomain.get(domain) || 1;
const titleWithSequenceNumber = i18nString(UIStrings.sD, {PH1: domain, PH2: sequenceNumber});
this.nextNumberByDomain.set(domain, sequenceNumber + 1);
const preview = document.createElement('div');
preview.classList.add('preview-item');
preview.classList.add('vbox');
preview.setAttribute('jslog', `${VisualLogging.dropDown('timeline.history-item').track({click: true})}`);
preview.style.width = `${previewWidth}px`;
const data = {
preview,
title: titleWithSequenceNumber,
lastUsed: Date.now(),
};
parsedTraceIndexToPerformancePreviewData.set(parsedTraceIndex, data);
preview.appendChild(this.#buildTextDetails(metadata, domain));
const screenshotAndOverview = preview.createChild('div', 'hbox');
screenshotAndOverview.appendChild(this.#buildScreenshotThumbnail(filmStrip));
screenshotAndOverview.appendChild(this.#buildOverview(parsedTrace));
return data.preview;
}
#buildTextDetails(metadata: Trace.Types.File.MetaData|null, title: string): Element {
const container = document.createElement('div');
container.classList.add('text-details');
container.classList.add('hbox');
const nameSpan = container.createChild('span', 'name');
nameSpan.textContent = title;
UI.ARIAUtils.setLabel(nameSpan, title);
if (metadata) {
const parts = [
metadata.emulatedDeviceTitle,
metadata.cpuThrottling ? i18nString(UIStrings.dSlowdown, {PH1: metadata.cpuThrottling}) : undefined,
metadata.networkThrottling,
].filter(Boolean);
container.createChild('span', 'metadata').textContent = listFormatter.format(parts as string[]);
}
return container;
}
#buildScreenshotThumbnail(filmStrip: Trace.Extras.FilmStrip.Data|null): Element {
const container = document.createElement('div');
container.classList.add('screenshot-thumb');
const thumbnailAspectRatio = 3 / 2;
container.style.width = this.totalHeight * thumbnailAspectRatio + 'px';
container.style.height = this.totalHeight + 'px';
if (!filmStrip) {
return container;
}
const lastFrame = filmStrip.frames.at(-1);
if (!lastFrame) {
return container;
}
// TODO(paulirish): Adopt Util.ImageCache
const uri = Trace.Handlers.ModelHandlers.Screenshots.screenshotImageDataUri(lastFrame.screenshotEvent);
void UI.UIUtils.loadImage(uri).then(img => {
if (img) {
container.appendChild(img);
}
});
return container;
}
#buildOverview(parsedTrace: Trace.Handlers.Types.ParsedTrace): Element {
const container = document.createElement('div');
const dPR = window.devicePixelRatio;
container.style.width = previewWidth + 'px';
container.style.height = this.totalHeight + 'px';
const canvas = container.createChild('canvas');
canvas.width = dPR * previewWidth;
canvas.height = dPR * this.totalHeight;
const ctx = canvas.getContext('2d');
let yOffset = 0;
for (const overview of this.allOverviews) {
const timelineOverviewComponent = overview.constructor(parsedTrace);
timelineOverviewComponent.update();
if (ctx) {
ctx.drawImage(
timelineOverviewComponent.context().canvas, 0, yOffset, dPR * previewWidth, overview.height * dPR);
}
yOffset += overview.height * dPR;
}
return container;
}
private static dataForTraceIndex(index: number): PreviewData|null {
return parsedTraceIndexToPerformancePreviewData.get(index) || null;
}
}
export const maxRecordings = 5;
export const previewWidth = 500;
// The reason we store a global map is because the Dropdown component needs to
// be able to read the preview data in order to show a preview in the dropdown.
const parsedTraceIndexToPerformancePreviewData = new Map<number, PreviewData>();
export interface PreviewData {
preview: Element;
lastUsed: number;
title: string;
}
export class DropDown implements UI.ListControl.ListDelegate<number> {
private readonly glassPane: UI.GlassPane.GlassPane;
private readonly listControl: UI.ListControl.ListControl<number>;
private readonly focusRestorer: UI.UIUtils.ElementFocusRestorer;
private selectionDone: ((arg0: number|null) => void)|null;
#landingPageTitle: Common.UIString.LocalizedString;
contentElement: HTMLElement;
constructor(availableparsedTraceIndexes: number[], landingPageTitle: Common.UIString.LocalizedString) {
this.#landingPageTitle = landingPageTitle;
this.glassPane = new UI.GlassPane.GlassPane();
this.glassPane.setSizeBehavior(UI.GlassPane.SizeBehavior.MEASURE_CONTENT);
this.glassPane.setOutsideClickCallback(() => this.close(null));
this.glassPane.setPointerEventsBehavior(UI.GlassPane.PointerEventsBehavior.BLOCKED_BY_GLASS_PANE);
this.glassPane.setAnchorBehavior(UI.GlassPane.AnchorBehavior.PREFER_BOTTOM);
this.glassPane.element.addEventListener('blur', () => this.close(null));
const shadowRoot = UI.UIUtils.createShadowRootWithCoreStyles(
this.glassPane.contentElement, {cssFile: timelineHistoryManagerStyles});
this.contentElement = shadowRoot.createChild('div', 'drop-down');
const listModel = new UI.ListModel.ListModel<number>();
this.listControl = new UI.ListControl.ListControl<number>(listModel, this, UI.ListControl.ListMode.NonViewport);
this.listControl.element.addEventListener('mousemove', this.onMouseMove.bind(this), false);
listModel.replaceAll(availableparsedTraceIndexes);
UI.ARIAUtils.markAsMenu(this.listControl.element);
UI.ARIAUtils.setLabel(this.listControl.element, i18nString(UIStrings.selectTimelineSession));
this.contentElement.appendChild(this.listControl.element);
this.contentElement.addEventListener('keydown', this.onKeyDown.bind(this), false);
this.contentElement.addEventListener('click', this.onClick.bind(this), false);
this.focusRestorer = new UI.UIUtils.ElementFocusRestorer(this.listControl.element);
this.selectionDone = null;
}
static show(
availableparsedTraceIndexes: number[], activeparsedTraceIndex: number, anchor: Element,
landingPageTitle: Common.UIString.LocalizedString = i18nString(UIStrings.landingPageTitle)):
Promise<number|null> {
if (DropDown.instance) {
return Promise.resolve(null);
}
const availableDropdownChoices = [...availableparsedTraceIndexes];
availableDropdownChoices.unshift(LANDING_PAGE_INDEX_DROPDOWN_CHOICE);
const instance = new DropDown(availableDropdownChoices, landingPageTitle);
return instance.show(anchor, activeparsedTraceIndex);
}
static cancelIfShowing(): void {
if (!DropDown.instance) {
return;
}
DropDown.instance.close(null);
}
private show(anchor: Element, activeparsedTraceIndex: number): Promise<number|null> {
DropDown.instance = this;
this.glassPane.setContentAnchorBox(anchor.boxInWindow());
this.glassPane.show(this.glassPane.contentElement.ownerDocument);
this.listControl.element.focus();
this.listControl.selectItem(activeparsedTraceIndex);
return new Promise(fulfill => {
this.selectionDone = fulfill;
});
}
private onMouseMove(event: Event): void {
const node = (event.target as HTMLElement).enclosingNodeOrSelfWithClass('preview-item');
const listItem = node && this.listControl.itemForNode(node);
if (listItem === null) {
return;
}
this.listControl.selectItem(listItem);
}
private onClick(event: Event): void {
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// @ts-expect-error
if (!(event.target).enclosingNodeOrSelfWithClass('preview-item')) {
return;
}
this.close(this.listControl.selectedItem());
}
private onKeyDown(event: Event): void {
switch ((event as KeyboardEvent).key) {
case 'Tab':
case 'Escape':
this.close(null);
break;
case 'Enter':
this.close(this.listControl.selectedItem());
break;
default:
return;
}
event.consume(true);
}
private close(traceIndex: number|null): void {
if (this.selectionDone) {
this.selectionDone(traceIndex);
}
this.focusRestorer.restore();
this.glassPane.hide();
DropDown.instance = null;
}
createElementForItem(parsedTraceIndex: number): Element {
if (parsedTraceIndex === LANDING_PAGE_INDEX_DROPDOWN_CHOICE) {
return this.#createLandingPageListItem();
}
const element = TimelineHistoryManager.previewElement(parsedTraceIndex);
UI.ARIAUtils.markAsMenuItem(element);
element.classList.remove('selected');
return element;
}
#createLandingPageListItem(): HTMLElement {
const div = document.createElement('div');
UI.ARIAUtils.markAsMenuItem(div);
div.classList.remove('selected');
div.classList.add('preview-item');
div.classList.add('landing-page-item');
div.style.width = `${previewWidth}px`;
const icon = IconButton.Icon.create('arrow-back');
icon.title = i18nString(UIStrings.backButtonTooltip);
icon.classList.add('back-arrow');
div.appendChild(icon);
const text = document.createElement('span');
text.innerText = this.#landingPageTitle;
div.appendChild(text);
return div;
}
heightForItem(_parsedTraceIndex: number): number {
console.assert(false, 'Should not be called');
return 0;
}
isItemSelectable(_parsedTraceIndex: number): boolean {
return true;
}
selectedItemChanged(_from: number|null, _to: number|null, fromElement: Element|null, toElement: Element|null): void {
if (fromElement) {
fromElement.classList.remove('selected');
}
if (toElement) {
toElement.classList.add('selected');
}
}
updateSelectedItemARIA(_fromElement: Element|null, _toElement: Element|null): boolean {
return false;
}
static instance: DropDown|null = null;
}
export class ToolbarButton extends UI.Toolbar.ToolbarItem {
private contentElement: HTMLElement;
constructor(action: UI.ActionRegistration.Action) {
const element = document.createElement('button');
element.classList.add('history-dropdown-button');
element.setAttribute('jslog', `${VisualLogging.dropDown('history')}`);
super(element);
this.contentElement = this.element.createChild('span', 'content');
this.element.addEventListener('click', () => void action.execute(), false);
this.setEnabled(action.enabled());
action.addEventListener(UI.ActionRegistration.Events.ENABLED, event => this.setEnabled(event.data));
this.setTitle(action.title());
}
setText(text: string): void {
this.contentElement.textContent = text;
}
}