From 28c3a2026c4f7367f4c6ac20734dfd84fe00a2d7 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Mon, 29 Aug 2016 16:15:28 -0700 Subject: [PATCH] Pull CompositionHelper into a module Fixes #254 --- src/CompositionHelper.js | 196 +++++++++++++++++++++++++++++++++++++++ src/xterm.js | 196 +-------------------------------------- 2 files changed, 197 insertions(+), 195 deletions(-) create mode 100644 src/CompositionHelper.js diff --git a/src/CompositionHelper.js b/src/CompositionHelper.js new file mode 100644 index 00000000..46bdab91 --- /dev/null +++ b/src/CompositionHelper.js @@ -0,0 +1,196 @@ +/** + * Encapsulates the logic for handling compositionstart, compositionupdate and compositionend + * events, displaying the in-progress composition to the UI and forwarding the final composition + * to the handler. + * @param {HTMLTextAreaElement} textarea The textarea that xterm uses for input. + * @param {HTMLElement} compositionView The element to display the in-progress composition in. + * @param {Terminal} terminal The Terminal to forward the finished composition to. + */ +function CompositionHelper(textarea, compositionView, terminal) { + this.textarea = textarea; + this.compositionView = compositionView; + this.terminal = terminal; + + // Whether input composition is currently happening, eg. via a mobile keyboard, speech input + // or IME. This variable determines whether the compositionText should be displayed on the UI. + this.isComposing = false; + + // The input currently being composed, eg. via a mobile keyboard, speech input or IME. + this.compositionText = null; + + // The position within the input textarea's value of the current composition. + this.compositionPosition = { start: null, end: null }; + + // Whether a composition is in the process of being sent, setting this to false will cancel + // any in-progress composition. + this.isSendingComposition = false; +} + +/** + * Handles the compositionstart event, activating the composition view. + */ +CompositionHelper.prototype.compositionstart = function() { + this.isComposing = true; + this.compositionPosition.start = this.textarea.value.length; + this.compositionView.textContent = ''; + this.compositionView.classList.add('active'); +}; + +/** + * Handles the compositionupdate event, updating the composition view. + * @param {CompositionEvent} ev The event. + */ +CompositionHelper.prototype.compositionupdate = function(ev) { + this.compositionView.textContent = ev.data; + this.updateCompositionElements(); + var self = this; + setTimeout(function() { + self.compositionPosition.end = self.textarea.value.length; + }, 0); +}; + +/** + * Handles the compositionend event, hiding the composition view and sending the composition to + * the handler. + */ +CompositionHelper.prototype.compositionend = function() { + this.finalizeComposition(true); +}; + +/** + * Handles the keydown event, routing any necessary events to the CompositionHelper functions. + * @return Whether the Terminal should continue processing the keydown event. + */ +CompositionHelper.prototype.keydown = function(ev) { + if (this.isComposing || this.isSendingComposition) { + if (ev.keyCode === 229) { + // Continue composing if the keyCode is the "composition character" + return false; + } else if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) { + // Continue composing if the keyCode is a modifier key + return false; + } else { + // Finish composition immediately. This is mainly here for the case where enter is + // pressed and the handler needs to be triggered before the command is executed. + this.finalizeComposition(false); + } + } + + if (ev.keyCode === 229) { + // If the "composition character" is used but gets to this point it means a non-composition + // character (eg. numbers and punctuation) was pressed when the IME was active. + this.handleAnyTextareaChanges(); + return false; + } + + return true; +}; + +/** + * Finalizes the composition, resuming regular input actions. This is called when a composition + * is ending. + * @param {boolean} waitForPropogation Whether to wait for events to propogate before sending + * the input. This should be false if a non-composition keystroke is entered before the + * compositionend event is triggered, such as enter, so that the composition is send before + * the command is executed. + */ +CompositionHelper.prototype.finalizeComposition = function(waitForPropogation) { + this.compositionView.classList.remove('active'); + this.isComposing = false; + this.clearTextareaPosition(); + + if (!waitForPropogation) { + // Cancel any delayed composition send requests and send the input immediately. + this.isSendingComposition = false; + var input = this.textarea.value.substring(this.compositionPosition.start, this.compositionPosition.end); + this.terminal.handler(input); + } else { + // Make a deep copy of the composition position here as a new compositionstart event may + // fire before the setTimeout executes. + var currentCompositionPosition = { + start: this.compositionPosition.start, + end: this.compositionPosition.end, + } + + // Since composition* events happen before the changes take place in the textarea on most + // browsers, use a setTimeout with 0ms time to allow the native compositionend event to + // complete. This ensures the correct character is retrieved, this solution was used + // because: + // - The compositionend event's data property is unreliable, at least on Chromium + // - The last compositionupdate event's data property does not always accurately describe + // the character, a counter example being Korean where an ending consonsant can move to + // the following character if the following input is a vowel. + var self = this; + this.isSendingComposition = true; + setTimeout(function () { + // Ensure that the input has not already been sent + if (self.isSendingComposition) { + self.isSendingComposition = false; + var input; + if (self.isComposing) { + // Use the end position to get the string if a new composition has started. + input = self.textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end); + } else { + // Don't use the end position here in order to pick up any characters after the + // composition has finished, for example when typing a non-composition character + // (eg. 2) after a composition character. + input = self.textarea.value.substring(currentCompositionPosition.start); + } + self.terminal.handler(input); + } + }, 0); + } +}; + +/** + * Apply any changes made to the textarea after the current event chain is allowed to complete. + * This should be called when not currently composing but a keydown event with the "composition + * character" (229) is triggered, in order to allow non-composition text to be entered when an + * IME is active. + */ +CompositionHelper.prototype.handleAnyTextareaChanges = function() { + var oldValue = this.textarea.value; + var self = this; + setTimeout(function() { + // Ignore if a composition has started since the timeout + if (!self.isComposing) { + var newValue = self.textarea.value; + var diff = newValue.replace(oldValue, ''); + if (diff.length > 0) { + self.terminal.handler(diff); + } + } + }, 0); +}; + +/** + * Positions the composition view on top of the cursor and the textarea just below it (so the + * IME helper dialog is positioned correctly). + */ +CompositionHelper.prototype.updateCompositionElements = function(dontRecurse) { + if (!this.isComposing) { + return; + } + var cursor = this.terminal.element.querySelector('.terminal-cursor'); + if (cursor) { + this.compositionView.style.left = cursor.offsetLeft + 'px'; + this.compositionView.style.top = cursor.offsetTop + 'px'; + var compositionViewBounds = this.compositionView.getBoundingClientRect(); + this.textarea.style.left = cursor.offsetLeft + compositionViewBounds.width + 'px'; + this.textarea.style.top = (cursor.offsetTop + cursor.offsetHeight) + 'px'; + } + if (!dontRecurse) { + setTimeout(this.updateCompositionElements.bind(this, true), 0); + } +}; + +/** + * Clears the textarea's position so that the cursor does not blink on IE. + * @private + */ +CompositionHelper.prototype.clearTextareaPosition = function() { + this.textarea.style.left = ''; + this.textarea.style.top = ''; +}; + +export { CompositionHelper }; diff --git a/src/xterm.js b/src/xterm.js index f4d26465..896fc6a0 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -31,6 +31,7 @@ * other features. */ +import { CompositionHelper } from './CompositionHelper.js'; import { EventEmitter } from './EventEmitter.js'; /** @@ -47,201 +48,6 @@ import { EventEmitter } from './EventEmitter.js'; // Let it work inside Node.js for automated testing purposes. var document = (typeof window != 'undefined') ? window.document : null; - /** - * Encapsulates the logic for handling compositionstart, compositionupdate and compositionend - * events, displaying the in-progress composition to the UI and forwarding the final composition - * to the handler. - * @param {HTMLTextAreaElement} textarea The textarea that xterm uses for input. - * @param {HTMLElement} compositionView The element to display the in-progress composition in. - * @param {Terminal} terminal The Terminal to forward the finished composition to. - */ - function CompositionHelper(textarea, compositionView, terminal) { - this.textarea = textarea; - this.compositionView = compositionView; - this.terminal = terminal; - - // Whether input composition is currently happening, eg. via a mobile keyboard, speech input - // or IME. This variable determines whether the compositionText should be displayed on the UI. - this.isComposing = false; - - // The input currently being composed, eg. via a mobile keyboard, speech input or IME. - this.compositionText = null; - - // The position within the input textarea's value of the current composition. - this.compositionPosition = { start: null, end: null }; - - // Whether a composition is in the process of being sent, setting this to false will cancel - // any in-progress composition. - this.isSendingComposition = false; - } - - /** - * Handles the compositionstart event, activating the composition view. - */ - CompositionHelper.prototype.compositionstart = function() { - this.isComposing = true; - this.compositionPosition.start = this.textarea.value.length; - this.compositionView.textContent = ''; - this.compositionView.classList.add('active'); - }; - - /** - * Handles the compositionupdate event, updating the composition view. - * @param {CompositionEvent} ev The event. - */ - CompositionHelper.prototype.compositionupdate = function(ev) { - this.compositionView.textContent = ev.data; - this.updateCompositionElements(); - var self = this; - setTimeout(function() { - self.compositionPosition.end = self.textarea.value.length; - }, 0); - }; - - /** - * Handles the compositionend event, hiding the composition view and sending the composition to - * the handler. - */ - CompositionHelper.prototype.compositionend = function() { - this.finalizeComposition(true); - }; - - /** - * Handles the keydown event, routing any necessary events to the CompositionHelper functions. - * @return Whether the Terminal should continue processing the keydown event. - */ - CompositionHelper.prototype.keydown = function(ev) { - if (this.isComposing || this.isSendingComposition) { - if (ev.keyCode === 229) { - // Continue composing if the keyCode is the "composition character" - return false; - } else if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) { - // Continue composing if the keyCode is a modifier key - return false; - } else { - // Finish composition immediately. This is mainly here for the case where enter is - // pressed and the handler needs to be triggered before the command is executed. - this.finalizeComposition(false); - } - } - - if (ev.keyCode === 229) { - // If the "composition character" is used but gets to this point it means a non-composition - // character (eg. numbers and punctuation) was pressed when the IME was active. - this.handleAnyTextareaChanges(); - return false; - } - - return true; - }; - - /** - * Finalizes the composition, resuming regular input actions. This is called when a composition - * is ending. - * @param {boolean} waitForPropogation Whether to wait for events to propogate before sending - * the input. This should be false if a non-composition keystroke is entered before the - * compositionend event is triggered, such as enter, so that the composition is send before - * the command is executed. - */ - CompositionHelper.prototype.finalizeComposition = function(waitForPropogation) { - this.compositionView.classList.remove('active'); - this.isComposing = false; - this.clearTextareaPosition(); - - if (!waitForPropogation) { - // Cancel any delayed composition send requests and send the input immediately. - this.isSendingComposition = false; - var input = this.textarea.value.substring(this.compositionPosition.start, this.compositionPosition.end); - this.terminal.handler(input); - } else { - // Make a deep copy of the composition position here as a new compositionstart event may - // fire before the setTimeout executes. - var currentCompositionPosition = { - start: this.compositionPosition.start, - end: this.compositionPosition.end, - } - - // Since composition* events happen before the changes take place in the textarea on most - // browsers, use a setTimeout with 0ms time to allow the native compositionend event to - // complete. This ensures the correct character is retrieved, this solution was used - // because: - // - The compositionend event's data property is unreliable, at least on Chromium - // - The last compositionupdate event's data property does not always accurately describe - // the character, a counter example being Korean where an ending consonsant can move to - // the following character if the following input is a vowel. - var self = this; - this.isSendingComposition = true; - setTimeout(function () { - // Ensure that the input has not already been sent - if (self.isSendingComposition) { - self.isSendingComposition = false; - var input; - if (self.isComposing) { - // Use the end position to get the string if a new composition has started. - input = self.textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end); - } else { - // Don't use the end position here in order to pick up any characters after the - // composition has finished, for example when typing a non-composition character - // (eg. 2) after a composition character. - input = self.textarea.value.substring(currentCompositionPosition.start); - } - self.terminal.handler(input); - } - }, 0); - } - }; - - /** - * Apply any changes made to the textarea after the current event chain is allowed to complete. - * This should be called when not currently composing but a keydown event with the "composition - * character" (229) is triggered, in order to allow non-composition text to be entered when an - * IME is active. - */ - CompositionHelper.prototype.handleAnyTextareaChanges = function() { - var oldValue = this.textarea.value; - var self = this; - setTimeout(function() { - // Ignore if a composition has started since the timeout - if (!self.isComposing) { - var newValue = self.textarea.value; - var diff = newValue.replace(oldValue, ''); - if (diff.length > 0) { - self.terminal.handler(diff); - } - } - }, 0); - }; - - /** - * Positions the composition view on top of the cursor and the textarea just below it (so the - * IME helper dialog is positioned correctly). - */ - CompositionHelper.prototype.updateCompositionElements = function(dontRecurse) { - if (!this.isComposing) { - return; - } - var cursor = this.terminal.element.querySelector('.terminal-cursor'); - if (cursor) { - this.compositionView.style.left = cursor.offsetLeft + 'px'; - this.compositionView.style.top = cursor.offsetTop + 'px'; - var compositionViewBounds = this.compositionView.getBoundingClientRect(); - this.textarea.style.left = cursor.offsetLeft + compositionViewBounds.width + 'px'; - this.textarea.style.top = (cursor.offsetTop + cursor.offsetHeight) + 'px'; - } - if (!dontRecurse) { - setTimeout(this.updateCompositionElements.bind(this, true), 0); - } - }; - - /** - * Clears the textarea's position so that the cursor does not blink on IE. - * @private - */ - CompositionHelper.prototype.clearTextareaPosition = function() { - this.textarea.style.left = ''; - this.textarea.style.top = ''; - }; - /** * Represents the viewport of a terminal, the visible area within the larger buffer of output. * Logic for the virtual scroll bar is included in this object.