Path: blob/main/src/vs/base/browser/ui/inputbox/inputBox.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as dom from '../../dom.js';6import * as cssJs from '../../cssValue.js';7import { DomEmitter } from '../../event.js';8import { renderFormattedText, renderText } from '../../formattedTextRenderer.js';9import { IHistoryNavigationWidget } from '../../history.js';10import { ActionBar } from '../actionbar/actionbar.js';11import * as aria from '../aria/aria.js';12import { AnchorAlignment, IContextViewProvider } from '../contextview/contextview.js';13import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js';14import { ScrollableElement } from '../scrollbar/scrollableElement.js';15import { Widget } from '../widget.js';16import { IAction } from '../../../common/actions.js';17import { Emitter, Event } from '../../../common/event.js';18import { HistoryNavigator, IHistory } from '../../../common/history.js';19import { equals } from '../../../common/objects.js';20import { ScrollbarVisibility } from '../../../common/scrollable.js';21import './inputBox.css';22import * as nls from '../../../../nls.js';23import { MutableDisposable, type IDisposable } from '../../../common/lifecycle.js';242526const $ = dom.$;2728export interface IInputOptions {29readonly placeholder?: string;30readonly showPlaceholderOnFocus?: boolean;31readonly tooltip?: string;32readonly ariaLabel?: string;33readonly type?: string;34readonly validationOptions?: IInputValidationOptions;35readonly flexibleHeight?: boolean;36readonly flexibleWidth?: boolean;37readonly flexibleMaxHeight?: number;38readonly actions?: ReadonlyArray<IAction>;39readonly inputBoxStyles: IInputBoxStyles;40readonly history?: IHistory<string>;41}4243export interface IInputBoxStyles {44readonly inputBackground: string | undefined;45readonly inputForeground: string | undefined;46readonly inputBorder: string | undefined;47readonly inputValidationInfoBorder: string | undefined;48readonly inputValidationInfoBackground: string | undefined;49readonly inputValidationInfoForeground: string | undefined;50readonly inputValidationWarningBorder: string | undefined;51readonly inputValidationWarningBackground: string | undefined;52readonly inputValidationWarningForeground: string | undefined;53readonly inputValidationErrorBorder: string | undefined;54readonly inputValidationErrorBackground: string | undefined;55readonly inputValidationErrorForeground: string | undefined;56}5758export interface IInputValidator {59(value: string): IMessage | null;60}6162export interface IMessage {63readonly content?: string;64readonly formatContent?: boolean; // defaults to false65readonly type?: MessageType;66}6768export interface IInputValidationOptions {69validation?: IInputValidator;70}7172export const enum MessageType {73INFO = 1,74WARNING = 2,75ERROR = 376}7778export interface IRange {79start: number;80end: number;81}8283export const unthemedInboxStyles: IInputBoxStyles = {84inputBackground: '#3C3C3C',85inputForeground: '#CCCCCC',86inputValidationInfoBorder: '#55AAFF',87inputValidationInfoBackground: '#063B49',88inputValidationWarningBorder: '#B89500',89inputValidationWarningBackground: '#352A05',90inputValidationErrorBorder: '#BE1100',91inputValidationErrorBackground: '#5A1D1D',92inputBorder: undefined,93inputValidationErrorForeground: undefined,94inputValidationInfoForeground: undefined,95inputValidationWarningForeground: undefined96};9798export class InputBox extends Widget {99private contextViewProvider?: IContextViewProvider;100element: HTMLElement;101protected input: HTMLInputElement;102private actionbar?: ActionBar;103private readonly options: IInputOptions;104private message: IMessage | null;105protected placeholder: string;106private tooltip: string;107private ariaLabel: string;108private validation?: IInputValidator;109private state: 'idle' | 'open' | 'closed' = 'idle';110111private mirror: HTMLElement | undefined;112private cachedHeight: number | undefined;113private cachedContentHeight: number | undefined;114private maxHeight: number = Number.POSITIVE_INFINITY;115private scrollableElement: ScrollableElement | undefined;116private readonly hover: MutableDisposable<IDisposable> = this._register(new MutableDisposable());117118private _onDidChange = this._register(new Emitter<string>());119public get onDidChange(): Event<string> { return this._onDidChange.event; }120121private _onDidHeightChange = this._register(new Emitter<number>());122public get onDidHeightChange(): Event<number> { return this._onDidHeightChange.event; }123124constructor(container: HTMLElement, contextViewProvider: IContextViewProvider | undefined, options: IInputOptions) {125super();126127this.contextViewProvider = contextViewProvider;128this.options = options;129130this.message = null;131this.placeholder = this.options.placeholder || '';132this.tooltip = this.options.tooltip ?? (this.placeholder || '');133this.ariaLabel = this.options.ariaLabel || '';134135if (this.options.validationOptions) {136this.validation = this.options.validationOptions.validation;137}138139this.element = dom.append(container, $('.monaco-inputbox.idle'));140141const tagName = this.options.flexibleHeight ? 'textarea' : 'input';142143const wrapper = dom.append(this.element, $('.ibwrapper'));144this.input = dom.append(wrapper, $(tagName + '.input.empty'));145this.input.setAttribute('autocorrect', 'off');146this.input.setAttribute('autocapitalize', 'off');147this.input.setAttribute('spellcheck', 'false');148149this.onfocus(this.input, () => this.element.classList.add('synthetic-focus'));150this.onblur(this.input, () => this.element.classList.remove('synthetic-focus'));151152if (this.options.flexibleHeight) {153this.maxHeight = typeof this.options.flexibleMaxHeight === 'number' ? this.options.flexibleMaxHeight : Number.POSITIVE_INFINITY;154155this.mirror = dom.append(wrapper, $('div.mirror'));156this.mirror.innerText = '\u00a0';157158this.scrollableElement = new ScrollableElement(this.element, { vertical: ScrollbarVisibility.Auto });159160if (this.options.flexibleWidth) {161this.input.setAttribute('wrap', 'off');162this.mirror.style.whiteSpace = 'pre';163this.mirror.style.wordWrap = 'initial';164}165166dom.append(container, this.scrollableElement.getDomNode());167this._register(this.scrollableElement);168169// from ScrollableElement to DOM170this._register(this.scrollableElement.onScroll(e => this.input.scrollTop = e.scrollTop));171172const onSelectionChange = this._register(new DomEmitter(container.ownerDocument, 'selectionchange'));173const onAnchoredSelectionChange = Event.filter(onSelectionChange.event, () => {174const selection = container.ownerDocument.getSelection();175return selection?.anchorNode === wrapper;176});177178// from DOM to ScrollableElement179this._register(onAnchoredSelectionChange(this.updateScrollDimensions, this));180this._register(this.onDidHeightChange(this.updateScrollDimensions, this));181} else {182this.input.type = this.options.type || 'text';183this.input.setAttribute('wrap', 'off');184}185186if (this.ariaLabel) {187this.input.setAttribute('aria-label', this.ariaLabel);188}189190if (this.placeholder && !this.options.showPlaceholderOnFocus) {191this.setPlaceHolder(this.placeholder);192}193194if (this.tooltip) {195this.setTooltip(this.tooltip);196}197198this.oninput(this.input, () => this.onValueChange());199this.onblur(this.input, () => this.onBlur());200this.onfocus(this.input, () => this.onFocus());201202this._register(this.ignoreGesture(this.input));203204setTimeout(() => this.updateMirror(), 0);205206// Support actions207if (this.options.actions) {208this.actionbar = this._register(new ActionBar(this.element));209this.actionbar.push(this.options.actions, { icon: true, label: false });210}211212this.applyStyles();213}214215protected onBlur(): void {216this._hideMessage();217if (this.options.showPlaceholderOnFocus) {218this.input.setAttribute('placeholder', '');219}220}221222protected onFocus(): void {223this._showMessage();224if (this.options.showPlaceholderOnFocus) {225this.input.setAttribute('placeholder', this.placeholder || '');226}227}228229public setPlaceHolder(placeHolder: string): void {230this.placeholder = placeHolder;231this.input.setAttribute('placeholder', placeHolder);232}233234public setTooltip(tooltip: string): void {235this.tooltip = tooltip;236if (!this.hover.value) {237this.hover.value = this._register(getBaseLayerHoverDelegate().setupDelayedHoverAtMouse(this.input, () => ({238content: this.tooltip,239appearance: {240compact: true,241}242})));243}244}245246public setAriaLabel(label: string): void {247this.ariaLabel = label;248249if (label) {250this.input.setAttribute('aria-label', this.ariaLabel);251} else {252this.input.removeAttribute('aria-label');253}254}255256public getAriaLabel(): string {257return this.ariaLabel;258}259260public get mirrorElement(): HTMLElement | undefined {261return this.mirror;262}263264public get inputElement(): HTMLInputElement {265return this.input;266}267268public get value(): string {269return this.input.value;270}271272public set value(newValue: string) {273if (this.input.value !== newValue) {274this.input.value = newValue;275this.onValueChange();276}277}278279public get step(): string {280return this.input.step;281}282283public set step(newValue: string) {284this.input.step = newValue;285}286287public get height(): number {288return typeof this.cachedHeight === 'number' ? this.cachedHeight : dom.getTotalHeight(this.element);289}290291public focus(): void {292this.input.focus();293}294295public blur(): void {296this.input.blur();297}298299public hasFocus(): boolean {300return dom.isActiveElement(this.input);301}302303public select(range: IRange | null = null): void {304this.input.select();305306if (range) {307this.input.setSelectionRange(range.start, range.end);308if (range.end === this.input.value.length) {309this.input.scrollLeft = this.input.scrollWidth;310}311}312}313314public isSelectionAtEnd(): boolean {315return this.input.selectionEnd === this.input.value.length && this.input.selectionStart === this.input.selectionEnd;316}317318public getSelection(): IRange | null {319const selectionStart = this.input.selectionStart;320if (selectionStart === null) {321return null;322}323const selectionEnd = this.input.selectionEnd ?? selectionStart;324return {325start: selectionStart,326end: selectionEnd,327};328}329330public enable(): void {331this.input.removeAttribute('disabled');332}333334public disable(): void {335this.blur();336this.input.disabled = true;337this._hideMessage();338}339340public setEnabled(enabled: boolean): void {341if (enabled) {342this.enable();343} else {344this.disable();345}346}347348public get width(): number {349return dom.getTotalWidth(this.input);350}351352public set width(width: number) {353if (this.options.flexibleHeight && this.options.flexibleWidth) {354// textarea with horizontal scrolling355let horizontalPadding = 0;356if (this.mirror) {357const paddingLeft = parseFloat(this.mirror.style.paddingLeft || '') || 0;358const paddingRight = parseFloat(this.mirror.style.paddingRight || '') || 0;359horizontalPadding = paddingLeft + paddingRight;360}361this.input.style.width = (width - horizontalPadding) + 'px';362} else {363this.input.style.width = width + 'px';364}365366if (this.mirror) {367this.mirror.style.width = width + 'px';368}369}370371public set paddingRight(paddingRight: number) {372// Set width to avoid hint text overlapping buttons373this.input.style.width = `calc(100% - ${paddingRight}px)`;374375if (this.mirror) {376this.mirror.style.paddingRight = paddingRight + 'px';377}378}379380private updateScrollDimensions(): void {381if (typeof this.cachedContentHeight !== 'number' || typeof this.cachedHeight !== 'number' || !this.scrollableElement) {382return;383}384385const scrollHeight = this.cachedContentHeight;386const height = this.cachedHeight;387const scrollTop = this.input.scrollTop;388389this.scrollableElement.setScrollDimensions({ scrollHeight, height });390this.scrollableElement.setScrollPosition({ scrollTop });391}392393public showMessage(message: IMessage, force?: boolean): void {394if (this.state === 'open' && equals(this.message, message)) {395// Already showing396return;397}398399this.message = message;400401this.element.classList.remove('idle');402this.element.classList.remove('info');403this.element.classList.remove('warning');404this.element.classList.remove('error');405this.element.classList.add(this.classForType(message.type));406407const styles = this.stylesForType(this.message.type);408this.element.style.border = `1px solid ${cssJs.asCssValueWithDefault(styles.border, 'transparent')}`;409410if (this.message.content && (this.hasFocus() || force)) {411this._showMessage();412}413}414415public hideMessage(): void {416this.message = null;417418this.element.classList.remove('info');419this.element.classList.remove('warning');420this.element.classList.remove('error');421this.element.classList.add('idle');422423this._hideMessage();424this.applyStyles();425}426427public isInputValid(): boolean {428return !!this.validation && !this.validation(this.value);429}430431public validate(): MessageType | undefined {432let errorMsg: IMessage | null = null;433434if (this.validation) {435errorMsg = this.validation(this.value);436437if (errorMsg) {438this.inputElement.setAttribute('aria-invalid', 'true');439this.showMessage(errorMsg);440}441else if (this.inputElement.hasAttribute('aria-invalid')) {442this.inputElement.removeAttribute('aria-invalid');443this.hideMessage();444}445}446447return errorMsg?.type;448}449450public stylesForType(type: MessageType | undefined): { border: string | undefined; background: string | undefined; foreground: string | undefined } {451const styles = this.options.inputBoxStyles;452switch (type) {453case MessageType.INFO: return { border: styles.inputValidationInfoBorder, background: styles.inputValidationInfoBackground, foreground: styles.inputValidationInfoForeground };454case MessageType.WARNING: return { border: styles.inputValidationWarningBorder, background: styles.inputValidationWarningBackground, foreground: styles.inputValidationWarningForeground };455default: return { border: styles.inputValidationErrorBorder, background: styles.inputValidationErrorBackground, foreground: styles.inputValidationErrorForeground };456}457}458459private classForType(type: MessageType | undefined): string {460switch (type) {461case MessageType.INFO: return 'info';462case MessageType.WARNING: return 'warning';463default: return 'error';464}465}466467private _showMessage(): void {468if (!this.contextViewProvider || !this.message) {469return;470}471472let div: HTMLElement;473const layout = () => div.style.width = dom.getTotalWidth(this.element) + 'px';474475this.contextViewProvider.showContextView({476getAnchor: () => this.element,477anchorAlignment: AnchorAlignment.RIGHT,478render: (container: HTMLElement) => {479if (!this.message) {480return null;481}482483div = dom.append(container, $('.monaco-inputbox-container'));484layout();485486487const spanElement = $('span.monaco-inputbox-message');488if (this.message.formatContent) {489renderFormattedText(this.message.content!, undefined, spanElement);490} else {491renderText(this.message.content!, undefined, spanElement);492}493494spanElement.classList.add(this.classForType(this.message.type));495496const styles = this.stylesForType(this.message.type);497spanElement.style.backgroundColor = styles.background ?? '';498spanElement.style.color = styles.foreground ?? '';499spanElement.style.border = styles.border ? `1px solid ${styles.border}` : '';500501dom.append(div, spanElement);502503return null;504},505onHide: () => {506this.state = 'closed';507},508layout: layout509});510511// ARIA Support512let alertText: string;513if (this.message.type === MessageType.ERROR) {514alertText = nls.localize('alertErrorMessage', "Error: {0}", this.message.content);515} else if (this.message.type === MessageType.WARNING) {516alertText = nls.localize('alertWarningMessage', "Warning: {0}", this.message.content);517} else {518alertText = nls.localize('alertInfoMessage', "Info: {0}", this.message.content);519}520521aria.alert(alertText);522523this.state = 'open';524}525526private _hideMessage(): void {527if (!this.contextViewProvider) {528return;529}530531if (this.state === 'open') {532this.contextViewProvider.hideContextView();533}534535this.state = 'idle';536}537538private onValueChange(): void {539this._onDidChange.fire(this.value);540541this.validate();542this.updateMirror();543this.input.classList.toggle('empty', !this.value);544545if (this.state === 'open' && this.contextViewProvider) {546this.contextViewProvider.layout();547}548}549550private updateMirror(): void {551if (!this.mirror) {552return;553}554555const value = this.value;556const lastCharCode = value.charCodeAt(value.length - 1);557const suffix = lastCharCode === 10 ? ' ' : '';558const mirrorTextContent = (value + suffix)559.replace(/\u000c/g, ''); // Don't measure with the form feed character, which messes up sizing560561if (mirrorTextContent) {562this.mirror.textContent = value + suffix;563} else {564this.mirror.innerText = '\u00a0';565}566567this.layout();568}569570protected applyStyles(): void {571const styles = this.options.inputBoxStyles;572573const background = styles.inputBackground ?? '';574const foreground = styles.inputForeground ?? '';575const border = styles.inputBorder ?? '';576577this.element.style.backgroundColor = background;578this.element.style.color = foreground;579this.input.style.backgroundColor = 'inherit';580this.input.style.color = foreground;581582// there's always a border, even if the color is not set.583this.element.style.border = `1px solid ${cssJs.asCssValueWithDefault(border, 'transparent')}`;584}585586public layout(): void {587if (!this.mirror) {588return;589}590591const previousHeight = this.cachedContentHeight;592this.cachedContentHeight = dom.getTotalHeight(this.mirror);593594if (previousHeight !== this.cachedContentHeight) {595this.cachedHeight = Math.min(this.cachedContentHeight, this.maxHeight);596this.input.style.height = this.cachedHeight + 'px';597this._onDidHeightChange.fire(this.cachedContentHeight);598}599}600601public insertAtCursor(text: string): void {602const inputElement = this.inputElement;603const start = inputElement.selectionStart;604const end = inputElement.selectionEnd;605const content = inputElement.value;606607if (start !== null && end !== null) {608this.value = content.substr(0, start) + text + content.substr(end);609inputElement.setSelectionRange(start + 1, start + 1);610this.layout();611}612}613614public override dispose(): void {615this._hideMessage();616617this.message = null;618619this.actionbar?.dispose();620621super.dispose();622}623}624625export interface IHistoryInputOptions extends IInputOptions {626readonly showHistoryHint?: () => boolean;627}628629export class HistoryInputBox extends InputBox implements IHistoryNavigationWidget {630631private readonly history: HistoryNavigator<string>;632private observer: MutationObserver | undefined;633634private readonly _onDidFocus = this._register(new Emitter<void>());635readonly onDidFocus = this._onDidFocus.event;636637private readonly _onDidBlur = this._register(new Emitter<void>());638readonly onDidBlur = this._onDidBlur.event;639640constructor(container: HTMLElement, contextViewProvider: IContextViewProvider | undefined, options: IHistoryInputOptions) {641const NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_NO_PARENS = nls.localize({642key: 'history.inputbox.hint.suffix.noparens',643comment: ['Text is the suffix of an input field placeholder coming after the action the input field performs, this will be used when the input field ends in a closing parenthesis ")", for example "Filter (e.g. text, !exclude)". The character inserted into the final string is \u21C5 to represent the up and down arrow keys.']644}, ' or {0} for history', `\u21C5`);645const NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_IN_PARENS = nls.localize({646key: 'history.inputbox.hint.suffix.inparens',647comment: ['Text is the suffix of an input field placeholder coming after the action the input field performs, this will be used when the input field does NOT end in a closing parenthesis (eg. "Find"). The character inserted into the final string is \u21C5 to represent the up and down arrow keys.']648}, ' ({0} for history)', `\u21C5`);649650super(container, contextViewProvider, options);651this.history = this._register(new HistoryNavigator<string>(options.history, 100));652653// Function to append the history suffix to the placeholder if necessary654const addSuffix = () => {655if (options.showHistoryHint && options.showHistoryHint() && !this.placeholder.endsWith(NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_NO_PARENS) && !this.placeholder.endsWith(NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_IN_PARENS) && this.history.getHistory().length) {656const suffix = this.placeholder.endsWith(')') ? NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_NO_PARENS : NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_IN_PARENS;657const suffixedPlaceholder = this.placeholder + suffix;658if (options.showPlaceholderOnFocus && !dom.isActiveElement(this.input)) {659this.placeholder = suffixedPlaceholder;660}661else {662this.setPlaceHolder(suffixedPlaceholder);663}664}665};666667// Spot the change to the textarea class attribute which occurs when it changes between non-empty and empty,668// and add the history suffix to the placeholder if not yet present669this.observer = new MutationObserver((mutationList: MutationRecord[], observer: MutationObserver) => {670mutationList.forEach((mutation: MutationRecord) => {671if (!mutation.target.textContent) {672addSuffix();673}674});675});676this.observer.observe(this.input, { attributeFilter: ['class'] });677678this.onfocus(this.input, () => addSuffix());679this.onblur(this.input, () => {680const resetPlaceholder = (historyHint: string) => {681if (!this.placeholder.endsWith(historyHint)) {682return false;683}684else {685const revertedPlaceholder = this.placeholder.slice(0, this.placeholder.length - historyHint.length);686if (options.showPlaceholderOnFocus) {687this.placeholder = revertedPlaceholder;688}689else {690this.setPlaceHolder(revertedPlaceholder);691}692return true;693}694};695if (!resetPlaceholder(NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_IN_PARENS)) {696resetPlaceholder(NLS_PLACEHOLDER_HISTORY_HINT_SUFFIX_NO_PARENS);697}698});699}700701override dispose() {702super.dispose();703if (this.observer) {704this.observer.disconnect();705this.observer = undefined;706}707}708709public addToHistory(always?: boolean): void {710if (this.value && (always || this.value !== this.getCurrentValue())) {711this.history.add(this.value);712}713}714715public prependHistory(restoredHistory: string[]): void {716const newHistory = this.getHistory();717this.clearHistory();718719restoredHistory.forEach((item) => {720this.history.add(item);721});722723newHistory.forEach(item => {724this.history.add(item);725});726}727728public getHistory(): string[] {729return this.history.getHistory();730}731732public isAtFirstInHistory(): boolean {733return this.history.isFirst();734}735736public isAtLastInHistory(): boolean {737return this.history.isLast();738}739740public isNowhereInHistory(): boolean {741return this.history.isNowhere();742}743744public showNextValue(): void {745if (!this.history.has(this.value)) {746this.addToHistory();747}748749let next = this.getNextValue();750if (next) {751next = next === this.value ? this.getNextValue() : next;752}753754this.value = next ?? '';755aria.status(this.value ? this.value : nls.localize('clearedInput', "Cleared Input"));756}757758public showPreviousValue(): void {759if (!this.history.has(this.value)) {760this.addToHistory();761}762763let previous = this.getPreviousValue();764if (previous) {765previous = previous === this.value ? this.getPreviousValue() : previous;766}767768if (previous) {769this.value = previous;770aria.status(this.value);771}772}773774public clearHistory(): void {775this.history.clear();776}777778public override setPlaceHolder(placeHolder: string): void {779super.setPlaceHolder(placeHolder);780this.setTooltip(placeHolder);781}782783protected override onBlur(): void {784super.onBlur();785this._onDidBlur.fire();786}787788protected override onFocus(): void {789super.onFocus();790this._onDidFocus.fire();791}792793private getCurrentValue(): string | null {794let currentValue = this.history.current();795if (!currentValue) {796currentValue = this.history.last();797this.history.next();798}799return currentValue;800}801802private getPreviousValue(): string | null {803return this.history.previous() || this.history.first();804}805806private getNextValue(): string | null {807return this.history.next();808}809}810811812