UNPKG

chrome-devtools-frontend

Version:
1,316 lines (1,222 loc) 44.3 kB
// Copyright 2023 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-lit-render-outside-of-view */ import '../../../ui/components/icon_button/icon_button.js'; import './ExtensionView.js'; import './ControlButton.js'; import './ReplaySection.js'; import * as Host from '../../../core/host/host.js'; import * as i18n from '../../../core/i18n/i18n.js'; import * as Platform from '../../../core/platform/platform.js'; import * as SDK from '../../../core/sdk/sdk.js'; import type * as PublicExtensions from '../../../models/extensions/extensions.js'; import * as CodeMirror from '../../../third_party/codemirror.next/codemirror.next.js'; import type * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import * as CodeHighlighter from '../../../ui/components/code_highlighter/code_highlighter.js'; import * as Dialogs from '../../../ui/components/dialogs/dialogs.js'; import * as Input from '../../../ui/components/input/input.js'; import type * as Menus from '../../../ui/components/menus/menus.js'; import * as TextEditor from '../../../ui/components/text_editor/text_editor.js'; import * as UI from '../../../ui/legacy/legacy.js'; import * as Lit from '../../../ui/lit/lit.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import type * as Converters from '../converters/converters.js'; import type * as Extensions from '../extensions/extensions.js'; import * as Models from '../models/models.js'; import {PlayRecordingSpeed} from '../models/RecordingPlayer.js'; import * as Actions from '../recorder-actions/recorder-actions.js'; import recordingViewStyles from './recordingView.css.js'; import type {ReplaySectionData, StartReplayEvent} from './ReplaySection.js'; import { type CopyStepEvent, State, type StepView, type StepViewData, } from './StepView.js'; const {html} = Lit; const UIStrings = { /** * @description Depicts that the recording was done on a mobile device (e.g., a smartphone or tablet). */ mobile: 'Mobile', /** * @description Depicts that the recording was done on a desktop device (e.g., on a PC or laptop). */ desktop: 'Desktop', /** * @description Network latency in milliseconds. * @example {10} value */ latency: 'Latency: {value} ms', /** * @description Upload speed. * @example {42 kB} value */ upload: 'Upload: {value}', /** * @description Download speed. * @example {8 kB} value */ download: 'Download: {value}', /** * @description Title of the button to edit replay settings. */ editReplaySettings: 'Edit replay settings', /** * @description Title of the section that contains replay settings. */ replaySettings: 'Replay settings', /** * @description The string is shown when a default value is used for some replay settings. */ default: 'Default', /** * @description The title of the section with environment settings. */ environment: 'Environment', /** * @description The title of the screenshot image that is shown for every section in the recordign view. */ screenshotForSection: 'Screenshot for this section', /** * @description The title of the button that edits the current recording's title. */ editTitle: 'Edit title', /** * @description The error for when the title is missing. */ requiredTitleError: 'Title is required', /** * @description The status text that is shown while the recording is ongoing. */ recording: 'Recording…', /** * @description The title of the button to end the current recording. */ endRecording: 'End recording', /** * @description The title of the button while the recording is being ended. */ recordingIsBeingStopped: 'Stopping recording…', /** * @description The text that describes a timeout setting of {value} milliseconds. * @example {1000} value */ timeout: 'Timeout: {value} ms', /** * @description The label for the input that allows entering network throttling configuration. */ network: 'Network', /** * @description The label for the input that allows entering timeout (a number in ms) configuration. */ timeoutLabel: 'Timeout', /** * @description The text in a tooltip for the timeout input that explains what timeout settings do. */ timeoutExplanation: 'The timeout setting (in milliseconds) applies to every action when replaying the recording. For example, if a DOM element identified by a CSS selector does not appear on the page within the specified timeout, the replay fails with an error.', /** * @description The label for the button that cancels replaying. */ cancelReplay: 'Cancel replay', /** * @description Button title that shows the code view when clicked. */ showCode: 'Show code', /** * @description Button title that hides the code view when clicked. */ hideCode: 'Hide code', /** * @description Button title that adds an assertion to the step editor. */ addAssertion: 'Add assertion', /** * @description The title of the button that open current recording in Performance panel. */ performancePanel: 'Performance panel', } as const; const str_ = i18n.i18n.registerUIStrings( 'panels/recorder/components/RecordingView.ts', UIStrings, ); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); declare global { interface HTMLElementTagNameMap { 'devtools-recording-view': RecordingView; } } export interface ReplayState { isPlaying: boolean; // Replay is in progress isPausedOnBreakpoint: boolean; // Replay is in progress and is in stopped state } export interface RecordingViewData { replayState: ReplayState; isRecording: boolean; recordingTogglingInProgress: boolean; recording: Models.Schema.UserFlow; currentStep?: Models.Schema.Step; currentError?: Error; sections: Models.Section.Section[]; settings?: Models.RecordingSettings.RecordingSettings; recorderSettings?: Models.RecorderSettings.RecorderSettings; lastReplayResult?: Models.RecordingPlayer.ReplayResult; replayAllowed: boolean; breakpointIndexes: Set<number>; builtInConverters: Converters.Converter.Converter[]; extensionConverters: Converters.Converter.Converter[]; replayExtensions: Extensions.ExtensionManager.Extension[]; extensionDescriptor?: PublicExtensions.RecorderPluginManager.ViewDescriptor; } export class RecordingFinishedEvent extends Event { static readonly eventName = 'recordingfinished'; constructor() { super(RecordingFinishedEvent.eventName); } } export const enum TargetPanel { PERFORMANCE_PANEL = 'timeline', DEFAULT = 'chrome-recorder', } interface PlayRecordingEventData { targetPanel: TargetPanel; speed: PlayRecordingSpeed; extension?: Extensions.ExtensionManager.Extension; } export class PlayRecordingEvent extends Event { static readonly eventName = 'playrecording'; readonly data: PlayRecordingEventData; constructor( data: PlayRecordingEventData = { targetPanel: TargetPanel.DEFAULT, speed: PlayRecordingSpeed.NORMAL, }, ) { super(PlayRecordingEvent.eventName); this.data = data; } } export class AbortReplayEvent extends Event { static readonly eventName = 'abortreplay'; constructor() { super(AbortReplayEvent.eventName); } } export class RecordingChangedEvent extends Event { static readonly eventName = 'recordingchanged'; data: {currentStep: Models.Schema.Step, newStep: Models.Schema.Step}; constructor(currentStep: Models.Schema.Step, newStep: Models.Schema.Step) { super(RecordingChangedEvent.eventName); this.data = {currentStep, newStep}; } } export class AddAssertionEvent extends Event { static readonly eventName = 'addassertion'; constructor() { super(AddAssertionEvent.eventName); } } export class RecordingTitleChangedEvent extends Event { static readonly eventName = 'recordingtitlechanged'; title: string; constructor(title: string) { super(RecordingTitleChangedEvent.eventName, {}); this.title = title; } } export class NetworkConditionsChanged extends Event { static readonly eventName = 'networkconditionschanged'; data?: SDK.NetworkManager.Conditions; constructor(data?: SDK.NetworkManager.Conditions) { super(NetworkConditionsChanged.eventName, { composed: true, bubbles: true, }); this.data = data; } } export class TimeoutChanged extends Event { static readonly eventName = 'timeoutchanged'; data?: number; constructor(data?: number) { super(TimeoutChanged.eventName, {composed: true, bubbles: true}); this.data = data; } } const networkConditionPresets = [ SDK.NetworkManager.NoThrottlingConditions, SDK.NetworkManager.OfflineConditions, SDK.NetworkManager.Slow3GConditions, SDK.NetworkManager.Slow4GConditions, SDK.NetworkManager.Fast4GConditions, ]; function converterIdToFlowMetric( converterId: string, ): Host.UserMetrics.RecordingCopiedToClipboard { switch (converterId) { case Models.ConverterIds.ConverterIds.PUPPETEER: case Models.ConverterIds.ConverterIds.PUPPETEER_FIREFOX: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_RECORDING_WITH_PUPPETEER; case Models.ConverterIds.ConverterIds.JSON: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_RECORDING_WITH_JSON; case Models.ConverterIds.ConverterIds.REPLAY: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_RECORDING_WITH_REPLAY; default: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_RECORDING_WITH_EXTENSION; } } function converterIdToStepMetric( converterId: string, ): Host.UserMetrics.RecordingCopiedToClipboard { switch (converterId) { case Models.ConverterIds.ConverterIds.PUPPETEER: case Models.ConverterIds.ConverterIds.PUPPETEER_FIREFOX: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_STEP_WITH_PUPPETEER; case Models.ConverterIds.ConverterIds.JSON: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_STEP_WITH_JSON; case Models.ConverterIds.ConverterIds.REPLAY: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_STEP_WITH_REPLAY; default: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_STEP_WITH_EXTENSION; } } export class RecordingView extends HTMLElement { readonly #shadow = this.attachShadow({mode: 'open'}); #replayState: ReplayState = {isPlaying: false, isPausedOnBreakpoint: false}; #userFlow: Models.Schema.UserFlow|null = null; #isRecording = false; #recordingTogglingInProgress = false; #isTitleInvalid = false; #currentStep?: Models.Schema.Step; #steps: Models.Schema.Step[] = []; #currentError?: Error; #sections: Models.Section.Section[] = []; #settings?: Models.RecordingSettings.RecordingSettings; #recorderSettings?: Models.RecorderSettings.RecorderSettings; #lastReplayResult?: Models.RecordingPlayer.ReplayResult; #breakpointIndexes = new Set<number>(); #selectedStep?: Models.Schema.Step|null; #replaySettingsExpanded = false; #replayAllowed = true; #builtInConverters: Converters.Converter.Converter[] = []; #extensionConverters: Converters.Converter.Converter[] = []; #replayExtensions?: Extensions.ExtensionManager.Extension[]; #showCodeView = false; #code = ''; #converterId = ''; #editorState?: CodeMirror.EditorState; #sourceMap: PuppeteerReplay.SourceMap|undefined; #extensionDescriptor?: PublicExtensions.RecorderPluginManager.ViewDescriptor; #onCopyBound = this.#onCopy.bind(this); set data(data: RecordingViewData) { this.#isRecording = data.isRecording; this.#replayState = data.replayState; this.#recordingTogglingInProgress = data.recordingTogglingInProgress; this.#currentStep = data.currentStep; this.#userFlow = data.recording; this.#steps = this.#userFlow.steps; this.#sections = data.sections; this.#settings = data.settings; this.#recorderSettings = data.recorderSettings; this.#currentError = data.currentError; this.#lastReplayResult = data.lastReplayResult; this.#replayAllowed = data.replayAllowed; this.#isTitleInvalid = false; this.#breakpointIndexes = data.breakpointIndexes; this.#builtInConverters = data.builtInConverters; this.#extensionConverters = data.extensionConverters; this.#replayExtensions = data.replayExtensions; this.#extensionDescriptor = data.extensionDescriptor; this.#converterId = this.#recorderSettings?.preferredCopyFormat ?? data.builtInConverters[0]?.getId(); void this.#convertToCode(); this.#render(); } connectedCallback(): void { document.addEventListener('copy', this.#onCopyBound); this.#render(); } disconnectedCallback(): void { document.removeEventListener('copy', this.#onCopyBound); } scrollToBottom(): void { const wrapper = this.shadowRoot?.querySelector('.sections'); if (!wrapper) { return; } wrapper.scrollTop = wrapper.scrollHeight; } #dispatchAddAssertionEvent(): void { this.dispatchEvent(new AddAssertionEvent()); } #dispatchRecordingFinished(): void { this.dispatchEvent(new RecordingFinishedEvent()); } #handleAbortReplay(): void { this.dispatchEvent(new AbortReplayEvent()); } #handleTogglePlaying(event: StartReplayEvent): void { this.dispatchEvent( new PlayRecordingEvent({ targetPanel: TargetPanel.DEFAULT, speed: event.speed, extension: event.extension, }), ); } #getStepState(step: Models.Schema.Step): State { if (!this.#currentStep) { return State.DEFAULT; } if (step === this.#currentStep) { if (this.#currentError) { return State.ERROR; } if (!this.#replayState.isPlaying) { return State.SUCCESS; } if (this.#replayState.isPausedOnBreakpoint) { return State.STOPPED; } return State.CURRENT; } const currentIndex = this.#steps.indexOf(this.#currentStep); if (currentIndex === -1) { return State.DEFAULT; } const index = this.#steps.indexOf(step); return index < currentIndex ? State.SUCCESS : State.OUTSTANDING; } #getSectionState(section: Models.Section.Section): State { const currentStep = this.#currentStep; if (!currentStep) { return State.DEFAULT; } const currentSection = this.#sections.find( section => section.steps.includes(currentStep), ) as Models.Section.Section; if (!currentSection) { if (this.#currentError) { return State.ERROR; } } if (section === currentSection) { return State.SUCCESS; } const index = this.#sections.indexOf(currentSection); const ownIndex = this.#sections.indexOf(section); return index >= ownIndex ? State.SUCCESS : State.OUTSTANDING; } #renderStep( section: Models.Section.Section, step: Models.Schema.Step, isLastSection: boolean, ): Lit.TemplateResult { const stepIndex = this.#steps.indexOf(step); // clang-format off return html` <devtools-step-view @click=${this.#onStepClick} @mouseover=${this.#onStepHover} @copystep=${this.#onCopyStepEvent} .data=${ { step, state: this.#getStepState(step), error: this.#currentStep === step ? this.#currentError : undefined, isFirstSection: false, isLastSection: isLastSection && this.#steps[this.#steps.length - 1] === step, isStartOfGroup: false, isEndOfGroup: section.steps[section.steps.length - 1] === step, stepIndex, hasBreakpoint: this.#breakpointIndexes.has(stepIndex), sectionIndex: -1, isRecording: this.#isRecording, isPlaying: this.#replayState.isPlaying, removable: this.#steps.length > 1, builtInConverters: this.#builtInConverters, extensionConverters: this.#extensionConverters, isSelected: this.#selectedStep === step, recorderSettings: this.#recorderSettings, } as StepViewData } jslog=${VisualLogging.section('step').track({click: true})} ></devtools-step-view> `; // clang-format on } #onStepHover = (event: MouseEvent): void => { const stepView = event.target as StepView; const step = stepView.step || stepView.section?.causingStep; if (!step || this.#selectedStep) { return; } this.#highlightCodeForStep(step); }; #onStepClick(event: Event): void { event.stopPropagation(); const stepView = event.target as StepView; const selectedStep = stepView.step || stepView.section?.causingStep || null; if (this.#selectedStep === selectedStep) { return; } this.#selectedStep = selectedStep; this.#render(); if (selectedStep) { this.#highlightCodeForStep(selectedStep, /* scroll=*/ true); } } #onWrapperClick(): void { if (this.#selectedStep === undefined) { return; } this.#selectedStep = undefined; this.#render(); } #onReplaySettingsKeydown(event: Event): void { if ((event as KeyboardEvent).key !== 'Enter') { return; } event.preventDefault(); this.#onToggleReplaySettings(event); } #onToggleReplaySettings(event: Event): void { event.stopPropagation(); this.#replaySettingsExpanded = !this.#replaySettingsExpanded; this.#render(); } #onNetworkConditionsChange(event: Event): void { const throttlingMenu = event.target; if (throttlingMenu instanceof HTMLSelectElement) { const preset = networkConditionPresets.find( preset => preset.i18nTitleKey === throttlingMenu.value, ); this.dispatchEvent( new NetworkConditionsChanged( preset?.i18nTitleKey === SDK.NetworkManager.NoThrottlingConditions.i18nTitleKey ? undefined : preset, ), ); } } #onTimeoutInput(event: Event): void { const target = event.target as HTMLInputElement; if (!target.checkValidity()) { target.reportValidity(); return; } this.dispatchEvent(new TimeoutChanged(Number(target.value))); } #onTitleBlur = (event: Event): void => { const target = event.target as HTMLInputElement; const title = target.value.trim(); if (!title) { this.#isTitleInvalid = true; this.#render(); return; } this.dispatchEvent(new RecordingTitleChangedEvent(title)); }; #onTitleInputKeyDown = (event: KeyboardEvent): void => { switch (event.code) { case 'Escape': case 'Enter': (event.target as HTMLElement).blur(); event.stopPropagation(); break; } }; #onEditTitleButtonClick = (): void => { const input = this.#shadow.getElementById('title-input') as HTMLInputElement; input.focus(); }; #onSelectMenuLabelClick = (event: Event): void => { const target = event.target as HTMLElement; if (target.matches('.wrapping-label')) { target.querySelector('devtools-select-menu')?.click(); } }; async #copyCurrentSelection(step?: Models.Schema.Step|null): Promise<void> { let converter = [ ...this.#builtInConverters, ...this.#extensionConverters, ] .find( converter => converter.getId() === this.#recorderSettings?.preferredCopyFormat, ); if (!converter) { converter = this.#builtInConverters[0]; } if (!converter) { throw new Error('No default converter found'); } let text = ''; if (step) { text = await converter.stringifyStep(step); } else if (this.#userFlow) { [text] = await converter.stringify(this.#userFlow); } Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(text); const metric = step ? converterIdToStepMetric(converter.getId()) : converterIdToFlowMetric(converter.getId()); Host.userMetrics.recordingCopiedToClipboard(metric); } #onCopyStepEvent(event: CopyStepEvent): void { event.stopPropagation(); void this.#copyCurrentSelection(event.step); } async #onCopy(event: ClipboardEvent): Promise<void> { if (event.target !== document.body) { return; } event.preventDefault(); await this.#copyCurrentSelection(this.#selectedStep); Host.userMetrics.keyboardShortcutFired(Actions.RecorderActions.COPY_RECORDING_OR_STEP); } #renderSettings(): Lit.TemplateResult { if (!this.#settings) { return html``; } const environmentFragments = []; if (this.#settings.viewportSettings) { // clang-format off environmentFragments.push( html`<div>${ this.#settings.viewportSettings.isMobile ? i18nString(UIStrings.mobile) : i18nString(UIStrings.desktop) }</div>`, ); environmentFragments.push(html`<div class="separator"></div>`); environmentFragments.push( html`<div>${this.#settings.viewportSettings.width}×${ this.#settings.viewportSettings.height } px</div>`, ); // clang-format on } const replaySettingsFragments = []; if (!this.#replaySettingsExpanded) { if (this.#settings.networkConditionsSettings) { if (this.#settings.networkConditionsSettings.title) { // clang-format off replaySettingsFragments.push( html`<div>${ this.#settings.networkConditionsSettings.title }</div>`, ); // clang-format on } else { // clang-format off replaySettingsFragments.push(html`<div> ${i18nString(UIStrings.download, { value: i18n.ByteUtilities.bytesToString( this.#settings.networkConditionsSettings.download, ), })}, ${i18nString(UIStrings.upload, { value: i18n.ByteUtilities.bytesToString( this.#settings.networkConditionsSettings.upload, ), })}, ${i18nString(UIStrings.latency, { value: this.#settings.networkConditionsSettings.latency, })} </div>`); // clang-format on } } else { // clang-format off replaySettingsFragments.push( html`<div>${ SDK.NetworkManager.NoThrottlingConditions.title instanceof Function ? SDK.NetworkManager.NoThrottlingConditions.title() : SDK.NetworkManager.NoThrottlingConditions.title }</div>`, ); // clang-format on } // clang-format off replaySettingsFragments.push(html`<div class="separator"></div>`); replaySettingsFragments.push( html`<div>${i18nString(UIStrings.timeout, { value: this.#settings.timeout || Models.RecordingPlayer.defaultTimeout, })}</div>`, ); // clang-format on } else { // clang-format off const selectedOption = this.#settings.networkConditionsSettings?.i18nTitleKey || SDK.NetworkManager.NoThrottlingConditions.i18nTitleKey; const selectedOptionTitle = networkConditionPresets.find( preset => preset.i18nTitleKey === selectedOption, ); let menuButtonTitle = ''; if (selectedOptionTitle) { menuButtonTitle = selectedOptionTitle.title instanceof Function ? selectedOptionTitle.title() : selectedOptionTitle.title; } replaySettingsFragments.push(html`<div class="editable-setting"> <label class="wrapping-label" @click=${this.#onSelectMenuLabelClick}> ${i18nString(UIStrings.network)} <select title=${menuButtonTitle} jslog=${VisualLogging.dropDown('network-conditions').track({change: true})} @change=${this.#onNetworkConditionsChange}> ${networkConditionPresets.map(condition => html` <option jslog=${VisualLogging.item(Platform.StringUtilities.toKebabCase(condition.i18nTitleKey || ''))} value=${condition.i18nTitleKey || ''} ?selected=${selectedOption === condition.i18nTitleKey}> ${ condition.title instanceof Function ? condition.title() : condition.title } </option>`)} </select> </label> </div>`); replaySettingsFragments.push(html`<div class="editable-setting"> <label class="wrapping-label" title=${i18nString( UIStrings.timeoutExplanation, )}> ${i18nString(UIStrings.timeoutLabel)} <input @input=${this.#onTimeoutInput} required min=${Models.SchemaUtils.minTimeout} max=${Models.SchemaUtils.maxTimeout} value=${ this.#settings.timeout || Models.RecordingPlayer.defaultTimeout } jslog=${VisualLogging.textField('timeout').track({change: true})} class="devtools-text-input" type="number"> </label> </div>`); // clang-format on } const isEditable = !this.#isRecording && !this.#replayState.isPlaying; const replaySettingsButtonClassMap = { 'settings-title': true, expanded: this.#replaySettingsExpanded, }; const replaySettingsClassMap = { expanded: this.#replaySettingsExpanded, settings: true, }; // clang-format off return html` <div class="settings-row"> <div class="settings-container"> <div class=${Lit.Directives.classMap(replaySettingsButtonClassMap)} @keydown=${isEditable && this.#onReplaySettingsKeydown} @click=${isEditable && this.#onToggleReplaySettings} tabindex="0" role="button" jslog=${VisualLogging.action('replay-settings').track({click: true})} aria-label=${i18nString(UIStrings.editReplaySettings)}> <span>${i18nString(UIStrings.replaySettings)}</span> ${ isEditable ? html`<devtools-icon class="chevron" name="triangle-down"> </devtools-icon>` : '' } </div> <div class=${Lit.Directives.classMap(replaySettingsClassMap)}> ${ replaySettingsFragments.length ? replaySettingsFragments : html`<div>${i18nString(UIStrings.default)}</div>` } </div> </div> <div class="settings-container"> <div class="settings-title">${i18nString(UIStrings.environment)}</div> <div class="settings"> ${ environmentFragments.length ? environmentFragments : html`<div>${i18nString(UIStrings.default)}</div>` } </div> </div> </div> `; // clang-format on } #getCurrentConverter(): Converters.Converter.Converter|undefined { const currentConverter = [ ...(this.#builtInConverters || []), ...(this.#extensionConverters || []), ].find(converter => converter.getId() === this.#converterId); if (!currentConverter) { return this.#builtInConverters[0]; } return currentConverter; } #renderTimelineArea(): Lit.LitTemplate { if (this.#extensionDescriptor) { // clang-format off return html` <devtools-recorder-extension-view .descriptor=${this.#extensionDescriptor}> </devtools-recorder-extension-view> `; // clang-format on } const currentConverter = this.#getCurrentConverter(); const converterFormatName = currentConverter?.getFormatName(); // clang-format off /* eslint-disable rulesdir/no-deprecated-component-usages */ return html` <devtools-split-view direction="auto" sidebar-position="second" sidebar-initial-size="300" sidebar-visibility=${this.#showCodeView ? '' : 'hidden'} > <div slot="main"> ${this.#renderSections()} </div> <div slot="sidebar" jslog=${VisualLogging.pane('source-code').track({resize: true})}> ${this.#showCodeView ? html` <div class="section-toolbar" jslog=${VisualLogging.toolbar()}> <devtools-select-menu @selectmenuselected=${this.#onCodeFormatChange} .showDivider=${true} .showArrow=${true} .sideButton=${false} .showSelectedItem=${true} .position=${Dialogs.Dialog.DialogVerticalPosition.BOTTOM} .buttonTitle=${converterFormatName || ''} .jslogContext=${'code-format'} > ${this.#builtInConverters.map(converter => { return html`<devtools-menu-item .value=${converter.getId()} .selected=${this.#converterId === converter.getId()} jslog=${VisualLogging.action().track({click: true}).context(`converter-${Platform.StringUtilities.toKebabCase(converter.getId())}`)} > ${converter.getFormatName()} </devtools-menu-item>`; })} ${this.#extensionConverters.map(converter => { return html`<devtools-menu-item .value=${converter.getId()} .selected=${this.#converterId === converter.getId()} jslog=${VisualLogging.action().track({click: true}).context('converter-extension')} > ${converter.getFormatName()} </devtools-menu-item>`; })} </devtools-select-menu> <devtools-button title=${Models.Tooltip.getTooltipForActions( i18nString(UIStrings.hideCode), Actions.RecorderActions.TOGGLE_CODE_VIEW, )} .data=${ { variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'cross', } as Buttons.Button.ButtonData } @click=${this.showCodeToggle} jslog=${VisualLogging.close().track({click: true})} ></devtools-button> </div> ${this.#renderTextEditor()}` : Lit.nothing} </div> </devtools-split-view> `; /* eslint-enable rulesdir/no-deprecated-component-usages */ // clang-format on } #renderTextEditor(): Lit.TemplateResult { if (!this.#editorState) { throw new Error('Unexpected: trying to render the text editor without editorState'); } // clang-format off return html` <div class="text-editor" jslog=${VisualLogging.textField().track({change: true})}> <devtools-text-editor .state=${this.#editorState}></devtools-text-editor> </div> `; // clang-format on } #renderScreenshot( section: Models.Section.Section, ): Lit.TemplateResult|null { if (!section.screenshot) { return null; } // clang-format off return html` <img class="screenshot" src=${section.screenshot} alt=${i18nString( UIStrings.screenshotForSection, )} /> `; // clang-format on } #renderReplayOrAbortButton(): Lit.TemplateResult { if (this.#replayState.isPlaying) { return html` <devtools-button .jslogContext=${'abort-replay'} @click=${ this.#handleAbortReplay} .iconName=${'pause'} .variant=${Buttons.Button.Variant.OUTLINED}> ${i18nString(UIStrings.cancelReplay)} </devtools-button>`; } // clang-format off return html`<devtools-replay-section .data=${ { settings: this.#recorderSettings, replayExtensions: this.#replayExtensions, } as ReplaySectionData } .disabled=${this.#replayState.isPlaying} @startreplay=${this.#handleTogglePlaying} > </devtools-replay-section>`; // clang-format on } #handleMeasurePerformanceClickEvent(event: Event): void { event.stopPropagation(); this.dispatchEvent( new PlayRecordingEvent({ targetPanel: TargetPanel.PERFORMANCE_PANEL, speed: PlayRecordingSpeed.NORMAL, }), ); } showCodeToggle = (): void => { this.#showCodeView = !this.#showCodeView; Host.userMetrics.recordingCodeToggled( this.#showCodeView ? Host.UserMetrics.RecordingCodeToggled.CODE_SHOWN : Host.UserMetrics.RecordingCodeToggled.CODE_HIDDEN, ); void this.#convertToCode(); }; #convertToCode = async(): Promise<void> => { if (!this.#userFlow) { return; } const converter = this.#getCurrentConverter(); if (!converter) { return; } const [code, sourceMap] = await converter.stringify(this.#userFlow); this.#code = code; this.#sourceMap = sourceMap; this.#sourceMap?.shift(); const mediaType = converter.getMediaType(); const languageSupport = mediaType ? await CodeHighlighter.CodeHighlighter.languageFromMIME(mediaType) : null; this.#editorState = CodeMirror.EditorState.create({ doc: this.#code, extensions: [ TextEditor.Config.baseConfiguration(this.#code), CodeMirror.EditorState.readOnly.of(true), CodeMirror.EditorView.lineWrapping, languageSupport ? languageSupport : [], ], }); this.#render(); // Used by tests. this.dispatchEvent(new Event('code-generated')); }; #highlightCodeForStep = (step: Models.Schema.Step, scroll = false): void => { if (!this.#sourceMap) { return; } const stepIndex = this.#steps.indexOf(step); if (stepIndex === -1) { return; } const editor = this.#shadow.querySelector('devtools-text-editor') as | TextEditor.TextEditor.TextEditor | undefined; if (!editor) { return; } const cm = editor.editor; if (!cm) { return; } const line = this.#sourceMap[stepIndex * 2]; const length = this.#sourceMap[stepIndex * 2 + 1]; let selection = editor.createSelection( {lineNumber: line + length, columnNumber: 0}, {lineNumber: line, columnNumber: 0}, ); const lastLine = editor.state.doc.lineAt(selection.main.anchor); selection = editor.createSelection( {lineNumber: line + length - 1, columnNumber: lastLine.length + 1}, {lineNumber: line, columnNumber: 0}, ); cm.dispatch({ selection, effects: scroll ? [ CodeMirror.EditorView.scrollIntoView(selection.main, { y: 'nearest', }), ] : undefined, }); }; #onCodeFormatChange = (event: Menus.SelectMenu.SelectMenuItemSelectedEvent): void => { this.#converterId = event.itemValue as string; if (this.#recorderSettings) { this.#recorderSettings.preferredCopyFormat = event.itemValue as string; } void this.#convertToCode(); }; #renderSections(): Lit.LitTemplate { // clang-format off return html` <div class="sections"> ${ !this.#showCodeView ? html`<div class="section-toolbar"> <devtools-button @click=${this.showCodeToggle} class="show-code" .data=${ { variant: Buttons.Button.Variant.OUTLINED, title: Models.Tooltip.getTooltipForActions( i18nString(UIStrings.showCode), Actions.RecorderActions.TOGGLE_CODE_VIEW, ), } as Buttons.Button.ButtonData } jslog=${VisualLogging.toggleSubpane(Actions.RecorderActions.TOGGLE_CODE_VIEW).track({click: true})} > ${i18nString(UIStrings.showCode)} </devtools-button> </div>` : '' } ${this.#sections.map( (section, i) => html` <div class="section"> <div class="screenshot-wrapper"> ${this.#renderScreenshot(section)} </div> <div class="content"> <div class="steps"> <devtools-step-view @click=${this.#onStepClick} @mouseover=${this.#onStepHover} .data=${ { section, state: this.#getSectionState(section), isStartOfGroup: true, isEndOfGroup: section.steps.length === 0, isFirstSection: i === 0, isLastSection: i === this.#sections.length - 1 && section.steps.length === 0, isSelected: this.#selectedStep === (section.causingStep || null), sectionIndex: i, isRecording: this.#isRecording, isPlaying: this.#replayState.isPlaying, error: this.#getSectionState(section) === State.ERROR ? this.#currentError : undefined, hasBreakpoint: false, removable: this.#steps.length > 1 && section.causingStep, } as StepViewData } > </devtools-step-view> ${section.steps.map(step => this.#renderStep( section, step, i === this.#sections.length - 1, ), )} ${!this.#recordingTogglingInProgress && this.#isRecording && i === this.#sections.length - 1 ? html`<devtools-button class="step add-assertion-button" .data=${ { variant: Buttons.Button.Variant.OUTLINED, title: i18nString(UIStrings.addAssertion), jslogContext: 'add-assertion', } as Buttons.Button.ButtonData } @click=${this.#dispatchAddAssertionEvent} >${i18nString(UIStrings.addAssertion)}</devtools-button>` : undefined} ${ this.#isRecording && i === this.#sections.length - 1 ? html`<div class="step recording">${i18nString( UIStrings.recording, )}</div>` : null } </div> </div> </div> `, )} </div> `; // clang-format on } #renderHeader(): Lit.LitTemplate|string { if (!this.#userFlow) { return ''; } const {title} = this.#userFlow; const isTitleEditable = !this.#replayState.isPlaying && !this.#isRecording; // clang-format off return html` <div class="header"> <div class="header-title-wrapper"> <div class="header-title"> <input @blur=${this.#onTitleBlur} @keydown=${this.#onTitleInputKeyDown} id="title-input" jslog=${VisualLogging.value('title').track({change: true})} class=${Lit.Directives.classMap({ 'has-error': this.#isTitleInvalid, disabled: !isTitleEditable, })} .value=${Lit.Directives.live(title)} .disabled=${!isTitleEditable} > <div class="title-button-bar"> <devtools-button @click=${this.#onEditTitleButtonClick} .data=${ { disabled: !isTitleEditable, variant: Buttons.Button.Variant.TOOLBAR, iconName: 'edit', title: i18nString(UIStrings.editTitle), jslogContext: 'edit-title', } as Buttons.Button.ButtonData } ></devtools-button> </div> </div> ${ this.#isTitleInvalid ? html`<div class="title-input-error-text"> ${ i18nString(UIStrings.requiredTitleError) } </div>` : '' } </div> ${ !this.#isRecording && this.#replayAllowed ? html`<div class="actions"> <devtools-button @click=${this.#handleMeasurePerformanceClickEvent} .data=${ { disabled: this.#replayState.isPlaying, variant: Buttons.Button.Variant.OUTLINED, iconName: 'performance', title: i18nString(UIStrings.performancePanel), jslogContext: 'measure-performance', } as Buttons.Button.ButtonData } > ${i18nString(UIStrings.performancePanel)} </devtools-button> <div class="separator"></div> ${this.#renderReplayOrAbortButton()} </div>` : '' } </div>`; // clang-format on } #renderFooter(): Lit.LitTemplate|string { if (!this.#isRecording) { return ''; } const translation = this.#recordingTogglingInProgress ? i18nString(UIStrings.recordingIsBeingStopped) : i18nString(UIStrings.endRecording); // clang-format off return html` <div class="footer"> <div class="controls"> <devtools-control-button jslog=${VisualLogging.toggle('toggle-recording').track({click: true})} @click=${this.#dispatchRecordingFinished} .disabled=${this.#recordingTogglingInProgress} .shape=${'square'} .label=${translation} title=${Models.Tooltip.getTooltipForActions( translation, Actions.RecorderActions.START_RECORDING, )} > </devtools-control-button> </div> </div> `; // clang-format on } #render(): void { const classNames = { wrapper: true, 'is-recording': this.#isRecording, 'is-playing': this.#replayState.isPlaying, 'was-successful': this.#lastReplayResult === Models.RecordingPlayer.ReplayResult.SUCCESS, 'was-failure': this.#lastReplayResult === Models.RecordingPlayer.ReplayResult.FAILURE, }; // clang-format off Lit.render( html` <style>${UI.inspectorCommonStyles}</style> <style>${recordingViewStyles}</style> <style>${Input.textInputStyles}</style> <div @click=${this.#onWrapperClick} class=${Lit.Directives.classMap( classNames, )}> <div class="main"> ${this.#renderHeader()} ${ this.#extensionDescriptor ? html` <devtools-recorder-extension-view .descriptor=${ this.#extensionDescriptor }> </devtools-recorder-extension-view> ` : html` ${this.#renderSettings()} ${this.#renderTimelineArea()} ` } ${this.#renderFooter()} </div> </div> `, this.#shadow, { host: this }, ); // clang-format on } } customElements.define( 'devtools-recording-view', RecordingView, );