chrome-devtools-frontend
Version:
Chrome DevTools UI
337 lines (281 loc) • 9.01 kB
text/typescript
// 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 CodeHighlighter from '../../../ui/components/code_highlighter/code_highlighter.js';
// eslint-disable-next-line rulesdir/es_modules_import
import codeHighlighterStyles from
'../../../ui/components/code_highlighter/codeHighlighter.css.js';
import * as LitHtml from '../../../ui/lit-html/lit-html.js';
import contentEditableStyles from './recorderInput.css.js';
import {assert, mod} from './util.js';
const {html, Decorators, Directives, LitElement} = LitHtml;
const {customElement, property, state} = Decorators;
const {classMap} = Directives;
declare global {
interface HTMLElementTagNameMap {
'devtools-recorder-input': RecorderInput;
'devtools-editable-content': EditableContent;
'devtools-suggestion-box': SuggestionBox;
}
}
const jsonPropertyOptions = {
hasChanged(value: unknown, oldValue: unknown): boolean {
return JSON.stringify(value) !== JSON.stringify(oldValue);
},
};
class EditableContent extends HTMLElement {
static get observedAttributes(): string[] {
return ['disabled', 'placeholder'];
}
set disabled(disabled: boolean) {
this.contentEditable = String(!disabled);
}
get disabled(): boolean {
return this.contentEditable !== 'true';
}
set value(value: string) {
this.innerText = value;
this.#highlight();
}
get value(): string {
return this.innerText;
}
set mimeType(type: string) {
this.#mimeType = type;
this.#highlight();
}
get mimeType(): string {
return this.#mimeType;
}
#mimeType = '';
constructor() {
super();
this.contentEditable = 'true';
this.tabIndex = 0;
this.addEventListener('focus', () => {
this.innerHTML = this.innerText;
});
this.addEventListener('blur', this.#highlight.bind(this));
}
#highlight(): void {
if (this.#mimeType) {
void CodeHighlighter.CodeHighlighter.highlightNode(this, this.#mimeType);
}
}
attributeChangedCallback(name: string, _: string|null, value: string|null): void {
switch (name) {
case 'disabled':
this.disabled = value !== null;
break;
}
}
}
/**
* Contains a suggestion emitted due to action by the user.
*/
class SuggestEvent extends Event {
static readonly eventName = 'suggest';
declare suggestion: string;
constructor(suggestion: string) {
super(SuggestEvent.eventName);
this.suggestion = suggestion;
}
}
/**
* Parents should listen for this event and register the listeners provided by
* this event.
*/
class SuggestionInitEvent extends Event {
static readonly eventName = 'suggestioninit';
listeners: [string, (event: Event) => void][];
constructor(listeners: [string, (event: Event) => void][]) {
super(SuggestionInitEvent.eventName);
this.listeners = listeners;
}
}
/**
* @fires SuggestionInitEvent#suggestioninit
* @fires SuggestEvent#suggest
*/
class SuggestionBox extends LitElement {
static override styles = [contentEditableStyles];
declare options: Readonly<string[]>;
declare expression: string;
private declare cursor: number;
#suggestions: string[] = [];
constructor() {
super();
this.options = [];
this.expression = '';
this.cursor = 0;
}
#handleKeyDownEvent = (event: Event): void => {
assert(event instanceof KeyboardEvent, 'Bound to the wrong event.');
if (this.#suggestions.length > 0) {
switch (event.key) {
case 'ArrowDown':
event.stopPropagation();
event.preventDefault();
this.#moveCursor(1);
break;
case 'ArrowUp':
event.stopPropagation();
event.preventDefault();
this.#moveCursor(-1);
break;
}
}
switch (event.key) {
case 'Enter':
if (this.#suggestions[this.cursor]) {
this.#dispatchSuggestEvent(this.#suggestions[this.cursor]);
}
event.preventDefault();
break;
}
};
#moveCursor(delta: number): void {
this.cursor = mod(this.cursor + delta, this.#suggestions.length);
}
#dispatchSuggestEvent(suggestion: string): void {
this.dispatchEvent(new SuggestEvent(suggestion));
}
override connectedCallback(): void {
super.connectedCallback();
this.dispatchEvent(
new SuggestionInitEvent([['keydown', this.#handleKeyDownEvent]]),
);
}
override willUpdate(changedProperties: LitHtml.PropertyValues<this>): void {
if (changedProperties.has('options')) {
this.options = Object.freeze([...this.options].sort());
}
if (changedProperties.has('expression')) {
this.cursor = 0;
this.#suggestions = this.options.filter(
option => option.startsWith(this.expression),
);
}
}
protected override render(): LitHtml.TemplateResult|undefined {
if (this.#suggestions.length === 0) {
return;
}
return html`<ul class="suggestions">
${this.#suggestions.map((suggestion, index) => {
return html`<li
class=${classMap({
selected: index === this.cursor,
})}
=${this.#dispatchSuggestEvent.bind(this, suggestion)}
>
${suggestion}
</li>`;
})}
</ul>`;
}
}
export class RecorderInput extends LitElement {
static override shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
} as const;
static override styles = [contentEditableStyles, codeHighlighterStyles];
/**
* State passed to devtools-suggestion-box.
*/
declare options: Readonly<string[]>;
declare expression: string;
/**
* State passed to devtools-editable-content.
*/
declare placeholder: string;
declare value: string;
declare disabled: boolean;
declare mimeType: string;
constructor() {
super();
this.options = [];
this.expression = '';
this.placeholder = '';
this.value = '';
this.disabled = false;
this.mimeType = '';
this.addEventListener('blur', this.#handleBlurEvent);
}
#cachedEditableContent?: EditableContent;
get #editableContent(): EditableContent {
if (this.#cachedEditableContent) {
return this.#cachedEditableContent;
}
const node = this.renderRoot.querySelector('devtools-editable-content');
if (!node) {
throw new Error('Attempted to query node before rendering.');
}
this.#cachedEditableContent = node;
return node;
}
#handleBlurEvent = (): void => {
window.getSelection()?.removeAllRanges();
this.value = this.#editableContent.value;
this.expression = this.#editableContent.value;
};
#handleFocusEvent = (event: FocusEvent): void => {
assert(event.target instanceof Node);
const range = document.createRange();
range.selectNodeContents(event.target);
const selection = window.getSelection() as Selection;
selection.removeAllRanges();
selection.addRange(range);
};
#handleKeyDownEvent = (event: KeyboardEvent): void => {
if (event.key === 'Enter') {
event.preventDefault();
}
};
#handleInputEvent = (event: {target: EditableContent}): void => {
this.expression = event.target.value;
};
#handleSuggestionInitEvent = (event: SuggestionInitEvent): void => {
for (const [name, listener] of event.listeners) {
this.addEventListener(name, listener);
}
};
#handleSuggestEvent = (event: SuggestEvent): void => {
this.#editableContent.value = event.suggestion;
// If actions result in a `focus` after this blur, then the blur won't
// happen. `setTimeout` guarantees `blur` will always come after `focus`.
setTimeout(this.blur.bind(this), 0);
};
protected override willUpdate(
properties: LitHtml.PropertyValues<this>,
): void {
if (properties.has('value')) {
this.expression = this.value;
}
}
protected override render(): LitHtml.TemplateResult {
return html`<devtools-editable-content
?disabled=${this.disabled}
.enterKeyHint=${'done'}
.value=${this.value}
.mimeType=${this.mimeType}
=${this.#handleFocusEvent}
=${this.#handleInputEvent}
=${this.#handleKeyDownEvent}
autocapitalize="off"
inputmode="text"
placeholder=${this.placeholder}
spellcheck="false"
></devtools-editable-content>
<devtools-suggestion-box
=${this.#handleSuggestionInitEvent}
=${this.#handleSuggestEvent}
.options=${this.options}
.expression=${this.expression}
></devtools-suggestion-box>`;
}
}