chrome-devtools-frontend
Version:
Chrome DevTools UI
454 lines (408 loc) • 17.3 kB
text/typescript
// Copyright 2022 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 Persistence from '../../../models/persistence/persistence.js';
import * as Workspace from '../../../models/workspace/workspace.js';
import * as Buttons from '../../../ui/components/buttons/buttons.js';
import * as ComponentHelpers from '../../../ui/components/helpers/helpers.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as LitHtml from '../../../ui/lit-html/lit-html.js';
import type * as Protocol from '../../../generated/protocol.js';
import * as Host from '../../../core/host/host.js';
import HeadersViewStyles from './HeadersView.css.js';
const UIStrings = {
/**
*@description The title of a button that adds a field to input a header in the editor form.
*/
addHeader: 'Add a header',
/**
*@description The title of a button that removes a field to input a header in the editor form.
*/
removeHeader: 'Remove this header',
/**
*@description The title of a button that removes a section for defining header overrides in the editor form.
*/
removeBlock: 'Remove this \'`ApplyTo`\'-section',
/**
*@description Error message for files which cannot not be parsed.
*@example {.headers} PH1
*/
errorWhenParsing: 'Error when parsing \'\'{PH1}\'\'.',
/**
*@description Explainer for files which cannot be parsed.
*@example {.headers} PH1
*/
parsingErrorExplainer:
'This is most likely due to a syntax error in \'\'{PH1}\'\'. Try opening this file in an external editor to fix the error or delete the file and re-create the override.',
/**
*@description Button text for a button which adds an additional header override rule.
*/
addOverrideRule: 'Add override rule',
/**
*@description Text which is a hyperlink to more documentation
*/
learnMore: 'Learn more',
};
const str_ = i18n.i18n.registerUIStrings('panels/sources/components/HeadersView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const plusIconUrl = new URL('../../../Images/plus.svg', import.meta.url).toString();
const trashIconUrl = new URL('../../../Images/bin.svg', import.meta.url).toString();
export class HeadersView extends UI.View.SimpleView {
readonly #headersViewComponent = new HeadersViewComponent();
#uiSourceCode: Workspace.UISourceCode.UISourceCode;
constructor(uiSourceCode: Workspace.UISourceCode.UISourceCode) {
super(i18n.i18n.lockedString('HeadersView'));
this.#uiSourceCode = uiSourceCode;
this.#uiSourceCode.addEventListener(
Workspace.UISourceCode.Events.WorkingCopyChanged, this.#onWorkingCopyChanged, this);
this.#uiSourceCode.addEventListener(
Workspace.UISourceCode.Events.WorkingCopyCommitted, this.#onWorkingCopyCommitted, this);
this.element.appendChild(this.#headersViewComponent);
void this.#setInitialData();
}
async #setInitialData(): Promise<void> {
const content = await this.#uiSourceCode.requestContent();
this.#setComponentData(content.content || '');
}
#setComponentData(content: string): void {
let parsingError = false;
let headerOverrides: Persistence.NetworkPersistenceManager.HeaderOverride[] = [];
content = content || '[]';
try {
headerOverrides = JSON.parse(content) as Persistence.NetworkPersistenceManager.HeaderOverride[];
if (!headerOverrides.every(Persistence.NetworkPersistenceManager.isHeaderOverride)) {
throw 'Type mismatch after parsing';
}
} catch (e) {
console.error('Failed to parse', this.#uiSourceCode.url(), 'for locally overriding headers.');
parsingError = true;
}
this.#headersViewComponent.data = {
headerOverrides,
uiSourceCode: this.#uiSourceCode,
parsingError,
};
}
#onWorkingCopyChanged(): void {
this.#setComponentData(this.#uiSourceCode.workingCopy());
}
#onWorkingCopyCommitted(): void {
this.#setComponentData(this.#uiSourceCode.workingCopy());
}
getComponent(): HeadersViewComponent {
return this.#headersViewComponent;
}
dispose(): void {
this.#uiSourceCode.removeEventListener(
Workspace.UISourceCode.Events.WorkingCopyChanged, this.#onWorkingCopyChanged, this);
this.#uiSourceCode.removeEventListener(
Workspace.UISourceCode.Events.WorkingCopyCommitted, this.#onWorkingCopyCommitted, this);
}
}
export interface HeadersViewComponentData {
headerOverrides: Persistence.NetworkPersistenceManager.HeaderOverride[];
uiSourceCode: Workspace.UISourceCode.UISourceCode;
parsingError: boolean;
}
export class HeadersViewComponent extends HTMLElement {
static readonly litTagName = LitHtml.literal`devtools-sources-headers-view`;
readonly #shadow = this.attachShadow({mode: 'open'});
readonly #boundRender = this.#render.bind(this);
#headerOverrides: Persistence.NetworkPersistenceManager.HeaderOverride[] = [];
#uiSourceCode: Workspace.UISourceCode.UISourceCode|null = null;
#parsingError = false;
#focusElement: {blockIndex: number, headerIndex?: number}|null = null;
#textOnFocusIn = '';
constructor() {
super();
this.#shadow.addEventListener('focusin', this.#onFocusIn.bind(this));
this.#shadow.addEventListener('focusout', this.#onFocusOut.bind(this));
this.#shadow.addEventListener('click', this.#onClick.bind(this));
this.#shadow.addEventListener('input', this.#onInput.bind(this));
this.#shadow.addEventListener('keydown', this.#onKeyDown.bind(this));
this.#shadow.addEventListener('paste', this.#onPaste.bind(this));
this.addEventListener('contextmenu', this.#onContextMenu.bind(this));
}
connectedCallback(): void {
this.#shadow.adoptedStyleSheets = [HeadersViewStyles];
}
set data(data: HeadersViewComponentData) {
this.#headerOverrides = data.headerOverrides;
this.#uiSourceCode = data.uiSourceCode;
this.#parsingError = data.parsingError;
void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender);
}
// 'Enter' key should not create a new line in the contenteditable. Focus
// on the next contenteditable instead.
#onKeyDown(event: Event): void {
const target = event.target as HTMLElement;
if (!target.matches('.editable')) {
return;
}
const keyboardEvent = event as KeyboardEvent;
if (target.matches('.header-name') && target.innerText === '' &&
(keyboardEvent.key === 'Enter' || keyboardEvent.key === 'Tab')) {
// onFocusOut will remove the header -> blur instead of focusing on next editable
event.preventDefault();
target.blur();
} else if (keyboardEvent.key === 'Enter') {
event.preventDefault();
target.blur();
this.#focusNext(target);
} else if (keyboardEvent.key === 'Escape') {
event.consume();
target.innerText = this.#textOnFocusIn;
target.blur();
this.#onChange(target);
}
}
#focusNext(target: HTMLElement): void {
const elements = Array.from(this.#shadow.querySelectorAll('.editable')) as HTMLElement[];
const idx = elements.indexOf(target);
if (idx !== -1 && idx + 1 < elements.length) {
elements[idx + 1].focus();
}
}
#selectAllText(target: HTMLElement): void {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(target);
selection?.removeAllRanges();
selection?.addRange(range);
}
#onFocusIn(event: Event): void {
const target = event.target as HTMLElement;
if (target.matches('.editable')) {
this.#selectAllText(target);
this.#textOnFocusIn = target.innerText;
}
}
#onFocusOut(event: Event): void {
const target = event.target as HTMLElement;
if (target.innerText === '') {
const rowElement = target.closest('.row') as HTMLElement;
const blockIndex = Number(rowElement.dataset.blockIndex);
const headerIndex = Number(rowElement.dataset.headerIndex);
if (target.matches('.apply-to')) {
target.innerText = '*';
this.#headerOverrides[blockIndex].applyTo = '*';
this.#onHeadersChanged();
} else if (target.matches('.header-name')) {
this.#removeHeader(blockIndex, headerIndex);
}
}
// clear selection
const selection = window.getSelection();
selection?.removeAllRanges();
this.#uiSourceCode?.commitWorkingCopy();
}
#onContextMenu(event: Event): void {
if (!this.#uiSourceCode) {
return;
}
const contextMenu = new UI.ContextMenu.ContextMenu(event);
contextMenu.appendApplicableItems(this.#uiSourceCode);
void contextMenu.show();
}
#generateNextHeaderName(headers: Protocol.Fetch.HeaderEntry[]): string {
const takenNames = new Set<string>(headers.map(header => header.name));
let idx = 1;
while (takenNames.has('header-name-' + idx)) {
idx++;
}
return 'header-name-' + idx;
}
#onClick(event: Event): void {
const target = event.target as HTMLButtonElement;
const rowElement = target.closest('.row') as HTMLElement | null;
const blockIndex = Number(rowElement?.dataset.blockIndex || 0);
const headerIndex = Number(rowElement?.dataset.headerIndex || 0);
if (target.matches('.add-header')) {
this.#headerOverrides[blockIndex].headers.splice(
headerIndex + 1, 0,
{name: this.#generateNextHeaderName(this.#headerOverrides[blockIndex].headers), value: 'header value'});
this.#focusElement = {blockIndex, headerIndex: headerIndex + 1};
this.#onHeadersChanged();
} else if (target.matches('.remove-header')) {
this.#removeHeader(blockIndex, headerIndex);
} else if (target.matches('.add-block')) {
this.#headerOverrides.push({applyTo: '*', headers: [{name: 'header-name-1', value: 'header value'}]});
this.#focusElement = {blockIndex: this.#headerOverrides.length - 1};
this.#onHeadersChanged();
} else if (target.matches('.remove-block')) {
this.#headerOverrides.splice(blockIndex, 1);
this.#onHeadersChanged();
}
}
#removeHeader(blockIndex: number, headerIndex: number): void {
this.#headerOverrides[blockIndex].headers.splice(headerIndex, 1);
if (this.#headerOverrides[blockIndex].headers.length === 0) {
this.#headerOverrides[blockIndex].headers.push(
{name: this.#generateNextHeaderName(this.#headerOverrides[blockIndex].headers), value: 'header value'});
}
this.#onHeadersChanged();
}
#onInput(event: Event): void {
this.#onChange(event.target as HTMLElement);
}
#onChange(target: HTMLElement): void {
const rowElement = target.closest('.row') as HTMLElement;
const blockIndex = Number(rowElement.dataset.blockIndex);
const headerIndex = Number(rowElement.dataset.headerIndex);
if (target.matches('.header-name')) {
this.#headerOverrides[blockIndex].headers[headerIndex].name = target.innerText;
this.#onHeadersChanged();
}
if (target.matches('.header-value')) {
this.#headerOverrides[blockIndex].headers[headerIndex].value = target.innerText;
this.#onHeadersChanged();
}
if (target.matches('.apply-to')) {
this.#headerOverrides[blockIndex].applyTo = target.innerText;
this.#onHeadersChanged();
}
}
#onHeadersChanged(): void {
this.#uiSourceCode?.setWorkingCopy(JSON.stringify(this.#headerOverrides, null, 2));
Host.userMetrics.actionTaken(Host.UserMetrics.Action.HeaderOverrideHeadersFileEdited);
}
#onPaste(event: Event): void {
const clipboardEvent = event as ClipboardEvent;
event.preventDefault();
if (clipboardEvent.clipboardData) {
const text = clipboardEvent.clipboardData.getData('text/plain');
const range = this.#shadow.getSelection()?.getRangeAt(0);
if (!range) {
return;
}
range.deleteContents();
const textNode = document.createTextNode(text);
range.insertNode(textNode);
range.selectNodeContents(textNode);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
this.#onChange(event.target as HTMLElement);
}
}
#render(): void {
if (!ComponentHelpers.ScheduledRender.isScheduledRender(this)) {
throw new Error('HeadersView render was not scheduled');
}
if (this.#parsingError) {
const fileName = this.#uiSourceCode?.name() || '.headers';
// clang-format off
LitHtml.render(LitHtml.html`
<div class="center-wrapper">
<div class="centered">
<div class="error-header">${i18nString(UIStrings.errorWhenParsing, {PH1: fileName})}</div>
<div class="error-body">${i18nString(UIStrings.parsingErrorExplainer, {PH1: fileName})}</div>
</div>
</div>
`, this.#shadow, {host: this});
// clang-format on
return;
}
// clang-format off
LitHtml.render(LitHtml.html`
${this.#headerOverrides.map((headerOverride, blockIndex) =>
LitHtml.html`
${this.#renderApplyToRow(headerOverride.applyTo, blockIndex)}
${headerOverride.headers.map((header, headerIndex) =>
LitHtml.html`
${this.#renderHeaderRow(header, blockIndex, headerIndex)}
`,
)}
`,
)}
<${Buttons.Button.Button.litTagName} .variant=${Buttons.Button.Variant.SECONDARY} class="add-block">
${i18nString(UIStrings.addOverrideRule)}
</${Buttons.Button.Button.litTagName}>
<div class="learn-more-row">
<x-link href="https://goo.gle/devtools-override" class="link">${i18nString(UIStrings.learnMore)}</x-link>
</div>
`, this.#shadow, {host: this});
// clang-format on
if (this.#focusElement) {
let focusElement: Element|null = null;
if (this.#focusElement.headerIndex) {
focusElement = this.#shadow.querySelector(`[data-block-index="${
this.#focusElement.blockIndex}"][data-header-index="${this.#focusElement.headerIndex}"] .header-name`);
} else {
focusElement = this.#shadow.querySelector(`[data-block-index="${this.#focusElement.blockIndex}"] .apply-to`);
}
if (focusElement) {
(focusElement as HTMLElement).focus();
}
this.#focusElement = null;
}
}
#renderApplyToRow(pattern: string, blockIndex: number): LitHtml.TemplateResult {
// clang-format off
return LitHtml.html`
<div class="row" data-block-index=${blockIndex}>
<div>${i18n.i18n.lockedString('Apply to')}</div>
<div class="separator">:</div>
${this.#renderEditable(pattern, 'apply-to')}
<${Buttons.Button.Button.litTagName}
title=${i18nString(UIStrings.removeBlock)}
.size=${Buttons.Button.Size.SMALL}
.iconUrl=${trashIconUrl}
.iconWidth=${'14px'}
.iconHeight=${'14px'}
.variant=${Buttons.Button.Variant.ROUND}
class="remove-block inline-button"
></${Buttons.Button.Button.litTagName}>
</div>
`;
// clang-format on
}
#renderHeaderRow(header: Protocol.Fetch.HeaderEntry, blockIndex: number, headerIndex: number):
LitHtml.TemplateResult {
// clang-format off
return LitHtml.html`
<div class="row padded" data-block-index=${blockIndex} data-header-index=${headerIndex}>
${this.#renderEditable(header.name, 'header-name red')}
<div class="separator">:</div>
${this.#renderEditable(header.value, 'header-value')}
<${Buttons.Button.Button.litTagName}
title=${i18nString(UIStrings.addHeader)}
.size=${Buttons.Button.Size.SMALL}
.iconUrl=${plusIconUrl}
.iconWidth=${'20px'}
.iconHeight=${'20px'}
.variant=${Buttons.Button.Variant.ROUND}
class="add-header inline-button"
></${Buttons.Button.Button.litTagName}>
<${Buttons.Button.Button.litTagName}
title=${i18nString(UIStrings.removeHeader)}
.size=${Buttons.Button.Size.SMALL}
.iconUrl=${trashIconUrl}
.iconWidth=${'14px'}
.iconHeight=${'14px'}
.variant=${Buttons.Button.Variant.ROUND}
class="remove-header inline-button"
></${Buttons.Button.Button.litTagName}>
</div>
`;
// clang-format on
}
#renderEditable(value: string, className?: string): LitHtml.TemplateResult {
// This uses LitHtml's `live`-directive, so that when checking whether to
// update during re-render, `value` is compared against the actual live DOM
// value of the contenteditable element and not the potentially outdated
// value from the previous render.
// clang-format off
return LitHtml.html`<span contenteditable="true" class="editable ${className}" tabindex="0" .innerText=${LitHtml.Directives.live(value)}></span>`;
// clang-format on
}
}
ComponentHelpers.CustomElements.defineComponent('devtools-sources-headers-view', HeadersViewComponent);
declare global {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface HTMLElementTagNameMap {
'devtools-sources-headers-view': HeadersViewComponent;
}
}