/// /// /// /// /// /// // namespace namespace cf { // interface export interface InputKeyChangeDTO{ dto: FlowDTO, keyCode: number, inputFieldActive: boolean } // class export class UserTextInput extends UserInputElement implements IUserInputElement { private inputElement: HTMLInputElement | HTMLTextAreaElement; private submitButton: UserInputSubmitButton; private onControlElementSubmitCallback: () => void; private onSubmitButtonChangeStateCallback: () => void; private onInputFocusCallback: () => void; private onInputBlurCallback: () => void; private onOriginalTagChangedCallback: () => void; private onControlElementProgressChangeCallback: () => void; private errorTimer: ReturnType; private initialInputHeight: number = 0; private shiftIsDown: boolean = false; private keyUpCallback: () => void; private keyDownCallback: () => void; protected microphoneObj: IUserInput; private controlElements: ControlElements; //acts as a fallback for ex. shadow dom implementation private _active: boolean = false; public get active(): boolean{ return this.inputElement === document.activeElement || this._active; } public set disabled(value: boolean){ const hasChanged: boolean = this._disabled != value; if(!ConversationalForm.suppressLog) console.log('option hasChanged', value); if(hasChanged){ this._disabled = value; if(value){ this.el.setAttribute("disabled", "disabled"); this.inputElement.blur(); }else{ this.setFocusOnInput(); this.el.removeAttribute("disabled"); } } } constructor(options: IUserInputOptions){ super(options); this.cfReference = options.cfReference; this.eventTarget = options.eventTarget; this.inputElement = this.el.getElementsByTagName("textarea")[0]; this.onInputFocusCallback = this.onInputFocus.bind(this); this.onInputBlurCallback = this.onInputBlur.bind(this); this.inputElement.addEventListener('focus', this.onInputFocusCallback, false); this.inputElement.addEventListener('blur', this.onInputBlurCallback, false); if (!ConversationalForm.animationsEnabled) { this.inputElement.setAttribute('no-animations', ''); } // is defined in the ChatList.ts this.controlElements = new ControlElements({ el: this.el.getElementsByTagName("cf-input-control-elements")[0], cfReference: this.cfReference, infoEl: this.el.getElementsByTagName("cf-info")[0], eventTarget: this.eventTarget }); // setup event listeners this.keyUpCallback = this.onKeyUp.bind(this); document.addEventListener("keyup", this.keyUpCallback, false); this.keyDownCallback = this.onKeyDown.bind(this); document.addEventListener("keydown", this.keyDownCallback, false); this.onOriginalTagChangedCallback = this.onOriginalTagChanged.bind(this); this.eventTarget.addEventListener(TagEvents.ORIGINAL_ELEMENT_CHANGED, this.onOriginalTagChangedCallback, false); this.onControlElementSubmitCallback = this.onControlElementSubmit.bind(this); this.eventTarget.addEventListener(ControlElementEvents.SUBMIT_VALUE, this.onControlElementSubmitCallback, false); this.onControlElementProgressChangeCallback = this.onControlElementProgressChange.bind(this); this.eventTarget.addEventListener(ControlElementEvents.PROGRESS_CHANGE, this.onControlElementProgressChangeCallback, false); this.onSubmitButtonChangeStateCallback = this.onSubmitButtonChangeState.bind(this); this.eventTarget.addEventListener(UserInputSubmitButtonEvents.CHANGE, this.onSubmitButtonChangeStateCallback, false); // this.eventTarget.addEventListener(ControlElementsEvents.ON_RESIZE, () => {}, false); this.submitButton = new UserInputSubmitButton({ eventTarget: this.eventTarget }); this.el.querySelector('div').appendChild(this.submitButton.el); // setup microphone support, audio if(options.microphoneInputObj){ this.microphoneObj = options.microphoneInputObj; if(this.microphoneObj && this.microphoneObj.init){ // init if init method is defined this.microphoneObj.init(); } this.submitButton.addMicrophone(this.microphoneObj); } } public getInputValue():string{ const str: string = this.inputElement.value; // Build-in way to handle XSS issues -> const div = document.createElement('div'); div.appendChild(document.createTextNode(str)); return div.innerHTML; } public getFlowDTO():FlowDTO{ let value: FlowDTO;// = this.inputElement.value; // check for values on control elements as they should overwrite the input value. if(this.controlElements && this.controlElements.active){ value = this.controlElements.getDTO(); }else{ value = { text: this.getInputValue() }; } // add current tag to DTO if not set if(!value.tag) value.tag = this.currentTag; value.input = this; value.tag = this.currentTag; return value; } public reset(){ if(this.controlElements){ this.controlElements.clearTagsAndReset() } } public deactivate(): void { super.deactivate(); if(this.microphoneObj){ this.submitButton.active = false; } } public reactivate(): void { super.reactivate(); // called from microphone interface, check if active microphone, and set loading if yes if(this.microphoneObj && !this.submitButton.typing){ this.submitButton.loading = true; // setting typing to false calls the externa interface, like Microphone this.submitButton.typing = false; this.submitButton.active = true; } } public onFlowStopped(){ this.submitButton.loading = false; if(this.submitButton.typing) this.submitButton.typing = false; if(this.controlElements) this.controlElements.clearTagsAndReset(); this.disabled = true; } /** * @name onOriginalTagChanged * on domElement from a Tag value changed.. */ private onOriginalTagChanged(event: CustomEvent): void { if(this.currentTag == event.detail.tag){ this.onInputChange(); } if(this.controlElements && this.controlElements.active){ this.controlElements.updateStateOnElementsFromTag(event.detail.tag) } } private onInputChange(){ if(!this.active && !this.controlElements.active) return; // safari likes to jump around with the scrollHeight value, let's keep it in check with an initial height. const oldHeight: number = Math.max(this.initialInputHeight, parseInt(this.inputElement.style.height, 10)); this.inputElement.style.height = '0px'; // console.log(this.inputElement.style.height, this.inputElement.style); this.inputElement.style.height = (this.inputElement.scrollHeight === 0 ? oldHeight : this.inputElement.scrollHeight) + "px"; ConversationalForm.illustrateFlow(this, "dispatch", UserInputEvents.HEIGHT_CHANGE); this.eventTarget.dispatchEvent(new CustomEvent(UserInputEvents.HEIGHT_CHANGE, { detail: this.inputElement.scrollHeight })); } private resetInputHeight() { if (this.inputElement.getAttribute('rows') === '1'){ this.inputElement.style.height = this.initialInputHeight + 'px'; } else { this.inputElement.style.height = '0px'; } } protected inputInvalid(event: CustomEvent){ ConversationalForm.illustrateFlow(this, "receive", event.type, event.detail); const dto: FlowDTO = event.detail; this.inputElement.setAttribute("data-value", this.inputElement.value); this.inputElement.value = ""; this.el.setAttribute("error", ""); this.disabled = true; // cf-error this.inputElement.setAttribute("placeholder", dto.errorText || (this._currentTag ? this._currentTag.errorMessage : "")); clearTimeout(this.errorTimer); // remove loading class this.submitButton.loading = false; this.errorTimer = setTimeout(() => { this.disabled = false; if(!ConversationalForm.suppressLog) console.log('option, disabled 1', ); this.el.removeAttribute("error"); this.inputElement.value = this.inputElement.getAttribute("data-value"); this.inputElement.setAttribute("data-value", ""); this.setPlaceholder(); this.setFocusOnInput(); //TODO: reset submit button.. this.submitButton.reset(); if(this.controlElements) this.controlElements.resetAfterErrorMessage(); }, UserInputElement.ERROR_TIME); } private setPlaceholder() { if(this._currentTag){ if(this._currentTag.inputPlaceholder){ this.inputElement.setAttribute("placeholder", this._currentTag.inputPlaceholder); }else{ this.inputElement.setAttribute("placeholder", this._currentTag.type == "group" ? Dictionary.get("group-placeholder") : Dictionary.get("input-placeholder")); } }else{ this.inputElement.setAttribute("placeholder", Dictionary.get("group-placeholder")); } } /** * TODO: handle detect input/textarea in a simpler way - too conditional heavy * * @private * @memberof UserTextInput */ private checkForCorrectInputTag(){ const tagName:String = this.tagType(this._currentTag); // remove focus and blur events, because we want to create a new element if(this.inputElement && this.inputElement.tagName !== tagName){ this.inputElement.removeEventListener('focus', this.onInputFocusCallback, false); this.inputElement.removeEventListener('blur', this.onInputBlurCallback, false); } this.removeAttribute('autocomplete'); this.removeAttribute('list'); if(tagName === 'INPUT'){ // change to input const input = document.createElement("input"); Array.prototype.slice.call(this.inputElement.attributes).forEach((item: any) => { input.setAttribute(item.name, item.value); }); if (this.inputElement.type === 'password') { input.setAttribute("autocomplete", "new-password"); } if (this._currentTag.domElement.hasAttribute('autocomplete')) { input.setAttribute('autocomplete', this._currentTag.domElement.getAttribute('autocomplete')); } if (this._currentTag.domElement.hasAttribute('list')) { input.setAttribute('list', this._currentTag.domElement.getAttribute('list')); } this.inputElement.parentNode.replaceChild(input, this.inputElement); this.inputElement = input; }else if (this.inputElement && this.inputElement.tagName !== tagName){ // change to textarea const textarea = document.createElement("textarea"); Array.prototype.slice.call(this.inputElement.attributes).forEach((item: any) => { textarea.setAttribute(item.name, item.value); }); this.inputElement.parentNode.replaceChild(textarea, this.inputElement); this.inputElement = textarea; } // add focus and blur events to newly created input element if(this.inputElement && this.inputElement.tagName !== tagName){ this.inputElement.addEventListener('focus', this.onInputFocusCallback, false); this.inputElement.addEventListener('blur', this.onInputBlurCallback, false); } if(this.initialInputHeight == 0){ // initial height not set this.initialInputHeight = this.inputElement.offsetHeight; } this.setFocusOnInput(); } /** * Removes attribute on input element if attribute is present * * @private * @param {string} attribute * @memberof UserTextInput */ private removeAttribute(attribute:string):void { if (this.inputElement && this.inputElement.hasAttribute(attribute)) { this.inputElement.removeAttribute(attribute); } } tagType(inputElement: ITag): String { if ( !inputElement.domElement || !inputElement.domElement.tagName ) { return 'TEXTAREA'; } if ( inputElement.domElement.tagName === 'TEXTAREA' || ( inputElement.domElement.hasAttribute('rows') && parseInt(inputElement.domElement.getAttribute('rows'), 10) > 1 ) ) return 'TEXTAREA'; if (inputElement.domElement.tagName === 'INPUT') return 'INPUT'; return 'TEXTAREA'; // TODO } protected onFlowUpdate(event: CustomEvent){ super.onFlowUpdate(event); this.submitButton.loading = false; if(this.submitButton.typing) this.submitButton.typing = false; // animate input field in this.el.setAttribute("tag-type", this._currentTag.type); // replace textarea and visa versa this.checkForCorrectInputTag() // set input field to type password if the dom input field is that, covering up the input var isInputSpecificType: boolean = ["password", "number", "email", "tel"].indexOf(this._currentTag.type) !== -1; this.inputElement.setAttribute("type", isInputSpecificType ? this._currentTag.type : "input"); clearTimeout(this.errorTimer); this.el.removeAttribute("error"); this.inputElement.setAttribute("data-value", ""); this.inputElement.value = ""; this.submitButton.loading = false; this.setPlaceholder(); this.resetValue(); this.setFocusOnInput(); this.controlElements.reset(); if(this._currentTag.type == "group"){ this.buildControlElements(( this._currentTag).elements); }else{ this.buildControlElements([this._currentTag]); } if (this._currentTag.defaultValue) { this.inputElement.value = this._currentTag.defaultValue.toString(); } if(this._currentTag.skipUserInput === true){ this.el.classList.add("hide-input"); } else { this.el.classList.remove("hide-input"); } // Set rows attribute if present if (( this._currentTag).rows && ( this._currentTag).rows > 1) { this.inputElement.setAttribute('rows', ( this._currentTag).rows.toString()); } if(UserInputElement.hideUserInputOnNoneTextInput){ // toggle userinput hide if(this.controlElements.active){ this.el.classList.add("hide-input"); // set focus on first control element this.controlElements.focusFrom("bottom"); }else{ this.el.classList.remove("hide-input"); } } this.resetInputHeight(); setTimeout(() => { this.onInputChange(); }, 300); } private onControlElementProgressChange(event: CustomEvent){ const status: string = event.detail; this.disabled = status == ControlElementProgressStates.BUSY; if(!ConversationalForm.suppressLog) console.log('option, disabled 2', ); } private buildControlElements(tags: Array){ this.controlElements.buildTags(tags); } private onControlElementSubmit(event: CustomEvent){ ConversationalForm.illustrateFlow(this, "receive", event.type, event.detail); // when ex a RadioButton is clicked.. const controlElement: IControlElement = event.detail; this.controlElements.updateStateOnElements(controlElement); this.doSubmit(); } private onSubmitButtonChangeState(event: CustomEvent){ this.onEnterOrSubmitButtonSubmit(event); } private isMetaKeyPressed(event: KeyboardEvent): boolean{ // if any meta keys, then ignore, getModifierState, but safari does not support.. if(event.metaKey || [91, 93].indexOf(event.keyCode) !== -1) return; } private onKeyDown(event: KeyboardEvent){ if(!this.active && !this.controlElements.focus) return; if(this.isControlElementsActiveAndUserInputHidden()) return; if(this.isMetaKeyPressed(event)) return; // if any meta keys, then ignore if(event.keyCode == Dictionary.keyCodes["shift"]) this.shiftIsDown = true; // If submit is prevented by option 'preventSubmitOnEnter' if (this.cfReference.preventSubmitOnEnter === true && this.inputElement.hasAttribute('rows') && parseInt(this.inputElement.getAttribute('rows')) > 1) { return; } // prevent textarea line breaks if(event.keyCode == Dictionary.keyCodes["enter"] && !event.shiftKey){ event.preventDefault(); } } private isControlElementsActiveAndUserInputHidden():boolean{ return this.controlElements && this.controlElements.active && UserInputElement.hideUserInputOnNoneTextInput } private onKeyUp(event: KeyboardEvent){ if((!this.active && !this.isControlElementsActiveAndUserInputHidden()) && !this.controlElements.focus) return; if(this.isMetaKeyPressed(event)) return; if(event.keyCode == Dictionary.keyCodes["shift"]){ this.shiftIsDown = false; }else if(event.keyCode == Dictionary.keyCodes["up"]){ event.preventDefault(); if(this.active && !this.controlElements.focus) this.controlElements.focusFrom("bottom"); }else if(event.keyCode == Dictionary.keyCodes["down"]){ event.preventDefault(); if(this.active && !this.controlElements.focus) this.controlElements.focusFrom("top"); }else if(event.keyCode == Dictionary.keyCodes["tab"]){ // tab key pressed, check if node is child of CF, if then then reset focus to input element var doesKeyTargetExistInCF: boolean = false; var node = ( event.target).parentNode; while (node != null) { if (node === this.cfReference.el) { doesKeyTargetExistInCF = true; break; } node = node.parentNode; } // prevent normal behaviour, we are not here to take part, we are here to take over! if(!doesKeyTargetExistInCF){ event.preventDefault(); if(!this.controlElements.active) this.setFocusOnInput(); } } if(this.el.hasAttribute("disabled")) return; const value: FlowDTO = this.getFlowDTO(); if((event.keyCode == Dictionary.keyCodes["enter"] && !event.shiftKey) || event.keyCode == Dictionary.keyCodes["space"]){ if(event.keyCode == Dictionary.keyCodes["enter"] && this.active){ if (this.cfReference.preventSubmitOnEnter === true) return; event.preventDefault(); this.onEnterOrSubmitButtonSubmit(); }else{ // either click on submit button or do something with control elements if(event.keyCode == Dictionary.keyCodes["enter"] || event.keyCode == Dictionary.keyCodes["space"]){ event.preventDefault(); const tagType: string = this._currentTag.type == "group" ? (this._currentTag).getGroupTagType() : this._currentTag.type; if(tagType == "select" || tagType == "checkbox"){ const mutiTag: SelectTag | InputTag = this._currentTag; // if select or checkbox then check for multi select item if(tagType == "checkbox" || ( mutiTag).multipleChoice){ if((this.active || this.isControlElementsActiveAndUserInputHidden()) && event.keyCode == Dictionary.keyCodes["enter"]){ // click on UserTextInput submit button, only ENTER allowed this.submitButton.click(); }else{ // let UI know that we changed the key if(!this.active && !this.controlElements.active && !this.isControlElementsActiveAndUserInputHidden()){ // after ui has been selected we RESET the input/filter this.resetValue(); this.setFocusOnInput(); } this.dispatchKeyChange(value, event.keyCode); } }else{ this.dispatchKeyChange(value, event.keyCode); } }else{ if(this._currentTag.type == "group"){ // let the controlements handle action this.dispatchKeyChange(value, event.keyCode); } } }else if(event.keyCode == Dictionary.keyCodes["space"] && document.activeElement){ this.dispatchKeyChange(value, event.keyCode); } } }else if(event.keyCode != Dictionary.keyCodes["shift"] && event.keyCode != Dictionary.keyCodes["tab"]){ this.dispatchKeyChange(value, event.keyCode) } this.onInputChange(); } private dispatchKeyChange(dto: FlowDTO, keyCode: number){ // typing ---> this.submitButton.typing = dto.text && dto.text.length > 0; ConversationalForm.illustrateFlow(this, "dispatch", UserInputEvents.KEY_CHANGE, dto); this.eventTarget.dispatchEvent(new CustomEvent(UserInputEvents.KEY_CHANGE, { detail: { dto: dto, keyCode: keyCode, inputFieldActive: this.active } })); } protected windowFocus(event: Event){ super.windowFocus(event); this.setFocusOnInput(); } private onInputBlur(event: FocusEvent){ this._active = false; this.eventTarget.dispatchEvent(new CustomEvent(UserInputEvents.BLUR)); } private onInputFocus(event: FocusEvent){ this._active = true; this.onInputChange(); this.eventTarget.dispatchEvent(new CustomEvent(UserInputEvents.FOCUS)); } public setFocusOnInput(){ if(!UserInputElement.preventAutoFocus && !this.el.classList.contains("hide-input")){ this.inputElement.focus(); } } protected onEnterOrSubmitButtonSubmit(event: CustomEvent = null){ const isControlElementsActiveAndUserInputHidden: boolean = this.controlElements.active && UserInputElement.hideUserInputOnNoneTextInput; if((this.active || isControlElementsActiveAndUserInputHidden) && this.controlElements.highlighted){ // active input field and focus on control elements happens when a control element is highlighted this.controlElements.clickOnHighlighted(); }else{ if(!this._currentTag){ // happens when a form is empty, so just play along and submit response to chatlist.. this.eventTarget.cf.addUserChatResponse(this.inputElement.value); }else{ // we need to check if current tag is file if(this._currentTag.type == "file" && event){ // trigger this.controlElements.getElement(0)).triggerFileSelect(); }else{ // for groups, we expect that there is always a default value set this.doSubmit(); } } } } private doSubmit(){ const dto: FlowDTO = this.getFlowDTO(); this.submitButton.loading = true; this.disabled = true; this.el.removeAttribute("error"); this.inputElement.setAttribute("data-value", ""); ConversationalForm.illustrateFlow(this, "dispatch", UserInputEvents.SUBMIT, dto); this.eventTarget.dispatchEvent(new CustomEvent(UserInputEvents.SUBMIT, { detail: dto })); } private resetValue(){ this.inputElement.value = ""; if (this.inputElement.hasAttribute('rows')) this.inputElement.setAttribute('rows', '1'); this.onInputChange(); } public dealloc(){ this.inputElement.removeEventListener('blur', this.onInputBlurCallback, false); this.onInputBlurCallback = null; this.inputElement.removeEventListener('focus', this.onInputFocusCallback, false); this.onInputFocusCallback = null; document.removeEventListener("keydown", this.keyDownCallback, false); this.keyDownCallback = null; document.removeEventListener("keyup", this.keyUpCallback, false); this.keyUpCallback = null; this.eventTarget.removeEventListener(ControlElementEvents.SUBMIT_VALUE, this.onControlElementSubmitCallback, false); this.onControlElementSubmitCallback = null; // remove submit button instance this.submitButton.el.removeEventListener(UserInputSubmitButtonEvents.CHANGE, this.onSubmitButtonChangeStateCallback, false); this.onSubmitButtonChangeStateCallback = null; this.submitButton.dealloc(); this.submitButton = null; super.dealloc(); } // override public getTemplate () : string { return this.customTemplate || `
`; } } }