/******************************************************************************** * Copyright (C) 2017 TypeFox 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 { inject, injectable } from 'inversify'; import { CancellationTokenSource, Disposable, MaybePromise } from '@gedit/utils'; import { Key } from '@gedit/keyboard'; import { addKeyListener, BaseWidget, Message, Widget } from '../widgets'; import { FrontendApplicationContribution } from '@gedit/application-common/lib/browser'; import { nls } from '@gedit/nls'; @injectable() export class DialogProps { readonly title: string; /** * Determines the maximum width of the dialog in pixels. * Default value is undefined, which would result in the css property 'max-width: none' being applied to the dialog. */ maxWidth?: number; /** * Determine the word wrapping behavior for content in the dialog. * - `normal`: breaks words at allowed break points. * - `break-word`: breaks otherwise unbreakable words. * - `initial`: sets the property to it's default value. * - `inherit`: inherit this property from it's parent element. * Default value is undefined, which would result in the css property 'word-wrap' not being applied to the dialog. */ wordWrap?: 'normal' | 'break-word' | 'initial' | 'inherit'; } export type DialogMode = 'open' | 'preview'; export type DialogError = string | boolean | { message: string result: boolean }; export namespace DialogError { export function getResult(error: DialogError): boolean { if (typeof error === 'string') { return !error.length; } if (typeof error === 'boolean') { return error; } return error.result; } export function getMessage(error: DialogError): string { if (typeof error === 'string') { return error; } if (typeof error === 'boolean') { return ''; } return error.message; } } @injectable() export class DialogOverlayService implements FrontendApplicationContribution { protected static INSTANCE: DialogOverlayService; // eslint-disable-next-line @typescript-eslint/no-explicit-any protected readonly dialogs: AbstractDialog[] = []; constructor() { addKeyListener(document.body, Key.ENTER, e => this.handleEnter(e)); addKeyListener(document.body, Key.ESCAPE, e => this.handleEscape(e)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any protected get currentDialog(): AbstractDialog | undefined { return this.dialogs[0]; } static get(): DialogOverlayService { return DialogOverlayService.INSTANCE; } initialize(): void { DialogOverlayService.INSTANCE = this; } // eslint-disable-next-line @typescript-eslint/no-explicit-any push(dialog: AbstractDialog): Disposable { this.dialogs.unshift(dialog); return Disposable.create(() => { const index = this.dialogs.indexOf(dialog); if (index > -1) { this.dialogs.splice(index, 1); } }); } protected handleEscape(event: KeyboardEvent): boolean | void { const dialog = this.currentDialog; if (dialog) { return dialog['handleEscape'](event); } return false; } protected handleEnter(event: KeyboardEvent): boolean | void { const dialog = this.currentDialog; if (dialog) { return dialog['handleEnter'](event); } return false; } } @injectable() export abstract class AbstractDialog extends BaseWidget { protected readonly titleNode: HTMLDivElement; protected readonly contentNode: HTMLDivElement; protected readonly closeCrossNode: HTMLElement; protected readonly controlPanel: HTMLDivElement; protected readonly errorMessageNode: HTMLDivElement; protected resolve: undefined | ((value: T | undefined) => void); // eslint-disable-next-line @typescript-eslint/no-explicit-any protected reject: undefined | ((reason: any) => void); protected closeButton: HTMLButtonElement | undefined; protected acceptButton: HTMLButtonElement | undefined; protected activeElement: HTMLElement | undefined; protected validateCancellationSource = new CancellationTokenSource(); protected acceptCancellationSource = new CancellationTokenSource(); constructor( @inject(DialogProps) protected readonly props: DialogProps ) { super(); this.id = 'theia-dialog-shell'; this.addClass('dialogOverlay'); this.toDispose.push(Disposable.create(() => { if (this.reject) { Widget.detach(this); } })); const container = document.createElement('div'); container.classList.add('dialogBlock'); if (props.maxWidth === undefined) { container.setAttribute('style', 'max-width: none'); } else { container.setAttribute('style', `max-width: ${props.maxWidth}px; min-width: 0px`); } this.node.appendChild(container); const titleContentNode = document.createElement('div'); titleContentNode.classList.add('dialogTitle'); container.appendChild(titleContentNode); this.titleNode = document.createElement('div'); this.titleNode.textContent = props.title; titleContentNode.appendChild(this.titleNode); this.closeCrossNode = document.createElement('i'); this.closeCrossNode.classList.add('fa'); this.closeCrossNode.classList.add('fa-times'); this.closeCrossNode.classList.add('closeButton'); titleContentNode.appendChild(this.closeCrossNode); this.contentNode = document.createElement('div'); this.contentNode.classList.add('dialogContent'); if (props.wordWrap !== undefined) { this.contentNode.setAttribute('style', `word-wrap: ${props.wordWrap}`); } container.appendChild(this.contentNode); this.controlPanel = document.createElement('div'); this.controlPanel.classList.add('dialogControl'); container.appendChild(this.controlPanel); this.errorMessageNode = document.createElement('div'); this.errorMessageNode.classList.add('error'); this.errorMessageNode.setAttribute('style', 'flex: 2'); this.controlPanel.appendChild(this.errorMessageNode); this.update(); } abstract get value(): T; open(): Promise { if (this.resolve) { return Promise.reject(new Error('The dialog is already opened.')); } this.activeElement = window.document.activeElement as HTMLElement; return new Promise((resolve, reject) => { this.resolve = resolve; this.reject = reject; this.toDisposeOnDetach.push(Disposable.create(() => { this.resolve = undefined; this.reject = undefined; })); Widget.attach(this, document.body); this.activate(); }); } close(): void { if (this.resolve) { if (this.activeElement) { this.activeElement.focus({preventScroll: true}); } this.resolve(undefined); } this.activeElement = undefined; super.close(); } protected appendCloseButton(text: string = nls.localize('button.cancel', 'Cancel')): HTMLButtonElement { this.closeButton = this.createButton(text); this.controlPanel.appendChild(this.closeButton); this.closeButton.classList.add('secondary'); return this.closeButton; } protected appendAcceptButton(text: string = nls.localize('button.ok', 'OK')): HTMLButtonElement { this.acceptButton = this.createButton(text); this.controlPanel.appendChild(this.acceptButton); this.acceptButton.classList.add('main'); return this.acceptButton; } protected createButton(text: string): HTMLButtonElement { const button = document.createElement('button'); button.classList.add('theia-button'); button.textContent = text; return button; } protected onAfterAttach(msg: Message): void { super.onAfterAttach(msg); if (this.closeButton) { this.addCloseAction(this.closeButton, 'click'); } if (this.acceptButton) { this.addAcceptAction(this.acceptButton, 'click'); } this.addCloseAction(this.closeCrossNode, 'click'); // TODO: use DI always to create dialog instances this.toDisposeOnDetach.push(DialogOverlayService.get().push(this)); } protected handleEscape(event: KeyboardEvent): boolean | void { this.close(); } protected handleEnter(event: KeyboardEvent): boolean | void { if (event.target instanceof HTMLTextAreaElement) { return false; } this.accept(); } protected onActivateRequest(msg: Message): void { super.onActivateRequest(msg); if (this.acceptButton) { this.acceptButton.focus(); } } protected onUpdateRequest(msg: Message): void { super.onUpdateRequest(msg); this.validate(); } protected async validate(): Promise { if (!this.resolve) { return; } this.validateCancellationSource.cancel(); this.validateCancellationSource = new CancellationTokenSource(); const token = this.validateCancellationSource.token; const value = this.value; const error = await this.isValid(value, 'preview'); if (token.isCancellationRequested) { return; } this.setErrorMessage(error); } protected async accept(): Promise { if (!this.resolve) { return; } this.acceptCancellationSource.cancel(); this.acceptCancellationSource = new CancellationTokenSource(); const token = this.acceptCancellationSource.token; const value = this.value; const error = await this.isValid(value, 'open'); if (token.isCancellationRequested) { return; } if (!DialogError.getResult(error)) { this.setErrorMessage(error); } else { this.resolve(value); Widget.detach(this); } } /** * Return a string of zero-length or true if valid. */ protected isValid(value: T, mode: DialogMode): MaybePromise { return ''; } protected setErrorMessage(error: DialogError): void { if (this.acceptButton) { this.acceptButton.disabled = !DialogError.getResult(error); } this.errorMessageNode.innerText = DialogError.getMessage(error); } protected addCloseAction(element: HTMLElement, ...additionalEventTypes: K[]): void { this.addKeyListener(element, Key.ENTER, () => this.close(), ...additionalEventTypes); } protected addAcceptAction(element: HTMLElement, ...additionalEventTypes: K[]): void { this.addKeyListener(element, Key.ENTER, () => this.accept(), ...additionalEventTypes); } } @injectable() export class ConfirmDialogProps extends DialogProps { readonly msg: string | HTMLElement; readonly cancel?: string; readonly ok?: string; } export class ConfirmDialog extends AbstractDialog { protected confirmed = true; constructor( @inject(ConfirmDialogProps) protected readonly props: ConfirmDialogProps ) { super(props); this.contentNode.appendChild(this.createMessageNode(this.props.msg)); this.appendCloseButton(props.cancel); this.appendAcceptButton(props.ok); } get value(): boolean { return this.confirmed; } protected onCloseRequest(msg: Message): void { super.onCloseRequest(msg); this.confirmed = false; this.accept(); } protected createMessageNode(msg: string | HTMLElement): HTMLElement { if (typeof msg === 'string') { const messageNode = document.createElement('div'); messageNode.textContent = msg; return messageNode; } return msg; } } @injectable() export class SingleTextInputDialogProps extends DialogProps { readonly confirmButtonLabel?: string; readonly initialValue?: string; type?: 'text' | 'number'; readonly initialSelectionRange?: { start: number end: number direction?: 'forward' | 'backward' | 'none' }; readonly validate?: (input: string, mode: DialogMode) => MaybePromise; } export class SingleTextInputDialog extends AbstractDialog { protected readonly inputField: HTMLInputElement; constructor( @inject(SingleTextInputDialogProps) protected readonly props: SingleTextInputDialogProps ) { super(props); this.inputField = document.createElement('input'); this.inputField.type = props.type || 'text'; this.inputField.className = 'theia-input'; this.inputField.setAttribute('style', 'flex: 0;'); this.inputField.value = props.initialValue || ''; if (props.initialSelectionRange) { this.inputField.setSelectionRange( props.initialSelectionRange.start, props.initialSelectionRange.end, props.initialSelectionRange.direction ); } else { this.inputField.select(); } this.contentNode.appendChild(this.inputField); this.appendAcceptButton(props.confirmButtonLabel); } get value(): string { return this.inputField.value; } protected isValid(value: string, mode: DialogMode): MaybePromise { if (this.props.validate) { return this.props.validate(value, mode); } return super.isValid(value, mode); } protected onAfterAttach(msg: Message): void { super.onAfterAttach(msg); this.addUpdateListener(this.inputField, 'input'); } protected onActivateRequest(msg: Message): void { this.inputField.focus(); } protected handleEnter(event: KeyboardEvent): boolean | void { if (event.target instanceof HTMLInputElement) { return super.handleEnter(event); } return false; } }