| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/enforce-custom-element-definitions-location */ |
| |
| import * as CodeHighlighter from '../../../ui/components/code_highlighter/code_highlighter.js'; |
| import codeHighlighterStyles from '../../../ui/components/code_highlighter/codeHighlighter.css.js'; |
| import * as Lit from '../../../ui/lit/lit.js'; |
| import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; |
| |
| import contentEditableStyles from './suggestionInput.css.js'; |
| |
| const mod = (a: number, n: number): number => { |
| return ((a % n) + n) % n; |
| }; |
| |
| function assert<T>( |
| predicate: T, |
| message = 'Assertion failed!', |
| ): asserts predicate { |
| if (!predicate) { |
| throw new Error(message); |
| } |
| } |
| |
| const {html, Decorators, Directives, LitElement} = Lit; |
| const {customElement, property, state} = Decorators; |
| const {classMap} = Directives; |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'devtools-suggestion-input': SuggestionInput; |
| 'devtools-editable-content': EditableContent; |
| 'devtools-suggestion-box': SuggestionBox; |
| } |
| } |
| |
| const jsonPropertyOptions = { |
| hasChanged(value: unknown, oldValue: unknown): boolean { |
| return JSON.stringify(value) !== JSON.stringify(oldValue); |
| }, |
| attribute: false, |
| }; |
| |
| @customElement('devtools-editable-content') |
| 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: Array<[string, (event: Event) => void]>; |
| constructor(listeners: Array<[string, (event: Event) => void]>) { |
| super(SuggestionInitEvent.eventName); |
| this.listeners = listeners; |
| } |
| } |
| |
| type SuggestionFilter = (option: string, query: string) => boolean; |
| |
| const defaultSuggestionFilter = (option: string, query: string): boolean => |
| option.toLowerCase().startsWith(query.toLowerCase()); |
| |
| /** |
| * @fires SuggestionInitEvent#suggestioninit |
| * @fires SuggestEvent#suggest |
| */ |
| @customElement('devtools-suggestion-box') |
| class SuggestionBox extends LitElement { |
| @property(jsonPropertyOptions) declare options: readonly string[]; |
| @property() declare expression: string; |
| @property() declare suggestionFilter?: SuggestionFilter; |
| |
| @state() 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: Lit.PropertyValues<this>): void { |
| if (changedProperties.has('options')) { |
| this.options = Object.freeze([...this.options].sort()); |
| } |
| if (changedProperties.has('expression') || changedProperties.has('options')) { |
| this.cursor = 0; |
| this.#suggestions = this.options.filter( |
| option => (this.suggestionFilter || defaultSuggestionFilter)(option, this.expression), |
| ); |
| } |
| } |
| |
| protected override render(): Lit.TemplateResult|undefined { |
| if (this.#suggestions.length === 0) { |
| return; |
| } |
| |
| // clang-format off |
| return html`<style>${contentEditableStyles}</style><ul class="suggestions"> |
| ${this.#suggestions.map((suggestion, index) => html` |
| <li class=${classMap({selected: index === this.cursor})} |
| @mousedown=${this.#dispatchSuggestEvent.bind(this, suggestion)} |
| jslog=${VisualLogging.item('suggestion').track({ click: true, resize: true })}> |
| ${suggestion} |
| </li>`)} |
| </ul>`; |
| // clang-format on |
| } |
| } |
| |
| @customElement('devtools-suggestion-input') |
| export class SuggestionInput extends LitElement { |
| static override shadowRootOptions = { |
| ...LitElement.shadowRootOptions, |
| delegatesFocus: true, |
| } as const; |
| |
| /** |
| * State passed to devtools-suggestion-box. |
| */ |
| @property(jsonPropertyOptions) declare options: readonly string[]; |
| @property({type: Boolean}) declare autocomplete?: boolean; |
| @property() declare suggestionFilter?: SuggestionFilter; |
| @state() declare expression: string; |
| |
| /** |
| * State passed to devtools-editable-content. |
| */ |
| @property() declare placeholder: string; |
| @property() declare value: string; |
| @property({type: Boolean}) declare disabled: boolean; |
| @property({type: Boolean}) declare strikethrough: boolean; |
| @property() declare mimeType: string; |
| @property() declare jslogContext?: string; |
| |
| constructor() { |
| super(); |
| |
| this.options = []; |
| this.expression = ''; |
| |
| this.placeholder = ''; |
| this.value = ''; |
| this.disabled = false; |
| this.strikethrough = true; |
| this.mimeType = ''; |
| this.autocomplete = true; |
| this.addEventListener('blur', this.#handleBlurEvent); |
| let jslog = VisualLogging.value().track({keydown: 'ArrowUp|ArrowDown|Enter', change: true, click: true}); |
| if (this.jslogContext) { |
| jslog = jslog.context(this.jslogContext); |
| } |
| this.setAttribute('jslog', jslog.toString()); |
| } |
| |
| #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: Lit.PropertyValues<this>, |
| ): void { |
| if (properties.has('value')) { |
| this.expression = this.value; |
| } |
| } |
| |
| protected override render(): Lit.TemplateResult { |
| // clang-format off |
| return html`<style>${contentEditableStyles}</style> |
| <style>${codeHighlighterStyles}</style> |
| <devtools-editable-content |
| ?disabled=${this.disabled} |
| class=${classMap({ |
| strikethrough: !this.strikethrough, |
| })} |
| .enterKeyHint=${'done'} |
| .value=${this.value} |
| .mimeType=${this.mimeType} |
| @focus=${this.#handleFocusEvent} |
| @input=${this.#handleInputEvent} |
| @keydown=${this.#handleKeyDownEvent} |
| autocapitalize="off" |
| inputmode="text" |
| placeholder=${this.placeholder} |
| spellcheck="false" |
| ></devtools-editable-content> |
| <devtools-suggestion-box |
| @suggestioninit=${this.#handleSuggestionInitEvent} |
| @suggest=${this.#handleSuggestEvent} |
| .options=${this.options} |
| .suggestionFilter=${this.suggestionFilter} |
| .expression=${this.autocomplete ? this.expression : ''} |
| ></devtools-suggestion-box>`; |
| // clang-format on |
| } |
| } |