/********************************************************************************
 * 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 { injectable , inject } from 'inversify' ; import { Disposable , MaybePromise , CancellationTokenSource , nls } from '../common' ; import { Key } from './keyboard/keys' ; import { Widget , BaseWidget , Message , addKeyListener , codiconArray } from './widgets' ; import { FrontendApplicationContribution } from './frontend-application' ; @ 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 ) { if ( typeof error === 'string' ) { return ! error . length ; } if ( typeof error === 'boolean' ) { return error ; } return error . result ; } export function getMessage ( error ) { if ( typeof error === 'string' ) { return error ; } if ( typeof error === 'boolean' ) { return '' ; } return error . message ; } } export namespace Dialog { export const YES = nls . localizeByDefault ( 'Yes' ) ; export const NO = nls . localizeByDefault ( 'No' ) ; export const OK = nls . localizeByDefault ( 'OK' ) ; export const CANCEL = nls . localizeByDefault ( 'Cancel' ) ; } @ injectable ( ) export class DialogOverlayService implements FrontendApplicationContribution { protected static INSTANCE : DialogOverlayService ; static get ( ) { return DialogOverlayService . INSTANCE ; } protected readonly dialogs : AbstractDialog < any > [ ] = [ ] ; constructor ( ) { addKeyListener ( document . body , Key . ENTER , e => this . handleEnter ( e ) ) ; addKeyListener ( document . body , Key . ESCAPE , e => this . handleEscape ( e ) ) ; } initialize ( ) { DialogOverlayService . INSTANCE = this ; } protected get currentDialog ( ) : AbstractDialog < any > | undefined { return this . dialogs [ 0 ] ; } push ( dialog ) { this . dialogs . unshift ( dialog ) ; return Disposable . create ( ( ) => { const index = this . dialogs . indexOf ( dialog ) ; if ( index > - 1 ) { this . dialogs . splice ( index , 1 ) ; } } ) ; } protected handleEscape ( event ) { const dialog = this . currentDialog ; if ( dialog ) { return dialog [ 'handleEscape' ] ( event ) ; } return false ; } protected handleEnter ( event ) { const dialog = this . currentDialog ; if ( dialog ) { return dialog [ 'handleEnter' ] ( event ) ; } return false ; } } @ injectable ( ) export abstract class AbstractDialog < T > 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 ) ; protected reject : undefined | ( ( reason : any ) => void ) ; protected closeButton : HTMLButtonElement | undefined ; protected acceptButton : HTMLButtonElement | undefined ; protected activeElement : HTMLElement | undefined ; constructor ( @ inject ( DialogProps ) protected readonly props ) { 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 ( ... codiconArray ( 'close' ) ) ; 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 ( ) ; } protected appendCloseButton ( text = Dialog . CANCEL ) { this . closeButton = this . createButton ( text ) ; this . controlPanel . appendChild ( this . closeButton ) ; this . closeButton . classList . add ( 'secondary' ) ; return this . closeButton ; } protected appendAcceptButton ( text = Dialog . OK ) { this . acceptButton = this . createButton ( text ) ; this . controlPanel . appendChild ( this . acceptButton ) ; this . acceptButton . classList . add ( 'main' ) ; return this . acceptButton ; } protected createButton ( text ) { const button = document . createElement ( 'button' ) ; button . classList . add ( 'theia-button' ) ; button . textContent = text ; return button ; } protected onAfterAttach ( msg ) { super . onAfterAttach ( msg ) ; if ( this . closeButton ) { this . addCloseAction ( this . closeButton , 'click' ) ; } if ( this . acceptButton ) { this . addAcceptAction ( this . acceptButton , 'click' ) ; } this . addCloseAction ( this . closeCrossNode , 'click' ) ; this . toDisposeOnDetach . push ( DialogOverlayService . get ( ) . push ( this ) ) ; } protected handleEscape ( event ) { this . close ( ) ; } protected handleEnter ( event ) { if ( event . target instanceof HTMLTextAreaElement ) { return false ; } this . accept ( ) ; } protected onActivateRequest ( msg ) { super . onActivateRequest ( msg ) ; if ( this . acceptButton ) { this . acceptButton . focus ( ) ; } } open ( ) { if ( this . resolve ) { return Promise . reject ( new Error ( 'The dialog is already opened.' ) ) ; } this . activeElement = window . document . activeElement as HTMLElement ; return new Promise < T | undefined > ( ( 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 ( ) { if ( this . resolve ) { if ( this . activeElement ) { this . activeElement . focus ( { preventScroll : true } ) ; } this . resolve ( undefined ) ; } this . activeElement = undefined ; super . close ( ) ; } protected onUpdateRequest ( msg ) { super . onUpdateRequest ( msg ) ; this . validate ( ) ; } protected validateCancellationSource = new CancellationTokenSource ( ) ; protected async validate ( ) { 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 acceptCancellationSource = new CancellationTokenSource ( ) ; protected async accept ( ) { 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 ) ; } } abstract get value ( ) : T ; /**
     * Return a string of zero-length or true if valid.
     */ protected isValid ( value , mode ) { return '' ; } protected setErrorMessage ( error ) { if ( this . acceptButton ) { this . acceptButton . disabled = ! DialogError . getResult ( error ) ; } this . errorMessageNode . innerText = DialogError . getMessage ( error ) ; } protected addCloseAction < K extends keyof HTMLElementEventMap > ( element , ... additionalEventTypes ) { this . addKeyListener ( element , Key . ENTER , ( ) => this . close ( ) , ... additionalEventTypes ) ; } protected addAcceptAction < K extends keyof HTMLElementEventMap > ( element , ... additionalEventTypes ) { 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 < boolean > { protected confirmed = true ; constructor ( @ inject ( ConfirmDialogProps ) protected readonly props ) { super ( props ) ; this . contentNode . appendChild ( this . createMessageNode ( this . props . msg ) ) ; this . appendCloseButton ( props . cancel ) ; this . appendAcceptButton ( props . ok ) ; } protected onCloseRequest ( msg ) { super . onCloseRequest ( msg ) ; this . confirmed = false ; this . accept ( ) ; } get value ( ) : boolean { return this . confirmed ; } protected createMessageNode ( msg ) { if ( typeof msg === 'string' ) { const messageNode = document . createElement ( 'div' ) ; messageNode . textContent = msg ; return messageNode ; } return msg ; } } export async function confirmExit ( ) { const safeToExit = await new ConfirmDialog ( { title : nls . localize ( 'theia/core/quitTitle' , 'Are you sure you want to quit?' ) , msg : nls . localize ( 'theia/core/quitMessage' , 'Any unsaved changes will not be saved.' ) , ok : Dialog . YES , cancel : Dialog . NO , } ) . open ( ) ; return safeToExit === true ; } @ injectable ( ) export class SingleTextInputDialogProps extends DialogProps { readonly confirmButtonLabel ? : string ; readonly initialValue ? : string ; readonly initialSelectionRange ? : { start : number ; end : number ; direction ? : 'forward' | 'backward' | 'none' ; } ; readonly validate ? : ( input : string , mode : DialogMode ) => MaybePromise < DialogError > ; } export class SingleTextInputDialog extends AbstractDialog < string > { protected readonly inputField : HTMLInputElement ; constructor ( @ inject ( SingleTextInputDialogProps ) protected readonly props ) { super ( props ) ; this . inputField = document . createElement ( 'input' ) ; this . inputField . type = 'text' ; this . inputField . className = 'theia-input' ; this . inputField . spellcheck = false ; 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 , mode ) { if ( this . props . validate ) { return this . props . validate ( value , mode ) ; } return super . isValid ( value , mode ) ; } protected onAfterAttach ( msg ) { super . onAfterAttach ( msg ) ; this . addUpdateListener ( this . inputField , 'input' ) ; } protected onActivateRequest ( msg ) { this . inputField . focus ( ) ; } protected handleEnter ( event ) { if ( event . target instanceof HTMLInputElement ) { return super . handleEnter ( event ) ; } return false ; } }