[go: nahoru, domu]

blob: 7271af37dd5223efdd92ee35a12941eac1f8482b [file] [log] [blame]
// 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.
import * as i18n from '../../../core/i18n/i18n.js';
import * as Buttons from '../../../ui/components/buttons/buttons.js';
import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
import * as IconButton from '../../../ui/components/icon_button/icon_button.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as LitHtml from '../../../ui/lit-html/lit-html.js';
import * as Menus from '../../../ui/components/menus/menus.js';
import type * as Converters from '../converters/converters.js';
import * as Models from '../models/models.js';
import stepViewStyles from './stepView.css.js';
import {type StepEditedEvent} from './StepEditor.js';
import {
TimelineSection,
type TimelineSectionData,
} from './TimelineSection.js';
const deployMenuArrow = new URL(
'../images/select-arrow-icon.svg',
import.meta.url,
)
.toString();
const UIStrings = {
/**
*@description Title for the step type that configures the viewport
*/
setViewportClickTitle: 'Set viewport',
/**
*@description Title for the customStep step type
*/
customStepTitle: 'Custom step',
/**
*@description Title for the click step type
*/
clickStepTitle: 'Click',
/**
*@description Title for the double click step type
*/
doubleClickStepTitle: 'Double click',
/**
*@description Title for the hover step type
*/
hoverStepTitle: 'Hover',
/**
*@description Title for the emulateNetworkConditions step type
*/
emulateNetworkConditionsStepTitle: 'Emulate network conditions',
/**
*@description Title for the change step type
*/
changeStepTitle: 'Change',
/**
*@description Title for the close step type
*/
closeStepTitle: 'Close',
/**
*@description Title for the scroll step type
*/
scrollStepTitle: 'Scroll',
/**
*@description Title for the key up step type. `up` refers to the state of the keyboard key: it's released, i.e., up. It does not refer to the down arrow key specifically.
*/
keyUpStepTitle: 'Key up',
/**
*@description Title for the navigate step type
*/
navigateStepTitle: 'Navigate',
/**
*@description Title for the key down step type. `down` refers to the state of the keyboard key: it's pressed, i.e., down. It does not refer to the down arrow key specifically.
*/
keyDownStepTitle: 'Key down',
/**
*@description Title for the waitForElement step type
*/
waitForElementStepTitle: 'Wait for element',
/**
*@description Title for the waitForExpression step type
*/
waitForExpressionStepTitle: 'Wait for expression',
/**
*@description Title for elements with role button
*/
elementRoleButton: 'Button',
/**
*@description Title for elements with role input
*/
elementRoleInput: 'Input',
/**
*@description Default title for elements without a specific role
*/
elementRoleFallback: 'Element',
/**
*@description The title of the button in the step's context menu that adds a new step before the current one.
*/
addStepBefore: 'Add step before',
/**
*@description The title of the button in the step's context menu that adds a new step after the current one.
*/
addStepAfter: 'Add step after',
/**
*@description The title of the button in the step's context menu that removes the step.
*/
removeStep: 'Remove step',
/**
*@description The title of the button that open the step's context menu.
*/
openStepActions: 'Open step actions',
/**
*@description The title of the button in the step's context menu that adds a breakpoint.
*/
addBreakpoint: 'Add breakpoint',
/**
*@description The title of the button in the step's context menu that removes a breakpoint.
*/
removeBreakpoint: 'Remove breakpoint',
/**
* @description A menu item item in the context menu that expands another menu which list all
* the formats the user can copy the recording as.
*/
copyAs: 'Copy as',
/**
* @description The title of the menu group that holds actions on recording steps.
*/
stepManagement: 'Manage steps',
/**
* @description The title of the menu group that holds actions related to breakpoints.
*/
breakpoints: 'Breakpoints',
};
const str_ = i18n.i18n.registerUIStrings(
'panels/recorder/components/StepView.ts',
UIStrings,
);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
declare global {
interface HTMLElementTagNameMap {
'devtools-step-view': StepView;
}
}
export const enum State {
Default = 'default',
Success = 'success',
Current = 'current',
Outstanding = 'outstanding',
Error = 'error',
Stopped = 'stopped',
}
export interface StepViewData {
state: State;
step?: Models.Schema.Step;
section?: Models.Section.Section;
error?: Error;
hasBreakpoint: boolean;
isEndOfGroup: boolean;
isStartOfGroup: boolean;
isFirstSection: boolean;
isLastSection: boolean;
stepIndex: number;
sectionIndex: number;
isRecording: boolean;
isPlaying: boolean;
removable: boolean;
builtInConverters: Converters.Converter.Converter[];
extensionConverters: Converters.Converter.Converter[];
isSelected: boolean;
recorderSettings: Models.RecorderSettings.RecorderSettings;
}
export class CaptureSelectorsEvent extends Event {
static readonly eventName = 'captureselectors';
data: Models.Schema.StepWithSelectors&Partial<Models.Schema.ClickAttributes>;
constructor(
step: Models.Schema.StepWithSelectors&Partial<Models.Schema.ClickAttributes>,
) {
super(CaptureSelectorsEvent.eventName, {bubbles: true, composed: true});
this.data = step;
}
}
export class StopSelectorsCaptureEvent extends Event {
static readonly eventName = 'stopselectorscapture';
constructor() {
super(StopSelectorsCaptureEvent.eventName, {
bubbles: true,
composed: true,
});
}
}
export class CopyStepEvent extends Event {
static readonly eventName = 'copystep';
step: Models.Schema.Step;
constructor(step: Models.Schema.Step) {
super(CopyStepEvent.eventName, {bubbles: true, composed: true});
this.step = step;
}
}
export class StepChanged extends Event {
static readonly eventName = 'stepchanged';
currentStep: Models.Schema.Step;
newStep: Models.Schema.Step;
constructor(currentStep: Models.Schema.Step, newStep: Models.Schema.Step) {
super(StepChanged.eventName, {bubbles: true, composed: true});
this.currentStep = currentStep;
this.newStep = newStep;
}
}
export const enum AddStepPosition {
BEFORE = 'before',
AFTER = 'after',
}
export class AddStep extends Event {
static readonly eventName = 'addstep';
position: AddStepPosition;
stepOrSection: Models.Schema.Step|Models.Section.Section;
constructor(
stepOrSection: Models.Schema.Step|Models.Section.Section,
position: AddStepPosition,
) {
super(AddStep.eventName, {bubbles: true, composed: true});
this.stepOrSection = stepOrSection;
this.position = position;
}
}
export class RemoveStep extends Event {
static readonly eventName = 'removestep';
step: Models.Schema.Step;
constructor(step: Models.Schema.Step) {
super(RemoveStep.eventName, {bubbles: true, composed: true});
this.step = step;
}
}
export class AddBreakpointEvent extends Event {
static readonly eventName = 'addbreakpoint';
index: number;
constructor(index: number) {
super(AddBreakpointEvent.eventName, {bubbles: true, composed: true});
this.index = index;
}
}
export class RemoveBreakpointEvent extends Event {
static readonly eventName = 'removebreakpoint';
index: number;
constructor(index: number) {
super(RemoveBreakpointEvent.eventName, {bubbles: true, composed: true});
this.index = index;
}
}
const actionsMenuIconUrl = new URL(
'../images/more_icon.svg',
import.meta.url,
)
.toString();
const COPY_ACTION_PREFIX = 'copy-step-as-';
type Action = {
id: string,
label: string,
group: string,
groupTitle: string,
};
export class StepView extends HTMLElement {
static readonly litTagName = LitHtml.literal`devtools-step-view`;
readonly #shadow = this.attachShadow({mode: 'open'});
#step?: Models.Schema.Step;
#section?: Models.Section.Section;
#state = State.Default;
#error?: Error;
#showDetails: boolean = false;
#isEndOfGroup: boolean = false;
#isStartOfGroup: boolean = false;
#stepIndex = 0;
#sectionIndex = 0;
#isFirstSection: boolean = false;
#isLastSection: boolean = false;
#isRecording = false;
#isPlaying = false;
#actionsMenuButton?: Buttons.Button.Button;
#actionsMenuExpanded = false;
#isVisible = false;
#observer: IntersectionObserver = new IntersectionObserver(result => {
this.#isVisible = result[0].isIntersecting;
});
#hasBreakpoint = false;
#removable = true;
#builtInConverters?: Converters.Converter.Converter[];
#extensionConverters?: Converters.Converter.Converter[];
#isSelected = false;
#recorderSettings?: Models.RecorderSettings.RecorderSettings;
set data(data: StepViewData) {
const prevState = this.#state;
this.#step = data.step;
this.#section = data.section;
this.#state = data.state;
this.#error = data.error;
this.#isEndOfGroup = data.isEndOfGroup;
this.#isStartOfGroup = data.isStartOfGroup;
this.#stepIndex = data.stepIndex;
this.#sectionIndex = data.sectionIndex;
this.#isFirstSection = data.isFirstSection;
this.#isLastSection = data.isLastSection;
this.#isRecording = data.isRecording;
this.#isPlaying = data.isPlaying;
this.#hasBreakpoint = data.hasBreakpoint;
this.#removable = data.removable;
this.#builtInConverters = data.builtInConverters;
this.#extensionConverters = data.extensionConverters;
this.#isSelected = data.isSelected;
this.#recorderSettings = data.recorderSettings;
this.#render();
if (this.#state !== prevState && this.#state === 'current' && !this.#isVisible) {
this.scrollIntoView();
}
}
get step(): Models.Schema.Step|undefined {
return this.#step;
}
get section(): Models.Section.Section|undefined {
return this.#section;
}
connectedCallback(): void {
this.#shadow.adoptedStyleSheets = [stepViewStyles];
this.#observer.observe(this);
this.#render();
}
disconnectedCallback(): void {
this.#observer.unobserve(this);
}
#toggleShowDetails(): void {
this.#showDetails = !this.#showDetails;
this.#render();
}
#onToggleShowDetailsKeydown(event: Event): void {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') {
this.#toggleShowDetails();
event.stopPropagation();
event.preventDefault();
}
}
#getStepTypeTitle(): string|LitHtml.TemplateResult {
if (this.#section) {
return this.#section.title ? this.#section.title : LitHtml.html`<span class="fallback">(No Title)</span>`;
}
if (!this.#step) {
throw new Error('Missing both step and section');
}
switch (this.#step.type) {
case Models.Schema.StepType.CustomStep:
return i18nString(UIStrings.customStepTitle);
case Models.Schema.StepType.SetViewport:
return i18nString(UIStrings.setViewportClickTitle);
case Models.Schema.StepType.Click:
return i18nString(UIStrings.clickStepTitle);
case Models.Schema.StepType.DoubleClick:
return i18nString(UIStrings.doubleClickStepTitle);
case Models.Schema.StepType.Hover:
return i18nString(UIStrings.hoverStepTitle);
case Models.Schema.StepType.EmulateNetworkConditions:
return i18nString(UIStrings.emulateNetworkConditionsStepTitle);
case Models.Schema.StepType.Change:
return i18nString(UIStrings.changeStepTitle);
case Models.Schema.StepType.Close:
return i18nString(UIStrings.closeStepTitle);
case Models.Schema.StepType.Scroll:
return i18nString(UIStrings.scrollStepTitle);
case Models.Schema.StepType.KeyUp:
return i18nString(UIStrings.keyUpStepTitle);
case Models.Schema.StepType.KeyDown:
return i18nString(UIStrings.keyDownStepTitle);
case Models.Schema.StepType.WaitForElement:
return i18nString(UIStrings.waitForElementStepTitle);
case Models.Schema.StepType.WaitForExpression:
return i18nString(UIStrings.waitForExpressionStepTitle);
case Models.Schema.StepType.Navigate:
return i18nString(UIStrings.navigateStepTitle);
}
}
#getElementRoleTitle(role: string): string {
switch (role) {
case 'button':
return i18nString(UIStrings.elementRoleButton);
case 'input':
return i18nString(UIStrings.elementRoleInput);
default:
return i18nString(UIStrings.elementRoleFallback);
}
}
#getSelectorPreview(): string {
if (!this.#step || !('selectors' in this.#step)) {
return '';
}
const ariaSelector = this.#step.selectors.flat().find(selector => selector.startsWith('aria/'));
if (!ariaSelector) {
return '';
}
const m = ariaSelector.match(/^aria\/(.+?)(\[role="(.+)"\])?$/);
if (!m) {
return '';
}
return `${this.#getElementRoleTitle(m[3])} "${m[1]}"`;
}
#getSectionPreview(): string {
if (!this.#section) {
return '';
}
return this.#section.url;
}
#stepEdited(event: StepEditedEvent): void {
const step = this.#step || this.#section?.causingStep;
if (!step) {
throw new Error('Expected step.');
}
this.dispatchEvent(new StepChanged(step, event.data));
}
#handleStepAction(event: Menus.Menu.MenuItemSelectedEvent): void {
switch (event.itemValue) {
case 'add-step-before': {
const stepOrSection = this.#step || this.#section;
if (!stepOrSection) {
throw new Error('Expected step or section.');
}
this.dispatchEvent(new AddStep(stepOrSection, AddStepPosition.BEFORE));
break;
}
case 'add-step-after': {
const stepOrSection = this.#step || this.#section;
if (!stepOrSection) {
throw new Error('Expected step or section.');
}
this.dispatchEvent(new AddStep(stepOrSection, AddStepPosition.AFTER));
break;
}
case 'remove-step': {
const causingStep = this.#section?.causingStep;
if (!this.#step && !causingStep) {
throw new Error('Expected step.');
}
this.dispatchEvent(
new RemoveStep(this.#step || (causingStep as Models.Schema.Step)),
);
break;
}
case 'add-breakpoint': {
if (!this.#step) {
throw new Error('Expected step');
}
this.dispatchEvent(new AddBreakpointEvent(this.#stepIndex));
break;
}
case 'remove-breakpoint': {
if (!this.#step) {
throw new Error('Expected step');
}
this.dispatchEvent(new RemoveBreakpointEvent(this.#stepIndex));
break;
}
default: {
const actionId = event.itemValue as string;
if (!actionId.startsWith(COPY_ACTION_PREFIX)) {
throw new Error('Unknown step action.');
}
const copyStep = this.#step || this.#section?.causingStep;
if (!copyStep) {
throw new Error('Step not found.');
}
const converterId = actionId.substring(COPY_ACTION_PREFIX.length);
if (this.#recorderSettings) {
this.#recorderSettings.preferredCopyFormat = converterId;
}
this.dispatchEvent(new CopyStepEvent(structuredClone(copyStep)));
}
}
}
#onToggleActionsMenu(event: Event): void {
event.stopPropagation();
event.preventDefault();
this.#actionsMenuExpanded = !this.#actionsMenuExpanded;
this.#render();
}
#onCloseActionsMenu(): void {
this.#actionsMenuExpanded = false;
this.#render();
}
#onBreakpointClick(): void {
if (this.#hasBreakpoint) {
this.dispatchEvent(new RemoveBreakpointEvent(this.#stepIndex));
} else {
this.dispatchEvent(new AddBreakpointEvent(this.#stepIndex));
}
this.#render();
}
#getActionsMenuButton(): Buttons.Button.Button {
if (!this.#actionsMenuButton) {
throw new Error('Missing actionsMenuButton');
}
return this.#actionsMenuButton;
}
#getActions = (): Array<Action> => {
const actions = [];
if (!this.#isPlaying) {
if (this.#step) {
actions.push({
id: 'add-step-before',
label: i18nString(UIStrings.addStepBefore),
group: 'stepManagement',
groupTitle: i18nString(UIStrings.stepManagement),
});
}
actions.push({
id: 'add-step-after',
label: i18nString(UIStrings.addStepAfter),
group: 'stepManagement',
groupTitle: i18nString(UIStrings.stepManagement),
});
if (this.#removable) {
actions.push({
id: 'remove-step',
group: 'stepManagement',
groupTitle: i18nString(UIStrings.stepManagement),
label: i18nString(UIStrings.removeStep),
});
}
}
if (this.#step && !this.#isRecording) {
if (this.#hasBreakpoint) {
actions.push({
id: 'remove-breakpoint',
label: i18nString(UIStrings.removeBreakpoint),
group: 'breakPointManagement',
groupTitle: i18nString(UIStrings.breakpoints),
});
} else {
actions.push({
id: 'add-breakpoint',
label: i18nString(UIStrings.addBreakpoint),
group: 'breakPointManagement',
groupTitle: i18nString(UIStrings.breakpoints),
});
}
}
if (this.#step) {
for (const converter of this.#builtInConverters || []) {
actions.push({
id: COPY_ACTION_PREFIX + converter.getId(),
label: converter.getFormatName(),
group: 'copy',
groupTitle: i18nString(UIStrings.copyAs),
});
}
for (const converter of this.#extensionConverters || []) {
actions.push({
id: COPY_ACTION_PREFIX + converter.getId(),
label: converter.getFormatName(),
group: 'copy',
groupTitle: i18nString(UIStrings.copyAs),
});
}
}
return actions;
};
#renderStepActions(): LitHtml.TemplateResult|null {
const actions = this.#getActions();
const groupsById = new Map<string, Action[]>();
for (const action of actions) {
const group = groupsById.get(action.group);
if (!group) {
groupsById.set(action.group, [action]);
} else {
group.push(action);
}
}
const groups = [];
for (const [group, actions] of groupsById) {
groups.push({
group,
groupTitle: actions[0].groupTitle,
actions,
});
}
// clang-format off
return LitHtml.html`
<${Buttons.Button.Button.litTagName}
class="step-actions"
title=${i18nString(UIStrings.openStepActions)}
aria-label=${i18nString(UIStrings.openStepActions)}
@click=${this.#onToggleActionsMenu}
@keydown=${(event: Event): void => {
event.stopPropagation();
}}
on-render=${ComponentHelpers.Directives.nodeRenderedCallback(node => {
this.#actionsMenuButton = node as Buttons.Button.Button;
})}
.data=${
{
variant: Buttons.Button.Variant.TOOLBAR,
iconUrl: actionsMenuIconUrl,
size: Buttons.Button.Size.SMALL,
title: i18nString(UIStrings.openStepActions),
} as Buttons.Button.ButtonData
}
></${Buttons.Button.Button.litTagName}>
<${Menus.Menu.Menu.litTagName}
@menucloserequest=${this.#onCloseActionsMenu}
@menuitemselected=${this.#handleStepAction}
.origin=${this.#getActionsMenuButton.bind(this)}
.showSelectedItem=${false}
.showConnector=${false}
.open=${this.#actionsMenuExpanded}
>
${LitHtml.Directives.repeat(
groups,
item => item.group,
item => {
return LitHtml.html`
<${Menus.Menu.MenuGroup.litTagName}
.name=${item.groupTitle}
>
${LitHtml.Directives.repeat(
item.actions,
item => item.id,
item => {
return LitHtml.html`<${Menus.Menu.MenuItem.litTagName}
.value=${item.id}
>
${item.label}
</${Menus.Menu.MenuItem.litTagName}>
`;
},
)}
</${Menus.Menu.MenuGroup.litTagName}>
`;
},
)}
</${Menus.Menu.Menu.litTagName}>
`;
// clang-format on
}
# MouseEvent): void => {
if (event.button !== 2) {
// 2 = secondary button = right click. We only show context menus if the
// user has right clicked.
return;
}
const menu = new UI.ContextMenu.ContextMenu(event);
const actions = this.#getActions();
const copyActions = actions.filter(
item => item.id.startsWith(COPY_ACTION_PREFIX),
);
const otherActions = actions.filter(
item => !item.id.startsWith(COPY_ACTION_PREFIX),
);
for (const item of otherActions) {
const section = menu.section(item.group);
section.appendItem(item.label, () => {
this.#handleStepAction(
new Menus.Menu.MenuItemSelectedEvent(item.id),
);
});
}
const preferredCopyAction = copyActions.find(
item => item.id === COPY_ACTION_PREFIX + this.#recorderSettings?.preferredCopyFormat,
);
if (preferredCopyAction) {
menu.section('copy').appendItem(preferredCopyAction.label, () => {
this.#handleStepAction(
new Menus.Menu.MenuItemSelectedEvent(preferredCopyAction.id),
);
});
}
if (copyActions.length) {
const copyAs = menu.section('copy').appendSubMenuItem(i18nString(UIStrings.copyAs));
for (const item of copyActions) {
if (item === preferredCopyAction) {
continue;
}
copyAs.section(item.group).appendItem(item.label, () => {
this.#handleStepAction(
new Menus.Menu.MenuItemSelectedEvent(item.id),
);
});
}
}
void menu.show();
};
#render(): void {
if (!this.#step && !this.#section) {
return;
}
const stepClasses = {
step: true,
expanded: this.#showDetails,
'is-success': this.#state === State.Success,
'is-current': this.#state === State.Current,
'is-outstanding': this.#state === State.Outstanding,
'is-error': this.#state === State.Error,
'is-stopped': this.#state === State.Stopped,
'is-start-of-group': this.#isStartOfGroup,
'is-first-section': this.#isFirstSection,
'has-breakpoint': this.#hasBreakpoint,
};
const isExpandable = Boolean(this.#step);
const mainTitle = this.#getStepTypeTitle();
const subtitle = this.#step ? this.#getSelectorPreview() : this.#getSectionPreview();
// clang-format off
LitHtml.render(
LitHtml.html`
<${TimelineSection.litTagName} .data=${
{
isFirstSection: this.#isFirstSection,
isLastSection: this.#isLastSection,
isStartOfGroup: this.#isStartOfGroup,
isEndOfGroup: this.#isEndOfGroup,
isSelected: this.#isSelected,
} as TimelineSectionData
} @contextmenu=${this.#onStepContextMenu} data-step-index=${
this.#stepIndex
} data-section-index=${
this.#sectionIndex
} class=${LitHtml.Directives.classMap(stepClasses)}>
<svg slot="icon" width="24" height="24" height="100%" class="icon">
<circle class="circle-icon"/>
<g class="error-icon">
<path d="M1.5 1.5L6.5 6.5" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.5 6.5L6.5 1.5" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<path @click=${this.#onBreakpointClick.bind(
this,
)} class="breakpoint-icon" d="M2.5 5.5H17.7098L21.4241 12L17.7098 18.5H2.5V5.5Z"/>
</svg>
<div class="summary">
<div class="title-container ${isExpandable ? 'action' : ''}"
@click=${isExpandable && this.#toggleShowDetails.bind(this)}
@keydown=${
isExpandable && this.#onToggleShowDetailsKeydown.bind(this)
}
tabindex="0"
aria-role=${isExpandable ? 'button' : ''}
aria-label=${isExpandable ? 'Show details for step' : ''}
>
${
isExpandable
? LitHtml.html`<${IconButton.Icon.Icon.litTagName}
class="chevron"
.data=${
{
iconPath: deployMenuArrow,
color: 'var(--color-text-primary)',
} as IconButton.Icon.IconData
}>
</${IconButton.Icon.Icon.litTagName}>`
: ''
}
<div class="title">
<div class="main-title" title=${mainTitle}>${mainTitle}</div>
<div class="subtitle" title=${subtitle}>${subtitle}</div>
</div>
</div>
<div class="filler"></div>
${this.#renderStepActions()}
</div>
<div class="details">
${
this.#step &&
LitHtml.html`<devtools-recorder-step-editor
.step=${this.#step}
.disabled=${this.#isPlaying}
@stepedited=${this.#stepEdited}>
</devtools-recorder-step-editor>`
}
${
this.#section?.causingStep &&
LitHtml.html`<devtools-recorder-step-editor
.step=${this.#section.causingStep}
.isTypeEditable=${false}
.disabled=${this.#isPlaying}
@stepedited=${this.#stepEdited}>
</devtools-recorder-step-editor>`
}
</div>
${
this.#error &&
LitHtml.html`
<div class="error" role="alert">
${this.#error.message}
</div>
`
}
</${TimelineSection.litTagName}>
`,
this.#shadow,
{ host: this },
);
// clang-format on
}
}
ComponentHelpers.CustomElements.defineComponent('devtools-step-view', StepView);