UNPKG

@eclipse-glsp/client

Version:

A sprotty-based client for GLSP

379 lines (332 loc) 14.8 kB
/******************************************************************************** * Copyright (c) 2020-2025 EclipseSource and others. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at * http://www.eclipse.org/legal/epl-2.0. * * This Source Code may also be made available under the following Secondary * Licenses when the conditions for such availability set forth in the Eclipse * Public License v. 2.0 are satisfied: GNU General Public License, version 2 * with the GNU Classpath Exception which is available at * https://www.gnu.org/software/classpath/license.html. * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { Action, GModelRoot, ILogger, LabeledAction, ValidationStatus, codiconCSSClasses, matchesKeystroke, toArray, type MaybePromise } from '@eclipse-glsp/sprotty'; import { AutocompleteResult, AutocompleteSettings } from 'autocompleter'; import { AutoCompleteValue } from './auto-complete-actions'; import { IValidationDecorator } from './validation-decorator'; export interface AutoCompleteSettings { readonly noSuggestionsMessage?: string; readonly suggestionsClass?: string; readonly debounceWaitMs?: number; readonly showOnFocus?: boolean; } export type CloseReason = 'submission' | 'blur' | 'escape'; export interface InputValidator { validate(input: string): Promise<ValidationStatus>; } export interface SuggestionProvider { provideSuggestions(input: string): Promise<LabeledAction[]>; } export interface InputValueInitializer { initializeValue(containerElement: HTMLElement, root: Readonly<GModelRoot>, ...contextElementIds: string[]): string; } export interface SuggestionSubmitHandler { executeFromSuggestion(input: LabeledAction | Action | Action[]): MaybePromise<void>; } export interface TextSubmitHandler { executeFromTextOnlyInput(input: string): void; } export interface AutoCompleteWidgetOptions { visibleSuggestionsChanged?: (suggestions: LabeledAction[]) => void; selectedSuggestionChanged?: (suggestion?: LabeledAction) => void; } // eslint-disable-next-line @typescript-eslint/no-var-requires const configureAutocomplete: (settings: AutocompleteSettings<LabeledAction>) => AutocompleteResult = require('autocompleter'); /** * The `AutoCompleteWidget` is a reusable UI element that provides a text input supporting auto-completion, * validation, validation messages, etc. * * An example for using it is available in the workflow diagram: * https://github.com/eclipse-glsp/glsp-client/blob/master/examples/workflow-glsp/src/direct-task-editing/direct-task-editor.ts */ export class AutoCompleteWidget { loadingIndicatorClasses = codiconCSSClasses('loading', false, true, ['loading']); protected containerElement: HTMLElement; protected inputElement: HTMLInputElement; protected loadingIndicator: HTMLSpanElement; protected autoCompleteResult: AutocompleteResult; protected contextActions?: LabeledAction[]; protected previousContent?: string; protected inputValidator?: InputValidator; protected validationDecorator: IValidationDecorator = IValidationDecorator.NO_DECORATION; protected textSubmitHandler?: TextSubmitHandler; protected observer?: MutationObserver; constructor( public autoSuggestionSettings: AutoCompleteSettings, public suggestionProvider: SuggestionProvider, public suggestionSubmitHandler: SuggestionSubmitHandler, // eslint-disable-next-line @typescript-eslint/no-empty-function protected notifyClose: (reason: CloseReason) => void = () => {}, protected logger?: ILogger, protected options?: AutoCompleteWidgetOptions ) {} configureValidation( inputValidator: InputValidator, validationDecorator: IValidationDecorator = IValidationDecorator.NO_DECORATION ): void { this.inputValidator = inputValidator; this.validationDecorator = validationDecorator; } configureTextSubmitHandler(textSubmitHandler: TextSubmitHandler): void { this.textSubmitHandler = textSubmitHandler; } initialize(containerElement: HTMLElement): void { this.containerElement = containerElement; this.inputElement = this.createInputElement(); this.containerElement.appendChild(this.inputElement); this.containerElement.style.position = 'absolute'; } protected createInputElement(): HTMLInputElement { const inputElement = document.createElement('input'); inputElement.style.position = 'absolute'; inputElement.spellcheck = false; inputElement.autocapitalize = 'false'; inputElement.autocomplete = 'off'; inputElement.style.width = '100%'; inputElement.addEventListener('keydown', event => this.handleKeyDown(event)); inputElement.addEventListener('blur', () => { if (this.containerElement.style.visibility !== 'hidden') { window.setTimeout(() => this.notifyClose('blur'), 200); } }); return inputElement; } protected handleKeyDown(event: KeyboardEvent): void { if (matchesKeystroke(event, 'Escape')) { this.notifyClose('escape'); return; } if (matchesKeystroke(event, 'Enter') && !this.isInputElementChanged() && this.isSuggestionAvailable()) { return; } if (this.isInputElementChanged()) { this.invalidateValidationResultAndContextActions(); } if (!matchesKeystroke(event, 'Enter') || this.isSuggestionAvailable()) { return; } if (!this.validationDecorator.isValidatedOk()) { event.stopImmediatePropagation(); return; } if (this.textSubmitHandler) { this.executeFromTextOnlyInput(); this.notifyClose('submission'); } } protected isInputElementChanged(): boolean { return this.inputElement.value !== this.previousContent; } protected invalidateValidationResultAndContextActions(): void { this.contextActions = undefined; this.validationDecorator.invalidate(); } open(root: Readonly<GModelRoot>, ...contextElementIds: string[]): void { this.contextActions = undefined; this.autoCompleteResult = configureAutocomplete(this.autocompleteSettings(root)); this.previousContent = this.inputElement.value; this.inputElement.setSelectionRange(0, this.inputElement.value.length); this.inputElement.focus(); } protected autocompleteSettings(root: Readonly<GModelRoot>): AutocompleteSettings<LabeledAction> { return { input: this.inputElement, emptyMsg: this.autoSuggestionSettings.noSuggestionsMessage, className: this.autoSuggestionSettings.suggestionsClass, showOnFocus: this.autoSuggestionSettings.showOnFocus, debounceWaitMs: this.autoSuggestionSettings.debounceWaitMs, minLength: -1, fetch: (text: string, update: (items: LabeledAction[]) => void) => this.updateSuggestions(update, text, root), onSelect: (item: LabeledAction) => this.onSelect(item), render: (item: LabeledAction, currentValue: string): HTMLDivElement | undefined => this.renderSuggestions(item, currentValue), customize: (input, inputRect, container, maxHeight) => { this.customizeInputElement(input, inputRect, container, maxHeight); const selectedSuggestionChanged = this.options?.selectedSuggestionChanged; if (selectedSuggestionChanged) { this.observer = new MutationObserver(mutations => this.handleContainerMutations(mutations, selectedSuggestionChanged)); this.observer.observe(container, { childList: true, attributes: true, subtree: true }); } } }; } protected customizeInputElement( input: HTMLInputElement | HTMLTextAreaElement, inputRect: DOMRect, container: HTMLDivElement, maxHeight: number ): void { // move container into our UIExtension container as this is already positioned correctly container.style.position = 'fixed'; if (this.containerElement) { this.containerElement.appendChild(container); } this.container = container; } protected container: HTMLDivElement; protected handleContainerMutations(mutations: MutationRecord[], selectionChanged: (action: LabeledAction | undefined) => void): void { const selectedElement = this.container.querySelector('.selected'); // Trigger selection changed event // eslint-disable-next-line no-null/no-null if (selectedElement !== null && selectedElement !== undefined) { const index = Array.from(this.container.children).indexOf(selectedElement); selectionChanged(this.contextActions?.[index]); } else { selectionChanged(undefined); } } protected updateSuggestions( update: (items: LabeledAction[]) => void, text: string, root: Readonly<GModelRoot>, ...contextElementIds: string[] ): void { this.onLoading(); this.doUpdateSuggestions(text, root) .then(actions => { this.contextActions = this.filterActions(text, actions); update(this.contextActions); this.options?.visibleSuggestionsChanged?.(this.contextActions); this.onLoaded('success'); }) .catch(reason => { if (this.logger) { this.logger.error(this, 'Failed to obtain suggestions', reason); } this.onLoaded('error'); }); } protected onLoading(): void { if (this.loadingIndicator && this.containerElement.contains(this.loadingIndicator)) { return; } this.loadingIndicator = document.createElement('span'); this.loadingIndicator.classList.add(...this.loadingIndicatorClasses); this.containerElement.appendChild(this.loadingIndicator); } protected doUpdateSuggestions(text: string, root: Readonly<GModelRoot>, ...contextElementIds: string[]): Promise<LabeledAction[]> { return this.suggestionProvider.provideSuggestions(text); } protected onLoaded(_success: 'success' | 'error'): void { if (this.containerElement.contains(this.loadingIndicator)) { this.containerElement.removeChild(this.loadingIndicator); } this.validationDecorator.invalidate(); this.validateInputIfNoContextActions(); this.previousContent = this.inputElement.value; } protected renderSuggestions(item: LabeledAction, value: string): HTMLDivElement { const itemElement = document.createElement('div'); const wordMatcher = this.escapeForRegExp(value).split(' ').join('|'); const regex = new RegExp(wordMatcher, 'gi'); if (item.icon) { this.renderIcon(itemElement, item.icon); } itemElement.innerHTML += item.label.replace(regex, match => '<em>' + match + '</em>').replace(/ /g, '&nbsp;'); return itemElement; } protected escapeForRegExp(value: string): string { return value.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); } protected renderIcon(itemElement: HTMLDivElement, icon: string): void { itemElement.innerHTML += `<span class="icon ${icon}"></span>`; } protected filterActions(filterText: string, actions: LabeledAction[]): LabeledAction[] { return toArray( actions.filter(action => { const label = action.label.toLowerCase(); const searchWords = filterText.split(' '); return searchWords.every(word => label.indexOf(word.toLowerCase()) !== -1); }) ); } protected onSelect(item: LabeledAction): void { if (AutoCompleteValue.is(item)) { this.inputElement.value = item.text; // trigger update of suggestions with an keyup event window.setTimeout(() => this.inputElement.dispatchEvent(new Event('input'))); } else { Promise.resolve(this.executeFromSuggestion(item)).then(() => { this.notifyClose('submission'); }); } } protected validateInputIfNoContextActions(): void { if (this.isNoOrExactlyOneMatchingContextAction()) { this.validateInput(); } else { this.validationDecorator.dispose(); } } private isNoOrExactlyOneMatchingContextAction(): boolean | undefined { return ( !this.isSuggestionAvailable() || (this.contextActions && this.contextActions.length === 1 && this.inputElement.value.endsWith(this.contextActions[0].label)) ); } protected isSuggestionAvailable(): boolean | undefined { return this.contextActions && this.contextActions.length > 0; } validateInput(): void { if (this.inputValidator) { this.inputValidator .validate(this.inputElement.value) .then(result => this.validationDecorator.decorateValidationResult(result)) .catch(error => this.handleErrorDuringValidation(error)); } } protected handleErrorDuringValidation(error: Error): void { if (this.logger) { this.logger.error(this, 'Failed to validate input', error); } this.validationDecorator.dispose(); } protected executeFromSuggestion(input: LabeledAction | Action[] | Action): void { this.suggestionSubmitHandler.executeFromSuggestion(input); } protected executeFromTextOnlyInput(): void { if (this.textSubmitHandler) { this.textSubmitHandler.executeFromTextOnlyInput(this.inputElement.value); } } get inputField(): HTMLInputElement { return this.inputElement; } dispose(): void { this.validationDecorator.dispose(); if (this.autoCompleteResult) { this.autoCompleteResult.destroy(); } this.observer?.disconnect(); } } export function toActionArray(input: LabeledAction | Action[] | Action): Action[] { if (LabeledAction.is(input)) { return input.actions; } else if (Action.is(input)) { return [input]; } return []; }