chrome-devtools-frontend
Version:
Chrome DevTools UI
1,007 lines (882 loc) • 33.8 kB
text/typescript
// Copyright 2021 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/no-imperative-dom-api */
/*
* Copyright (C) 2008 Apple Inc. All rights reserved.
* Copyright (C) 2011 Google Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
* its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
import * as Common from '../../core/common/common.js';
import * as Platform from '../../core/platform/platform.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import * as VisualLogging from '../visual_logging/visual_logging.js';
import * as ARIAUtils from './ARIAUtils.js';
import {appendStyle, rangeOfWord} from './DOMUtilities.js';
import {SuggestBox, type SuggestBoxDelegate, type Suggestion} from './SuggestBox.js';
import textPromptStyles from './textPrompt.css.js';
import {Tooltip} from './Tooltip.js';
import {cloneCustomElement, ElementFocusRestorer} from './UIUtils.js';
/**
* A custom element wrapper around TextPrompt that allows text-editing contents in-place.
*
* ## Usage ##
*
* ```
* <devtools-prompt>
* <b>Structured</b> content
* </devtools-prompt>
*
* ```
*
* @property completionTimeout Sets the delay for showing the autocomplete suggestion box.
* @event commit Editing is done and the result was accepted.
* @event cancel Editing was canceled.
* @event beforeautocomplete This is sent before the autocomplete suggestion box is triggered and before the <datalist>
* is read.
* @attribute editing Setting/removing this attribute starts/stops editing.
* @attribute completions Sets the `id` of the <datalist> containing the autocomplete options.
* @attribute placeholder Sets a placeholder that's shown in place of the text contents when editing if the text is too
* large.
*/
export class TextPromptElement extends HTMLElement {
static readonly observedAttributes = ['editing', 'completions', 'placeholder'];
readonly #shadow = this.attachShadow({mode: 'open'});
readonly #entrypoint = this.#shadow.createChild('span');
readonly #slot = this.#entrypoint.createChild('slot');
readonly #textPrompt = new TextPrompt();
#completionTimeout: number|null = null;
#completionObserver = new MutationObserver(this.#onMutate.bind(this));
constructor() {
super();
this.#textPrompt.initialize(this.#willAutoComplete.bind(this));
}
#onMutate(changes: MutationRecord[]): void {
const listId = this.getAttribute('completions');
if (!listId) {
return;
}
const checkIfNodeIsInCompletionList = (node: Node): boolean => {
if (node instanceof HTMLDataListElement) {
return node.id === listId;
}
if (node instanceof HTMLOptionElement) {
return Boolean(node.parentElement && checkIfNodeIsInCompletionList(node.parentElement));
}
return false;
};
const affectsCompletionList = (change: MutationRecord): boolean =>
change.addedNodes.values().some(checkIfNodeIsInCompletionList) ||
change.removedNodes.values().some(checkIfNodeIsInCompletionList) ||
checkIfNodeIsInCompletionList(change.target);
if (changes.some(affectsCompletionList)) {
this.#updateCompletions();
}
}
attributeChangedCallback(name: string, oldValue: string|null, newValue: string|null): void {
if (oldValue === newValue) {
return;
}
switch (name) {
case 'editing':
if (this.isConnected) {
if (newValue !== null && newValue !== 'false' && oldValue === null) {
this.#startEditing();
} else {
this.#stopEditing();
}
}
break;
case 'completions':
if (this.getAttribute('completions')) {
this.#completionObserver.observe(this, {childList: true, subtree: true});
this.#updateCompletions();
} else {
this.#textPrompt.clearAutocomplete();
this.#completionObserver.disconnect();
}
break;
}
}
#updateCompletions(): void {
if (this.isConnected) {
void this.#textPrompt.complete(/* force=*/ true);
}
}
async #willAutoComplete(expression: string, filter: string, force: boolean): Promise<Suggestion[]> {
this.dispatchEvent(new TextPromptElement.BeforeAutoCompleteEvent({expression, filter, force}));
const listId = this.getAttribute('completions');
if (!listId) {
return [];
}
const datalist = this.getComponentRoot()?.querySelectorAll<HTMLOptionElement>(`datalist#${listId} > option`);
if (!datalist?.length) {
return [];
}
return datalist.values()
.filter(option => option.textContent.startsWith(filter.toLowerCase()))
.map(option => ({text: option.textContent}))
.toArray();
}
#startEditing(): void {
const truncatedTextPlaceholder = this.getAttribute('placeholder');
const placeholder = this.#entrypoint.createChild('span');
if (truncatedTextPlaceholder === null) {
placeholder.textContent = this.#slot.deepInnerText();
} else {
placeholder.setTextContentTruncatedIfNeeded(this.#slot.deepInnerText(), truncatedTextPlaceholder);
}
this.#slot.remove();
const proxy = this.#textPrompt.attachAndStartEditing(placeholder, e => this.#done(e, /* commit=*/ true));
proxy.addEventListener('keydown', this.#editingValueKeyDown.bind(this));
placeholder.getComponentSelection()?.selectAllChildren(placeholder);
}
#stopEditing(): void {
this.#entrypoint.removeChildren();
this.#entrypoint.appendChild(this.#slot);
this.#textPrompt.detach();
}
connectedCallback(): void {
if (this.hasAttribute('editing')) {
this.attributeChangedCallback('editing', null, '');
}
}
#done(e: Event, commit: boolean): void {
const target = e.target as HTMLElement;
const text = target.textContent || '';
if (commit) {
this.dispatchEvent(new TextPromptElement.CommitEvent(text));
} else {
this.dispatchEvent(new TextPromptElement.CancelEvent());
}
e.consume();
}
#editingValueKeyDown(event: Event): void {
if (event.handled || !(event instanceof KeyboardEvent)) {
return;
}
if (event.key === 'Enter') {
this.#done(event, /* commit=*/ true);
} else if (Platform.KeyboardUtilities.isEscKey(event)) {
this.#done(event, /* commit=*/ false);
}
}
set completionTimeout(timeout: number) {
this.#completionTimeout = timeout;
this.#textPrompt.setAutocompletionTimeout(timeout);
}
override cloneNode(): Node {
const clone = cloneCustomElement(this);
if (this.#completionTimeout !== null) {
clone.completionTimeout = this.#completionTimeout;
}
return clone;
}
}
export namespace TextPromptElement {
export class CommitEvent extends CustomEvent<string> {
constructor(detail: string) {
super('commit', {detail});
}
}
export class CancelEvent extends CustomEvent<string> {
constructor() {
super('cancel');
}
}
export class BeforeAutoCompleteEvent extends CustomEvent<{expression: string, filter: string, force: boolean}> {
constructor(detail: {expression: string, filter: string, force: boolean}) {
super('beforeautocomplete', {detail});
}
}
}
customElements.define('devtools-prompt', TextPromptElement);
declare global {
interface HTMLElementTagNameMap {
'devtools-prompt': TextPromptElement;
}
}
export class TextPrompt extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements SuggestBoxDelegate {
private proxyElement!: HTMLElement|undefined;
private proxyElementDisplay: string;
private autocompletionTimeout: number;
#title: string;
private queryRange: TextUtils.TextRange.TextRange|null;
private previousText: string;
private currentSuggestion: Suggestion|null;
private completionRequestId: number;
private ghostTextElement: HTMLSpanElement;
private leftParenthesesIndices: number[];
private loadCompletions!: (
this: null,
arg1: string,
arg2: string,
arg3: boolean,
) => Promise<Suggestion[]>;
private completionStopCharacters!: string;
private usesSuggestionBuilder!: boolean;
#element?: Element;
private boundOnKeyDown?: ((ev: KeyboardEvent) => void);
private boundOnInput?: ((ev: Event) => void);
private boundOnMouseWheel?: ((event: Event) => void);
private boundClearAutocomplete?: (() => void);
private boundOnBlur?: ((ev: Event) => void);
private contentElement?: HTMLElement;
private suggestBox?: SuggestBox;
private isEditing?: boolean;
private focusRestorer?: ElementFocusRestorer;
private blurListener?: ((arg0: Event) => void);
private oldTabIndex?: number;
private completeTimeout?: number;
#disableDefaultSuggestionForEmptyInput?: boolean;
jslogContext: string|undefined = undefined;
constructor() {
super();
this.proxyElementDisplay = 'inline-block';
this.autocompletionTimeout = DefaultAutocompletionTimeout;
this.#title = '';
this.queryRange = null;
this.previousText = '';
this.currentSuggestion = null;
this.completionRequestId = 0;
this.ghostTextElement = document.createElement('span');
this.ghostTextElement.classList.add('auto-complete-text');
this.ghostTextElement.setAttribute('contenteditable', 'false');
this.leftParenthesesIndices = [];
ARIAUtils.setHidden(this.ghostTextElement, true);
}
initialize(
completions: (this: null, expression: string, filter: string, force: boolean) => Promise<Suggestion[]>,
stopCharacters?: string, usesSuggestionBuilder?: boolean): void {
this.loadCompletions = completions;
this.completionStopCharacters = stopCharacters || ' =:[({;,!+-*/&|^<>.';
this.usesSuggestionBuilder = usesSuggestionBuilder || false;
}
setAutocompletionTimeout(timeout: number): void {
this.autocompletionTimeout = timeout;
}
renderAsBlock(): void {
this.proxyElementDisplay = 'block';
}
/**
* Clients should never attach any event listeners to the |element|. Instead,
* they should use the result of this method to attach listeners for bubbling events.
*/
attach(element: Element): Element {
return this.#attach(element);
}
/**
* Clients should never attach any event listeners to the |element|. Instead,
* they should use the result of this method to attach listeners for bubbling events
* or the |blurListener| parameter to register a "blur" event listener on the |element|
* (since the "blur" event does not bubble.)
*/
attachAndStartEditing(element: Element, blurListener: (arg0: Event) => void): Element {
const proxyElement = this.#attach(element);
this.startEditing(blurListener);
return proxyElement;
}
#attach(element: Element): Element {
if (this.proxyElement) {
throw new Error('Cannot attach an attached TextPrompt');
}
this.#element = element;
this.boundOnKeyDown = this.onKeyDown.bind(this);
this.boundOnInput = this.onInput.bind(this);
this.boundOnMouseWheel = this.onMouseWheel.bind(this);
this.boundClearAutocomplete = this.clearAutocomplete.bind(this);
this.boundOnBlur = this.onBlur.bind(this);
this.proxyElement = element.ownerDocument.createElement('span');
appendStyle(this.proxyElement, textPromptStyles);
this.contentElement = this.proxyElement.createChild('div', 'text-prompt-root');
this.proxyElement.style.display = this.proxyElementDisplay;
if (element.parentElement) {
element.parentElement.insertBefore(this.proxyElement, element);
}
this.contentElement.appendChild(element);
let jslog = VisualLogging.textField().track({
keydown: 'ArrowLeft|ArrowUp|PageUp|Home|PageDown|ArrowRight|ArrowDown|End|Space|Tab|Enter|Escape',
change: true,
});
if (this.jslogContext) {
jslog = jslog.context(this.jslogContext);
}
if (!this.#element.hasAttribute('jslog')) {
this.#element.setAttribute('jslog', `${jslog}`);
}
this.#element.classList.add('text-prompt');
ARIAUtils.markAsTextBox(this.#element);
ARIAUtils.setAutocomplete(this.#element, ARIAUtils.AutocompleteInteractionModel.BOTH);
ARIAUtils.setHasPopup(this.#element, ARIAUtils.PopupRole.LIST_BOX);
this.#element.setAttribute('contenteditable', 'plaintext-only');
this.element().addEventListener('keydown', this.boundOnKeyDown, false);
this.#element.addEventListener('input', this.boundOnInput, false);
this.#element.addEventListener('wheel', this.boundOnMouseWheel, false);
this.#element.addEventListener('selectstart', this.boundClearAutocomplete, false);
this.#element.addEventListener('blur', this.boundOnBlur, false);
this.suggestBox = new SuggestBox(this, 20);
if (this.#title) {
Tooltip.install(this.proxyElement, this.#title);
}
return this.proxyElement;
}
element(): HTMLElement {
if (!this.#element) {
throw new Error('Expected an already attached element!');
}
return this.#element as HTMLElement;
}
detach(): void {
this.removeFromElement();
if (this.focusRestorer) {
this.focusRestorer.restore();
}
if (this.proxyElement?.parentElement) {
this.proxyElement.parentElement.insertBefore(this.element(), this.proxyElement);
this.proxyElement.remove();
}
delete this.proxyElement;
this.element().classList.remove('text-prompt');
this.element().removeAttribute('contenteditable');
this.element().removeAttribute('role');
ARIAUtils.clearAutocomplete(this.element());
ARIAUtils.setHasPopup(this.element(), ARIAUtils.PopupRole.FALSE);
}
textWithCurrentSuggestion(): string {
const text = this.text();
if (!this.queryRange || !this.currentSuggestion) {
return text;
}
const suggestion = this.currentSuggestion.text;
return text.substring(0, this.queryRange.startColumn) + suggestion + text.substring(this.queryRange.endColumn);
}
text(): string {
let text: string = this.element().textContent || '';
if (this.ghostTextElement.parentNode) {
const addition = this.ghostTextElement.textContent || '';
text = text.substring(0, text.length - addition.length);
}
return text;
}
setText(text: string): void {
this.clearAutocomplete();
this.element().textContent = text;
this.previousText = this.text();
if (this.element().hasFocus()) {
this.moveCaretToEndOfPrompt();
this.element().scrollIntoView();
}
}
setSelectedRange(startIndex: number, endIndex: number): void {
if (startIndex < 0) {
throw new RangeError('Selected range start must be a nonnegative integer');
}
const textContent = this.element().textContent;
const textContentLength = textContent ? textContent.length : 0;
if (endIndex > textContentLength) {
endIndex = textContentLength;
}
if (endIndex < startIndex) {
endIndex = startIndex;
}
const textNode = (this.element().childNodes[0] as Node);
const range = new Range();
range.setStart(textNode, startIndex);
range.setEnd(textNode, endIndex);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
focus(): void {
this.element().focus();
}
title(): string {
return this.#title;
}
setTitle(title: string): void {
this.#title = title;
if (this.proxyElement) {
Tooltip.install(this.proxyElement, title);
}
}
setPlaceholder(placeholder: string, ariaPlaceholder?: string): void {
if (placeholder) {
this.element().setAttribute('data-placeholder', placeholder);
// TODO(https://github.com/nvaccess/nvda/issues/10164): Remove ariaPlaceholder once the NVDA bug is fixed
// ariaPlaceholder and placeholder may differ, like in case the placeholder contains a '?'
ARIAUtils.setPlaceholder(this.element(), ariaPlaceholder || placeholder);
} else {
this.element().removeAttribute('data-placeholder');
ARIAUtils.setPlaceholder(this.element(), null);
}
}
setEnabled(enabled: boolean): void {
if (enabled) {
this.element().setAttribute('contenteditable', 'plaintext-only');
} else {
this.element().removeAttribute('contenteditable');
}
this.element().classList.toggle('disabled', !enabled);
}
private removeFromElement(): void {
this.clearAutocomplete();
this.element().removeEventListener(
'keydown', (this.boundOnKeyDown as (this: HTMLElement, arg1: Event) => void), false);
this.element().removeEventListener('input', (this.boundOnInput as (this: HTMLElement, arg1: Event) => void), false);
this.element().removeEventListener(
'selectstart', (this.boundClearAutocomplete as (this: HTMLElement, arg1: Event) => void), false);
this.element().removeEventListener('blur', (this.boundOnBlur as (this: HTMLElement, arg1: Event) => void), false);
if (this.isEditing) {
this.stopEditing();
}
if (this.suggestBox) {
this.suggestBox.hide();
}
}
private startEditing(blurListener?: ((arg0: Event) => void)): void {
this.isEditing = true;
if (this.contentElement) {
this.contentElement.classList.add('text-prompt-editing');
}
this.focusRestorer = new ElementFocusRestorer(this.element());
if (blurListener) {
this.blurListener = blurListener;
this.element().addEventListener('blur', this.blurListener, false);
}
this.oldTabIndex = this.element().tabIndex;
if (this.element().tabIndex < 0) {
this.element().tabIndex = 0;
}
if (!this.text()) {
this.autoCompleteSoon();
}
}
private stopEditing(): void {
this.element().tabIndex = (this.oldTabIndex as number);
if (this.blurListener) {
this.element().removeEventListener('blur', this.blurListener, false);
}
if (this.contentElement) {
this.contentElement.classList.remove('text-prompt-editing');
}
delete this.isEditing;
}
onMouseWheel(_event: Event): void {
// Subclasses can implement.
}
onKeyDown(event: KeyboardEvent): void {
let handled = false;
if (this.isSuggestBoxVisible() && this.suggestBox?.keyPressed(event)) {
void VisualLogging.logKeyDown(this.suggestBox.element, event);
event.consume(true);
return;
}
switch (event.key) {
case 'Tab':
handled = this.tabKeyPressed(event);
break;
case 'ArrowLeft':
case 'ArrowUp':
case 'PageUp':
case 'Home':
this.clearAutocomplete();
break;
case 'PageDown':
case 'ArrowRight':
case 'ArrowDown':
case 'End':
if (this.isCaretAtEndOfPrompt()) {
handled = this.acceptAutoComplete();
} else {
this.clearAutocomplete();
}
break;
case 'Escape':
if (this.isSuggestBoxVisible() || this.currentSuggestion) {
this.clearAutocomplete();
handled = true;
}
break;
case ' ': // Space
if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
this.autoCompleteSoon(true);
handled = true;
}
break;
}
if (event.key === 'Enter') {
event.preventDefault();
}
if (handled) {
event.consume(true);
}
}
private acceptSuggestionOnStopCharacters(key: string): boolean {
if (!this.currentSuggestion || !this.queryRange || key.length !== 1 ||
!this.completionStopCharacters?.includes(key)) {
return false;
}
const query = this.text().substring(this.queryRange.startColumn, this.queryRange.endColumn);
if (query && this.currentSuggestion.text.startsWith(query + key)) {
this.queryRange.endColumn += 1;
return this.acceptAutoComplete();
}
return false;
}
onInput(ev: Event): void {
const event = (ev as InputEvent);
let text = this.text();
const currentEntry = event.data;
if (event.inputType === 'insertFromPaste' && text.includes('\n')) {
/* Ensure that we remove any linebreaks from copied/pasted content
* to avoid breaking the rendering of the filter bar.
* See crbug.com/849563.
* We don't let users enter linebreaks when
* typing manually, so we should escape them if copying text in.
*/
text = Platform.StringUtilities.stripLineBreaks(text);
this.setText(text);
}
// Skip the current ')' entry if the caret is right before a ')' and there's an unmatched '('.
const caretPosition = this.getCaretPosition();
if (currentEntry === ')' && caretPosition >= 0 && this.leftParenthesesIndices.length > 0) {
const nextCharAtCaret = text[caretPosition];
if (nextCharAtCaret === ')' && this.tryMatchingLeftParenthesis(caretPosition)) {
text = text.substring(0, caretPosition) + text.substring(caretPosition + 1);
this.setText(text);
return;
}
}
if (currentEntry && !this.acceptSuggestionOnStopCharacters(currentEntry)) {
const hasCommonPrefix = text.startsWith(this.previousText) || this.previousText.startsWith(text);
if (this.queryRange && hasCommonPrefix) {
this.queryRange.endColumn += text.length - this.previousText.length;
}
}
this.refreshGhostText();
this.previousText = text;
this.dispatchEventToListeners(Events.TEXT_CHANGED);
this.autoCompleteSoon();
}
acceptAutoComplete(): boolean {
let result = false;
if (this.isSuggestBoxVisible() && this.suggestBox) {
result = this.suggestBox.acceptSuggestion();
}
if (!result) {
result = this.#acceptSuggestion();
}
if (this.usesSuggestionBuilder && result) {
// Trigger autocompletions for text prompts using suggestion builders
this.autoCompleteSoon();
}
return result;
}
clearAutocomplete(): void {
const beforeText = this.textWithCurrentSuggestion();
if (this.isSuggestBoxVisible() && this.suggestBox) {
this.suggestBox.hide();
}
this.clearAutocompleteTimeout();
this.queryRange = null;
this.refreshGhostText();
if (beforeText !== this.textWithCurrentSuggestion()) {
this.dispatchEventToListeners(Events.TEXT_CHANGED);
}
this.currentSuggestion = null;
}
private onBlur(): void {
this.clearAutocomplete();
}
private refreshGhostText(): void {
if (this.currentSuggestion?.hideGhostText) {
this.ghostTextElement.remove();
return;
}
if (this.queryRange && this.currentSuggestion && this.isCaretAtEndOfPrompt() &&
this.currentSuggestion.text.startsWith(this.text().substring(this.queryRange.startColumn))) {
this.ghostTextElement.textContent =
this.currentSuggestion.text.substring(this.queryRange.endColumn - this.queryRange.startColumn);
this.element().appendChild(this.ghostTextElement);
} else {
this.ghostTextElement.remove();
}
}
private clearAutocompleteTimeout(): void {
if (this.completeTimeout) {
clearTimeout(this.completeTimeout);
delete this.completeTimeout;
}
this.completionRequestId++;
}
autoCompleteSoon(force?: boolean): void {
const immediately = this.isSuggestBoxVisible() || force;
if (!this.completeTimeout) {
this.completeTimeout =
window.setTimeout(this.complete.bind(this, force), immediately ? 0 : this.autocompletionTimeout);
}
}
async complete(force?: boolean): Promise<void> {
this.clearAutocompleteTimeout();
if (!this.element().isConnected) {
return;
}
const selection = this.element().getComponentSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const selectionRange = selection.getRangeAt(0);
let shouldExit;
if (!force && !this.isCaretAtEndOfPrompt() && !this.isSuggestBoxVisible()) {
shouldExit = true;
} else if (!selection.isCollapsed) {
shouldExit = true;
}
if (shouldExit) {
this.clearAutocomplete();
return;
}
const wordQueryRange = rangeOfWord(
selectionRange.startContainer, selectionRange.startOffset, this.completionStopCharacters, this.element(),
'backward');
const expressionRange = wordQueryRange.cloneRange();
expressionRange.collapse(true);
expressionRange.setStartBefore(this.element());
const completionRequestId = ++this.completionRequestId;
const completions =
await this.loadCompletions.call(null, expressionRange.toString(), wordQueryRange.toString(), Boolean(force));
this.completionsReady(completionRequestId, (selection), wordQueryRange, Boolean(force), completions);
}
disableDefaultSuggestionForEmptyInput(): void {
this.#disableDefaultSuggestionForEmptyInput = true;
}
private boxForAnchorAtStart(selection: Selection, textRange: Range): AnchorBox {
const rangeCopy = selection.getRangeAt(0).cloneRange();
const anchorElement = document.createElement('span');
anchorElement.textContent = '\u200B';
textRange.insertNode(anchorElement);
const box = anchorElement.boxInWindow(window);
anchorElement.remove();
selection.removeAllRanges();
selection.addRange(rangeCopy);
return box;
}
additionalCompletions(_query: string): Suggestion[] {
return [];
}
private completionsReady(
completionRequestId: number, selection: Selection, originalWordQueryRange: Range, force: boolean,
completions: Suggestion[]): void {
if (this.completionRequestId !== completionRequestId) {
return;
}
const query = originalWordQueryRange.toString();
// Filter out dupes.
const store = new Set<string>();
completions = completions.filter(item => !store.has(item.text) && Boolean(store.add(item.text)));
if (query || force) {
if (query) {
completions = completions.concat(this.additionalCompletions(query));
} else {
completions = this.additionalCompletions(query).concat(completions);
}
}
if (!completions.length) {
this.clearAutocomplete();
return;
}
const selectionRange = selection.getRangeAt(0);
const fullWordRange = document.createRange();
fullWordRange.setStart(originalWordQueryRange.startContainer, originalWordQueryRange.startOffset);
fullWordRange.setEnd(selectionRange.endContainer, selectionRange.endOffset);
if (query + selectionRange.toString() !== fullWordRange.toString()) {
return;
}
const beforeRange = document.createRange();
beforeRange.setStart(this.element(), 0);
beforeRange.setEnd(fullWordRange.startContainer, fullWordRange.startOffset);
this.queryRange = new TextUtils.TextRange.TextRange(
0, beforeRange.toString().length, 0, beforeRange.toString().length + fullWordRange.toString().length);
const shouldSelect = !this.#disableDefaultSuggestionForEmptyInput || Boolean(this.text());
if (this.suggestBox) {
this.suggestBox.updateSuggestions(
this.boxForAnchorAtStart(selection, fullWordRange), completions, shouldSelect, !this.isCaretAtEndOfPrompt(),
this.text());
}
}
applySuggestion(suggestion: Suggestion|null, isIntermediateSuggestion?: boolean): void {
this.currentSuggestion = suggestion;
this.refreshGhostText();
if (isIntermediateSuggestion) {
this.dispatchEventToListeners(Events.TEXT_CHANGED);
}
}
acceptSuggestion(): void {
this.#acceptSuggestion();
}
#acceptSuggestion(): boolean {
if (!this.queryRange) {
return false;
}
const suggestionLength = this.currentSuggestion ? this.currentSuggestion.text.length : 0;
const selectionRange = this.currentSuggestion ? this.currentSuggestion.selectionRange : null;
const endColumn = selectionRange ? selectionRange.endColumn : suggestionLength;
const startColumn = selectionRange ? selectionRange.startColumn : suggestionLength;
this.element().textContent = this.textWithCurrentSuggestion();
this.setDOMSelection(this.queryRange.startColumn + startColumn, this.queryRange.startColumn + endColumn);
this.updateLeftParenthesesIndices();
this.clearAutocomplete();
this.dispatchEventToListeners(Events.TEXT_CHANGED);
return true;
}
ownerElement(): Element {
return this.element();
}
setDOMSelection(startColumn: number, endColumn: number): void {
this.element().normalize();
const node = this.element().childNodes[0];
if (!node || node === this.ghostTextElement) {
return;
}
const range = document.createRange();
range.setStart(node, startColumn);
range.setEnd(node, endColumn);
const selection = this.element().getComponentSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
isSuggestBoxVisible(): boolean {
return this.suggestBox?.visible() ?? false;
}
private isCaretAtEndOfPrompt(): boolean {
const selection = this.element().getComponentSelection();
if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) {
return false;
}
const selectionRange = selection.getRangeAt(0);
let node: (Node|null)|Node = selectionRange.startContainer;
if (!node.isSelfOrDescendant(this.element())) {
return false;
}
if (this.ghostTextElement.isAncestor(node)) {
return true;
}
if (node.nodeType === Node.TEXT_NODE && selectionRange.startOffset < (node.nodeValue || '').length) {
return false;
}
let foundNextText = false;
while (node) {
if (node.nodeType === Node.TEXT_NODE && node.nodeValue?.length) {
if (foundNextText && !this.ghostTextElement.isAncestor(node)) {
return false;
}
foundNextText = true;
}
node = node.traverseNextNode(this.#element);
}
return true;
}
moveCaretToEndOfPrompt(): void {
const selection = this.element().getComponentSelection();
const selectionRange = document.createRange();
let container: Node = this.element();
while (container.lastChild) {
container = container.lastChild;
}
let offset = 0;
if (container.nodeType === Node.TEXT_NODE) {
const textNode = (container as Text);
offset = (textNode.textContent || '').length;
}
selectionRange.setStart(container, offset);
selectionRange.setEnd(container, offset);
if (selection) {
selection.removeAllRanges();
selection.addRange(selectionRange);
}
}
/**
* -1 if no caret can be found in text prompt
*/
private getCaretPosition(): number {
if (!this.element().hasFocus()) {
return -1;
}
const selection = this.element().getComponentSelection();
if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) {
return -1;
}
const selectionRange = selection.getRangeAt(0);
if (selectionRange.startOffset !== selectionRange.endOffset) {
return -1;
}
return selectionRange.startOffset;
}
tabKeyPressed(_event: Event): boolean {
return this.acceptAutoComplete();
}
/**
* Try matching the most recent open parenthesis with the given right
* parenthesis, and closes the matched left parenthesis if found.
* Return the result of the matching.
*/
private tryMatchingLeftParenthesis(rightParenthesisIndex: number): boolean {
const leftParenthesesIndices = this.leftParenthesesIndices;
if (leftParenthesesIndices.length === 0 || rightParenthesisIndex < 0) {
return false;
}
for (let i = leftParenthesesIndices.length - 1; i >= 0; --i) {
if (leftParenthesesIndices[i] < rightParenthesisIndex) {
leftParenthesesIndices.splice(i, 1);
return true;
}
}
return false;
}
private updateLeftParenthesesIndices(): void {
const text = this.text();
const leftParenthesesIndices: number[] = this.leftParenthesesIndices = [];
for (let i = 0; i < text.length; ++i) {
if (text[i] === '(') {
leftParenthesesIndices.push(i);
}
}
}
suggestBoxForTest(): SuggestBox|undefined {
return this.suggestBox;
}
}
const DefaultAutocompletionTimeout = 250;
export const enum Events {
TEXT_CHANGED = 'TextChanged',
}
export interface EventTypes {
[Events.TEXT_CHANGED]: void;
}