chrome-devtools-frontend
Version:
Chrome DevTools UI
454 lines (419 loc) • 19 kB
text/typescript
// Copyright 2024 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/components/settings/settings.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 Trace from '../../../models/trace/trace.js';
import * as TraceBounds from '../../../services/trace_bounds/trace_bounds.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as ThemeSupport from '../../../ui/legacy/theme_support/theme_support.js';
import * as Lit from '../../../ui/lit/lit.js';
import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js';
import {AnnotationHoverOut, HoverAnnotation, RemoveAnnotation, RevealAnnotation} from './Sidebar.js';
import sidebarAnnotationsTabStyles from './sidebarAnnotationsTab.css.js';
const {html, render} = Lit;
const diagramImageUrl = new URL('../../../Images/performance-panel-diagram.svg', import.meta.url).toString();
const entryLabelImageUrl = new URL('../../../Images/performance-panel-entry-label.svg', import.meta.url).toString();
const timeRangeImageUrl = new URL('../../../Images/performance-panel-time-range.svg', import.meta.url).toString();
const deleteAnnotationImageUrl =
new URL('../../../Images/performance-panel-delete-annotation.svg', import.meta.url).toString();
const UIStrings = {
/**
* @description Title for entry label.
*/
annotationGetStarted: 'Annotate a trace for yourself and others',
/**
* @description Title for entry label.
*/
entryLabelTutorialTitle: 'Label an item',
/**
* @description Text for how to create an entry label.
*/
entryLabelTutorialDescription: 'Double-click or press Enter on an item and type to create an item label.',
/**
* @description Title for diagram.
*/
entryLinkTutorialTitle: 'Connect two items',
/**
* @description Text for how to create a diagram between entries.
*/
entryLinkTutorialDescription:
'Double-click on an item, click on the adjacent rightward arrow, then select the destination item.',
/**
* @description Title for time range.
*/
timeRangeTutorialTitle: 'Define a time range',
/**
* @description Text for how to create a time range selection and add note.
*/
timeRangeTutorialDescription: 'Shift-drag in the flamechart then type to create a time range annotation.',
/**
* @description Title for deleting annotations.
*/
deleteAnnotationTutorialTitle: 'Delete an annotation',
/**
* @description Text for how to access an annotation delete function.
*/
deleteAnnotationTutorialDescription:
'Hover over the list in the sidebar with Annotations tab selected to access the delete function.',
/**
* @description Text used to describe the delete button to screen readers.
* @example {"A paint event annotated with the text hello world"} PH1
**/
deleteButton: 'Delete annotation: {PH1}',
/**
* @description label used to describe an annotation on an entry
* @example {Paint} PH1
* @example {"Hello world"} PH2
*/
entryLabelDescriptionLabel: 'A "{PH1}" event annotated with the text "{PH2}"',
/**
* @description label used to describe a time range annotation
* @example {2.5 milliseconds} PH1
* @example {13.5 milliseconds} PH2
*/
timeRangeDescriptionLabel: 'A time range starting at {PH1} and ending at {PH2}',
/**
* @description label used to describe a link from one entry to another.
* @example {Paint} PH1
* @example {Recalculate styles} PH2
*/
entryLinkDescriptionLabel: 'A link between a "{PH1}" event and a "{PH2}" event',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/SidebarAnnotationsTab.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export interface SidebarAnnotationsTabViewInput {
annotations: readonly Trace.Types.File.Annotation[];
annotationsHiddenSetting: Common.Settings.Setting<boolean>;
annotationEntryToColorMap: ReadonlyMap<Trace.Types.Events.Event|Trace.Types.Events.LegacyTimelineFrame, string>;
onAnnotationClick: (annotation: Trace.Types.File.Annotation) => void;
onAnnotationHover: (annotation: Trace.Types.File.Annotation) => void;
onAnnotationHoverOut: () => void;
onAnnotationDelete: (annotation: Trace.Types.File.Annotation) => void;
}
export class SidebarAnnotationsTab extends UI.Widget.Widget {
#annotations: Trace.Types.File.Annotation[] = [];
// A map with annotated entries and the colours that are used to display them in the FlameChart.
// We need this map to display the entries in the sidebar with the same colours.
#annotationEntryToColorMap = new Map<Trace.Types.Events.Event|Trace.Types.Events.LegacyTimelineFrame, string>();
readonly #annotationsHiddenSetting: Common.Settings.Setting<boolean>;
#view: typeof DEFAULT_VIEW;
constructor(view = DEFAULT_VIEW) {
super();
this.#view = view;
this.#annotationsHiddenSetting = Common.Settings.Settings.instance().moduleSetting('annotations-hidden');
}
deduplicatedAnnotations(): readonly Trace.Types.File.Annotation[] {
return this.#annotations;
}
setData(data: {
annotations: Trace.Types.File.Annotation[],
annotationEntryToColorMap: Map<Trace.Types.Events.Event, string>,
}): void {
this.#annotations = this.#processAnnotationsList(data.annotations);
this.#annotationEntryToColorMap = data.annotationEntryToColorMap;
this.requestUpdate();
}
#processAnnotationsList(annotations: Trace.Types.File.Annotation[]): Trace.Types.File.Annotation[] {
// When an entry is double-clicked, we create two annotations (a label and an entries connection) for the user to choose from.
// The one not selected is deleted when the user makes their selection.
// To avoid excessive activity in the sidebar (adding and removing annotations), only show one 'not started' annotation associated with an entry.
//
// If we encounter an annotation for an entry that hasn't started creation, add that entry to the 'entriesWithNotStartedAnnotation'
// set. This allows us to filter out any subsequent not started annotations for the same entry.
const entriesWithNotStartedAnnotation = new Set();
const processedAnnotations = annotations.filter(annotation => {
if (this.#isAnnotationCreationStarted(annotation)) {
return true;
}
if (annotation.type === 'ENTRIES_LINK' || annotation.type === 'ENTRY_LABEL') {
const annotationEntry = annotation.type === 'ENTRIES_LINK' ? annotation.entryFrom : annotation.entry;
if (entriesWithNotStartedAnnotation.has(annotationEntry)) {
return false;
}
entriesWithNotStartedAnnotation.add(annotationEntry);
}
return true;
});
// Sort annotations by timestamp.
processedAnnotations.sort(
(firstAnnotation, secondAnnotation) =>
this.#getAnnotationTimestamp(firstAnnotation) - this.#getAnnotationTimestamp(secondAnnotation),
);
return processedAnnotations;
}
#getAnnotationTimestamp(annotation: Trace.Types.File.Annotation): Trace.Types.Timing.Micro {
switch (annotation.type) {
case 'ENTRY_LABEL': {
return annotation.entry.ts;
}
case 'ENTRIES_LINK': {
return annotation.entryFrom.ts;
}
case 'TIME_RANGE': {
return annotation.bounds.min;
}
default: {
Platform.assertNever(annotation, `Invalid annotation type ${annotation}`);
}
}
}
#isAnnotationCreationStarted(annotation: Trace.Types.File.Annotation): boolean {
// Consider the annotation not started if:
// ENTRY_LABEL - label is empty
// ENTRIES_LINK - the connection annotation does not have the 'to' entry
// TIME_RANGE - range is over zero
switch (annotation.type) {
case 'ENTRY_LABEL': {
return annotation.label.length > 0;
}
case 'ENTRIES_LINK': {
return Boolean(annotation.entryTo);
}
case 'TIME_RANGE': {
return annotation.bounds.range > 0;
}
}
}
override performUpdate(): void {
const input: SidebarAnnotationsTabViewInput = {
annotations: this.#annotations,
annotationsHiddenSetting: this.#annotationsHiddenSetting,
annotationEntryToColorMap: this.#annotationEntryToColorMap,
onAnnotationClick: (annotation: Trace.Types.File.Annotation) => {
this.contentElement.dispatchEvent(new RevealAnnotation(annotation));
},
onAnnotationHover: (annotation: Trace.Types.File.Annotation) => {
this.contentElement.dispatchEvent(new HoverAnnotation(annotation));
},
onAnnotationHoverOut: () => {
this.contentElement.dispatchEvent(new AnnotationHoverOut());
},
onAnnotationDelete: (annotation: Trace.Types.File.Annotation) => {
this.contentElement.dispatchEvent(new RemoveAnnotation(annotation));
},
};
this.#view(input, {}, this.contentElement);
}
}
function detailedAriaDescriptionForAnnotation(annotation: Trace.Types.File.Annotation): string {
switch (annotation.type) {
case 'ENTRY_LABEL': {
const name = Trace.Name.forEntry(annotation.entry);
return i18nString(UIStrings.entryLabelDescriptionLabel, {
PH1: name,
PH2: annotation.label,
});
}
case 'TIME_RANGE': {
const from = i18n.TimeUtilities.formatMicroSecondsAsMillisFixedExpanded(annotation.bounds.min);
const to = i18n.TimeUtilities.formatMicroSecondsAsMillisFixedExpanded(annotation.bounds.max);
return i18nString(UIStrings.timeRangeDescriptionLabel, {
PH1: from,
PH2: to,
});
}
case 'ENTRIES_LINK': {
if (!annotation.entryTo) {
// Only label it if it is completed.
return '';
}
const nameFrom = Trace.Name.forEntry(annotation.entryFrom);
const nameTo = Trace.Name.forEntry(annotation.entryTo);
return i18nString(UIStrings.entryLinkDescriptionLabel, {
PH1: nameFrom,
PH2: nameTo,
});
}
default:
Platform.assertNever(annotation, 'Unsupported annotation');
}
}
function findTextColorForContrast(bgColorText: string): string {
const bgColor = Common.Color.parse(bgColorText)?.asLegacyColor();
// Let's default to black text, since the entries' titles are black in the flamechart.
const darkColorToken = '--app-color-performance-sidebar-label-text-dark';
const darkColorText =
Common.Color.parse(ThemeSupport.ThemeSupport.instance().getComputedValue(darkColorToken))?.asLegacyColor();
if (!bgColor || !darkColorText) {
// This part of code shouldn't be reachable unless background color is invalid or something wrong with the color
// parsing. If so let's fall back to the dark color,
return `var(${darkColorToken})`;
}
const contrastRatio = Common.ColorUtils.contrastRatio(bgColor.rgba(), darkColorText.rgba());
return contrastRatio >= 4.5 ? `var(${darkColorToken})` : 'var(--app-color-performance-sidebar-label-text-light)';
}
function renderAnnotationIdentifier(
annotation: Trace.Types.File.Annotation,
annotationEntryToColorMap: ReadonlyMap<Trace.Types.Events.Event|Trace.Types.Events.LegacyTimelineFrame, string>):
Lit.LitTemplate {
switch (annotation.type) {
case 'ENTRY_LABEL': {
const entryName = Trace.Name.forEntry(annotation.entry);
const backgroundColor = annotationEntryToColorMap.get(annotation.entry) ?? '';
const color = findTextColorForContrast(backgroundColor);
const styleForAnnotationIdentifier = {
backgroundColor,
color,
};
return html`
<span class="annotation-identifier" style=${Lit.Directives.styleMap(styleForAnnotationIdentifier)}>
${entryName}
</span>
`;
}
case 'TIME_RANGE': {
const minTraceBoundsMilli =
TraceBounds.TraceBounds.BoundsManager.instance().state()?.milli.entireTraceBounds.min ?? 0;
const timeRangeStartInMs =
Math.round(Trace.Helpers.Timing.microToMilli(annotation.bounds.min) - minTraceBoundsMilli);
const timeRangeEndInMs =
Math.round(Trace.Helpers.Timing.microToMilli(annotation.bounds.max) - minTraceBoundsMilli);
return html`
<span class="annotation-identifier time-range">
${timeRangeStartInMs} - ${timeRangeEndInMs} ms
</span>
`;
}
case 'ENTRIES_LINK': {
const entryFromName = Trace.Name.forEntry(annotation.entryFrom);
const fromBackgroundColor = annotationEntryToColorMap.get(annotation.entryFrom) ?? '';
const fromTextColor = findTextColorForContrast(fromBackgroundColor);
const styleForFromAnnotationIdentifier = {
backgroundColor: fromBackgroundColor,
color: fromTextColor,
};
// clang-format off
return html`
<div class="entries-link">
<span class="annotation-identifier" style=${Lit.Directives.styleMap(styleForFromAnnotationIdentifier)}>
${entryFromName}
</span>
<devtools-icon name="arrow-forward" class="inline-icon large">
</devtools-icon>
${renderEntryToIdentifier(annotation, annotationEntryToColorMap)}
</div>
`;
// clang-format on
}
default:
Platform.assertNever(annotation, 'Unsupported annotation type');
}
}
/**
* Renders the Annotation 'identifier' or 'name' in the annotations list.
* This is the text rendered before the annotation label that we use to identify the annotation.
*
* Annotations identifiers for different annotations:
* Entry label -> Entry name
* Labelled range -> Start to End Range of the label in ms
* Connected entries -> Connected entries names
*
* All identifiers have a different colour background.
*/
function renderEntryToIdentifier(
annotation: Trace.Types.File.EntriesLinkAnnotation,
annotationEntryToColorMap: ReadonlyMap<Trace.Types.Events.Event|Trace.Types.Events.LegacyTimelineFrame, string>):
Lit.LitTemplate {
if (annotation.entryTo) {
const entryToName = Trace.Name.forEntry(annotation.entryTo);
const toBackgroundColor = annotationEntryToColorMap.get(annotation.entryTo) ?? '';
const toTextColor = findTextColorForContrast(toBackgroundColor);
const styleForToAnnotationIdentifier = {
backgroundColor: toBackgroundColor,
color: toTextColor,
};
// clang-format off
return html`
<span class="annotation-identifier" style=${Lit.Directives.styleMap(styleForToAnnotationIdentifier)}>
${entryToName}
</span>`;
// clang-format on
}
return Lit.nothing;
}
function jslogForAnnotation(annotation: Trace.Types.File.Annotation): string {
switch (annotation.type) {
case 'ENTRY_LABEL':
return 'entry-label';
case 'TIME_RANGE':
return 'time-range';
case 'ENTRIES_LINK':
return 'entries-link';
default:
Platform.assertNever(annotation, 'unknown annotation type');
}
}
function renderTutorial(): Lit.LitTemplate {
// clang-format off
return html`<div class="annotation-tutorial-container">
${i18nString(UIStrings.annotationGetStarted)}
<div class="tutorial-card">
<div class="tutorial-image"><img src=${entryLabelImageUrl}></img></div>
<div class="tutorial-title">${i18nString(UIStrings.entryLabelTutorialTitle)}</div>
<div class="tutorial-description">${i18nString(UIStrings.entryLabelTutorialDescription)}</div>
</div>
<div class="tutorial-card">
<div class="tutorial-image"><img src=${diagramImageUrl}></img></div>
<div class="tutorial-title">${i18nString(UIStrings.entryLinkTutorialTitle)}</div>
<div class="tutorial-description">${i18nString(UIStrings.entryLinkTutorialDescription)}</div>
</div>
<div class="tutorial-card">
<div class="tutorial-image"><img src=${timeRangeImageUrl}></img></div>
<div class="tutorial-title">${i18nString(UIStrings.timeRangeTutorialTitle)}</div>
<div class="tutorial-description">${i18nString(UIStrings.timeRangeTutorialDescription)}</div>
</div>
<div class="tutorial-card">
<div class="tutorial-image"><img src=${deleteAnnotationImageUrl}></img></div>
<div class="tutorial-title">${i18nString(UIStrings.deleteAnnotationTutorialTitle)}</div>
<div class="tutorial-description">${i18nString(UIStrings.deleteAnnotationTutorialDescription)}</div>
</div>
</div>` ;
// clang-format on
}
export const DEFAULT_VIEW: (input: SidebarAnnotationsTabViewInput, output: object, target: HTMLElement) => void =
(input, _output, target) => {
// clang-format off
render(html`
<style>${sidebarAnnotationsTabStyles}</style>
<span class="annotations">
${input.annotations.length === 0 ? renderTutorial():
html`
${input.annotations.map(annotation => {
const label = detailedAriaDescriptionForAnnotation(annotation);
return html`
<div class="annotation-container"
@click=${() => input.onAnnotationClick(annotation)}
@mouseover=${() => (annotation.type === 'ENTRY_LABEL') ? input.onAnnotationHover(annotation): null}
@mouseout=${() => (annotation.type === 'ENTRY_LABEL') ? input.onAnnotationHoverOut() : null}
aria-label=${label}
tabindex="0"
jslog=${VisualLogging.item(`timeline.annotation-sidebar.annotation-${jslogForAnnotation(annotation)}`).track({click: true})}
>
<div class="annotation">
${renderAnnotationIdentifier(annotation, input.annotationEntryToColorMap)}
<span class="label">
${(annotation.type === 'ENTRY_LABEL' || annotation.type === 'TIME_RANGE') ? annotation.label : ''}
</span>
</div>
<button class="delete-button" aria-label=${i18nString(UIStrings.deleteButton, {PH1: label})} @click=${(event: Event) => {
// Stop propagation to not zoom into the annotation when
// the delete button is clicked
event.stopPropagation();
input.onAnnotationDelete(annotation);
}} jslog=${VisualLogging.action('timeline.annotation-sidebar.delete').track({click: true})}>
<devtools-icon class="bin-icon extra-large" name="bin"></devtools-icon>
</button>
</div>`;
})}
<setting-checkbox class="visibility-setting" .data=${{
setting: input.annotationsHiddenSetting,
textOverride: 'Hide annotations',
}}>
</setting-checkbox>`
}
</span>`,
target);
};