From fc7b22dc485a52e736fd85bae0aaba9bbdd63c80 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 12 Jul 2016 16:03:31 -0700 Subject: [PATCH 01/24] Manually revert textarea changes --- src/xterm.css | 17 ++++ src/xterm.js | 234 ++++++++++++++++++-------------------------------- 2 files changed, 101 insertions(+), 150 deletions(-) diff --git a/src/xterm.css b/src/xterm.css index b34f41a2..348fc097 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -47,6 +47,23 @@ outline: none; } +.terminal .xterm-helpers { + position: absolute; + top: 0; +} + +.terminal .xterm-helper-textarea { + /* + * HACK: to fix IE's blinking cursor + * Move textarea out of the screen to the far left, so that the cursor is not visible. + */ + text-indent: -9999em; + opacity: 0; + width: 0; + height: 0; + z-index: -10; +} + .terminal .terminal-cursor { background-color: #fff; color: #000; diff --git a/src/xterm.js b/src/xterm.js index 8633ff96..30e6aee5 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -108,8 +108,8 @@ var self = this; function on() { var args = Array.prototype.slice.call(arguments); - self.removeListener(type, on); - return listener.apply(self, args); + this.removeListener(type, on); + return listener.apply(this, args); } on.listener = listener; return this.on(type, on); @@ -434,7 +434,17 @@ * @public */ Terminal.prototype.focus = function() { - return this.element.focus(); + if (document.activeElement === this.textarea) { + return; + } + + if (this.sendFocus) { + this.send('\x1b[I'); + } + + this.showCursor(); + this.textarea.focus(); + Terminal.focus = this; }; /** @@ -460,7 +470,17 @@ * @public */ Terminal.prototype.blur = function() { - return this.element.blur(); + if (Terminal.focus !== this) { + return; + } + + this.cursorState = 0; + this.refresh(this.y, this.y); + this.textarea.blur(); + if (this.sendFocus) { + this.send('\x1b[0]'); + } + Terminal.focus = null; }; /** @@ -482,48 +502,9 @@ * Initialize default behavior */ Terminal.prototype.initGlobal = function() { - Terminal.bindKeys(this); Terminal.bindPaste(this); + Terminal.bindKeys(this); Terminal.bindCopy(this); - Terminal.bindCut(this); - Terminal.bindDrop(this); - Terminal.bindFocus(this); - Terminal.bindBlur(this); - }; - - /** - * Clears all selected text, inside the terminal. - */ - Terminal.prototype.clearSelection = function() { - var selectionBaseNode = window.getSelection().baseNode; - - if (selectionBaseNode && (this.element.contains(selectionBaseNode.parentElement))) { - window.getSelection().removeAllRanges(); - } - }; - - /** - * This function temporarily enables (leases) the contentEditable value of the terminal, which - * should be set back to false within 5 seconds at most. - */ - Terminal.prototype.leaseContentEditable = function (ms, callback) { - var term = this; - - term.element.contentEditable = true; - - /** - * Blur and re-focus instantly. This is due to a weird focus state on Chrome, when setting - * contentEditable to true on a focused element. - */ - term.blur(); - term.focus(); - - setTimeout(function () { - term.element.contentEditable = false; - if (typeof callback == 'function') { - callback.call(term); - } - }, ms || 5000); }; /** @@ -531,70 +512,15 @@ * contentEditable value set to true. */ Terminal.bindPaste = function(term) { - on(term.element, 'paste', function(ev) { + on([term.textarea, term.element], 'paste', function(ev) { + ev.stopPropagation(); if (ev.clipboardData) { var text = ev.clipboardData.getData('text/plain'); - term.emit('paste', text, ev); term.handler(text); - /** - * Cancel the paste event, or else things will be pasted twice: - * 1. by the terminal handler - * 2. by the browser, because of the contentEditable value being true - */ - term.cancel(ev, true); - - /** - * After the paste event is completed, always set the contentEditable value to false. - */ - term.element.contentEditable = false; + term.textarea.value = ''; + return term.cancel(ev); } }); - - /** - * Hack pasting with keyboard, in order to make it work without contentEditable. - * When a user types Ctrl + Shift + V or Shift + Insert on a non Mac or Cmd + V on a Mac, - * lease the contentEditable value as true. - */ - on(term.element, 'keydown', function (ev) { - var isEditable = term.element.contentEditable === "true"; - - /** - * If on a Mac, lease the contentEditable value temporarily, when the user presses - * the Cmd button, in a keydown event order to paste frictionlessly. - */ - if (term.isMac && ev.metaKey && !isEditable) { - term.leaseContentEditable(5000); - } - - if (!term.isMac && !isEditable) { - if ((ev.keyCode == 45 && ev.shiftKey && !ev.ctrlKey) || // Shift + Insert - (ev.keyCode == 86 && ev.shiftKey && ev.ctrlKey)) { // Ctrl + Shict + V - term.leaseContentEditable(); - } - } - }); - - /** - * Hack pasting with right-click in order to allow right-click paste, by leasing the - * contentEditable value as true. - */ - on(term.element, 'contextmenu', function (ev) { - term.leaseContentEditable(); - }); - }; - - - /* - * Apply key handling to the terminal - */ - Terminal.bindKeys = function(term) { - on(term.element, 'keydown', function(ev) { - term.keyDown(ev); - }, true); - - on(term.element, 'keypress', function(ev) { - term.keyPress(ev); - }, true); }; /** @@ -622,48 +548,44 @@ return processedText; }; + /** + * Apply key handling to the terminal + */ + Terminal.bindKeys = function(term) { + on(term.element, 'keydown', function(ev) { + if (document.activeElement != this) { + return; + } + term.keyDown(ev); + }, true); + + on(term.element, 'keypress', function(ev) { + if (document.activeElement != this) { + return; + } + term.keyPress(ev); + }, true); + + on(term.element, 'keyup', term.focus.bind(term)); + + on(term.textarea, 'keydown', function(ev) { + term.keyDown(ev); + }, true); + + on(term.textarea, 'keypress', function(ev) { + term.keyPress(ev); + // Truncate the textarea's value, since it is not needed + this.value = ''; + }, true); + }; + /** * Binds copy functionality to the given terminal. * @static */ Terminal.bindCopy = function(term) { on(term.element, 'copy', function(ev) { - var copiedText = window.getSelection().toString(), - text = Terminal.prepareCopiedTextForClipboard(copiedText); - - ev.clipboardData.setData('text/plain', text); - ev.preventDefault(); - }); - }; - - /** - * Cancel the cut event completely - */ - Terminal.bindCut = function(term) { - on(term.element, 'cut', function (ev) { - ev.preventDefault(); - }); - }; - - - Terminal.bindDrop = function (term) { - /* - * Do not perform the "drop" event. Altering the contents of the - * terminal with drag n drop is unwanted behavior. - */ - on(term.element, 'drop', function (ev) { - term.cancel(ev, true); - }); - }; - - - Terminal.click = function (term) { - /* - * Do not perform the "drop" event. Altering the contents of the - * terminal with drag n drop is unwanted behavior. - */ - on(term.element, 'click', function (ev) { - term.cancel(ev, true); + return; // temporary }); }; @@ -740,7 +662,6 @@ this.element.classList.add('xterm'); this.element.classList.add('xterm-theme-' + this.theme); this.element.setAttribute('tabindex', 0); - this.element.spellcheck = false; /* * Create the container that will hold the lines of the terminal and then @@ -751,6 +672,27 @@ this.element.appendChild(this.rowContainer); this.children = []; + /* + * Create the container that will hold helpers like the textarea for + * capturing DOM Events. Then produce the helpers. + */ + this.helperContainer = document.createElement('div'); + this.helperContainer.classList.add('xterm-helpers'); + this.element.appendChild(this.helperContainer); + this.textarea = document.createElement('textarea'); + this.textarea.classList.add('xterm-helper-textarea'); + this.textarea.setAttribute('autocorrect', 'off'); + this.textarea.setAttribute('autocapitalize', 'off'); + this.textarea.setAttribute('spellcheck', 'false'); + this.textarea.tabIndex = 0; + this.textarea.onfocus = function() { + self.emit('focus', {terminal: self}); + } + this.textarea.onblur = function() { + self.emit('blur', {terminal: self}); + } + this.helperContainer.appendChild(this.textarea); + for (; i < this.rows; i++) { this.insertRow(); } @@ -2757,6 +2699,8 @@ Terminal.prototype.keyPress = function(ev) { var key; + this.cancel(ev); + if (ev.charCode) { key = ev.charCode; } else if (ev.which == null) { @@ -2775,21 +2719,11 @@ key = String.fromCharCode(key); - /** - * When a key is pressed and a character is sent to the terminal, then clear any text - * selected in the terminal. - */ - if (key) { - this.clearSelection(); - } - this.emit('keypress', key, ev); this.emit('key', key, ev); this.showCursor(); this.handler(key); - this.cancel(ev, true); - return false; }; From aaedcfc7002883a2bdc0bedf1b1047ca854d1755 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 12 Jul 2016 16:18:05 -0700 Subject: [PATCH 02/24] Support focus class on the .xterm element --- src/xterm.css | 10 +++++----- src/xterm.js | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/xterm.css b/src/xterm.css index 348fc097..4f1f4796 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -43,7 +43,7 @@ position: relative; } -.terminal:focus { +.terminal.focus { outline: none; } @@ -57,11 +57,11 @@ * HACK: to fix IE's blinking cursor * Move textarea out of the screen to the far left, so that the cursor is not visible. */ - text-indent: -9999em; + /*text-indent: -9999em; opacity: 0; width: 0; height: 0; - z-index: -10; + z-index: -10;*/ } .terminal .terminal-cursor { @@ -69,13 +69,13 @@ color: #000; } -.terminal:not(:focus) .terminal-cursor { +.terminal:not(.focus) .terminal-cursor { outline: 1px solid #fff; outline-offset: -1px; background-color: transparent; } -.terminal:focus .terminal-cursor.blinking { +.terminal.focus .terminal-cursor.blinking { animation: blink-cursor 1.2s infinite step-end; } diff --git a/src/xterm.js b/src/xterm.js index 30e6aee5..a33261c0 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -442,6 +442,7 @@ this.send('\x1b[I'); } + this.element.classList.add('focus'); this.showCursor(); this.textarea.focus(); Terminal.focus = this; @@ -474,6 +475,7 @@ return; } + this.element.classList.remove('focus'); this.cursorState = 0; this.refresh(this.y, this.y); this.textarea.blur(); From a52b7e7a4eaf9b6a37876e6d980fd72d007b58e6 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 12 Jul 2016 16:50:07 -0700 Subject: [PATCH 03/24] Add starting point for IME support --- src/xterm.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/xterm.js b/src/xterm.js index a33261c0..6682f83a 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -579,6 +579,20 @@ // Truncate the textarea's value, since it is not needed this.value = ''; }, true); + + on(term.textarea, 'compositionstart', function(ev) { + console.log('compositionstart', ev); + // TODO: Set a composing flag + }); + on(term.textarea, 'compositionupdate', function(ev) { + console.log('compositionupdate', ev); + // TODO: Display text being composed in the UI (needs to be handled in keydown) + }); + on(term.textarea, 'compositionend', function(ev) { + console.log('compositionend', ev); + // TODO: Send composed text to the pty + // TODO: Remove composing flag + }); }; /** @@ -2464,6 +2478,8 @@ // Key Resources: // https://developer.mozilla.org/en-US/docs/DOM/KeyboardEvent Terminal.prototype.keyDown = function(ev) { + // TODO: Ignore event if currently composing text + var self = this; var result = this.evaluateKeyEscapeSequence(ev); From 26af6ffd4df7449673071654689d95a5f613ccbb Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 12 Jul 2016 18:48:08 -0700 Subject: [PATCH 04/24] Get IMEs working --- src/xterm.css | 11 +++++++++ src/xterm.js | 62 ++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/xterm.css b/src/xterm.css index 4f1f4796..55fe27bf 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -90,6 +90,17 @@ } } +.terminal .composition-view { + background: #000; + color: #FFF; + display: none; + position: absolute; +} + +.terminal .composition-view.active { + display: block; +} + /* * Determine default colors for xterm.js */ diff --git a/src/xterm.js b/src/xterm.js index a6cdd8d4..f2a5b900 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -238,6 +238,15 @@ */ this.isRefreshing = false; + // 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; + + this.compositionPosition = { start: null, end: null }; + /** * Whether there is a full terminal refresh queued */ @@ -581,18 +590,42 @@ this.value = ''; }, true); + // TODO: Refactor into a CompositionHelper object on(term.textarea, 'compositionstart', function(ev) { - console.log('compositionstart', ev); - // TODO: Set a composing flag + this.isComposing = true; + term.compositionPosition.start = this.value.length; + term.compositionView.textContent = ''; + term.compositionView.classList.add('active'); }); on(term.textarea, 'compositionupdate', function(ev) { - console.log('compositionupdate', ev); - // TODO: Display text being composed in the UI (needs to be handled in keydown) + term.compositionPosition.end = this.value.length - 1; + + // Update composition view contents and position + term.compositionView.textContent = ev.data; + var cursor = document.querySelector('.terminal-cursor'); + term.compositionView.style.left = cursor.offsetLeft + 'px'; + term.compositionView.style.top = cursor.offsetTop + 'px'; }); on(term.textarea, 'compositionend', function(ev) { - console.log('compositionend', ev); - // TODO: Send composed text to the pty - // TODO: Remove composing flag + term.compositionView.classList.remove('active'); + this.isComposing = false; + var textarea = this; + // Record composition position here as a new compositionstart event may fire before the + // setTimeout executes + var compositionPosition = term.compositionPosition; + + // 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. + setTimeout(function () { + var input = textarea.value.substring(compositionPosition.start, compositionPosition.end); + term.write(input); + }, 0); }); }; @@ -694,6 +727,7 @@ */ this.helperContainer = document.createElement('div'); this.helperContainer.classList.add('xterm-helpers'); + // TODO: This should probably be inserted once it's filled to prevent an additional layout this.element.appendChild(this.helperContainer); this.textarea = document.createElement('textarea'); this.textarea.classList.add('xterm-helper-textarea'); @@ -708,12 +742,16 @@ self.emit('blur', {terminal: self}); } this.helperContainer.appendChild(this.textarea); + this.compositionView = document.createElement('div'); + this.compositionView.classList.add('composition-view'); + this.helperContainer.appendChild(this.compositionView); for (; i < this.rows; i++) { this.insertRow(); } this.parent.appendChild(this.element); + // Draw the screen. this.refresh(0, this.rows - 1); @@ -1349,6 +1387,12 @@ this.element.appendChild(this.rowContainer); } + // TODO: Put in a listener? + // Update composition view contents and position + var cursor = document.querySelector('.terminal-cursor'); + term.compositionView.style.left = cursor.offsetLeft + 'px'; + term.compositionView.style.top = cursor.offsetTop + 'px'; + this.emit('refresh', {element: this.element, start: start, end: end}); }; @@ -2487,7 +2531,9 @@ * @param {KeyboardEvent} ev The keydown event to be handled. */ Terminal.prototype.keyDown = function(ev) { - // TODO: Ignore event if currently composing text + if (this.isComposing) { + return; + } var self = this; var result = this.evaluateKeyEscapeSequence(ev); From 03fa017d4a6ff6b0a0610f8a3200b0699bb4e7be Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 12 Jul 2016 18:49:37 -0700 Subject: [PATCH 05/24] Re-hide the textarea --- src/xterm.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/xterm.css b/src/xterm.css index 55fe27bf..403389ac 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -57,11 +57,11 @@ * HACK: to fix IE's blinking cursor * Move textarea out of the screen to the far left, so that the cursor is not visible. */ - /*text-indent: -9999em; + text-indent: -9999em; opacity: 0; width: 0; height: 0; - z-index: -10;*/ + z-index: -10; } .terminal .terminal-cursor { From 29000fb79e3e29549f81cf1911693607be28109e Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 12 Jul 2016 19:14:56 -0700 Subject: [PATCH 06/24] Refactor into CompositionHelper object --- src/xterm.js | 93 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/src/xterm.js b/src/xterm.js index f2a5b900..3b6e2d60 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -133,6 +133,64 @@ }; + 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 }; + } + + CompositionHelper.prototype.compositionstart = function(ev) { + this.isComposing = true; + this.compositionPosition.start = this.textarea.value.length; + this.compositionView.textContent = ''; + this.compositionView.classList.add('active'); + }; + + CompositionHelper.prototype.compositionupdate = function(ev) { + this.compositionPosition.end = this.textarea.value.length - 1; + this.compositionView.textContent = ev.data; + this.updateCursorPosition(); + }; + + CompositionHelper.prototype.compositionend = function(ev) { + this.compositionView.classList.remove('active'); + this.isComposing = false; + // Record composition position here as a new compositionstart event may fire before the + // setTimeout executes + var currentCompositionPosition = this.compositionPosition; + + // 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; + setTimeout(function () { + var input = self.textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end); + self.terminal.write(input); + }, 0); + }; + + CompositionHelper.prototype.updateCursorPosition = function() { + var cursor = document.querySelector('.terminal-cursor'); + term.compositionView.style.left = cursor.offsetLeft + 'px'; + term.compositionView.style.top = cursor.offsetTop + 'px'; + }; + + /** * States */ @@ -238,15 +296,6 @@ */ this.isRefreshing = false; - // 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; - - this.compositionPosition = { start: null, end: null }; - /** * Whether there is a full terminal refresh queued */ @@ -592,22 +641,25 @@ // TODO: Refactor into a CompositionHelper object on(term.textarea, 'compositionstart', function(ev) { - this.isComposing = true; + term.compositionHelper.compositionstart.bind(term.compositionHelper, ev)(); + /*this.isComposing = true; term.compositionPosition.start = this.value.length; term.compositionView.textContent = ''; - term.compositionView.classList.add('active'); + term.compositionView.classList.add('active');*/ }); on(term.textarea, 'compositionupdate', function(ev) { - term.compositionPosition.end = this.value.length - 1; + term.compositionHelper.compositionupdate.bind(term.compositionHelper, ev)(); + /*term.compositionPosition.end = this.value.length - 1; // Update composition view contents and position term.compositionView.textContent = ev.data; var cursor = document.querySelector('.terminal-cursor'); term.compositionView.style.left = cursor.offsetLeft + 'px'; - term.compositionView.style.top = cursor.offsetTop + 'px'; + term.compositionView.style.top = cursor.offsetTop + 'px';*/ }); on(term.textarea, 'compositionend', function(ev) { - term.compositionView.classList.remove('active'); + term.compositionHelper.compositionend.bind(term.compositionHelper, ev)(); + /*term.compositionView.classList.remove('active'); this.isComposing = false; var textarea = this; // Record composition position here as a new compositionstart event may fire before the @@ -625,7 +677,7 @@ setTimeout(function () { var input = textarea.value.substring(compositionPosition.start, compositionPosition.end); term.write(input); - }, 0); + }, 0);*/ }); }; @@ -742,8 +794,10 @@ self.emit('blur', {terminal: self}); } this.helperContainer.appendChild(this.textarea); + this.compositionView = document.createElement('div'); this.compositionView.classList.add('composition-view'); + this.compositionHelper = new CompositionHelper(this.textarea, this.compositionView, this); this.helperContainer.appendChild(this.compositionView); for (; i < this.rows; i++) { @@ -1387,11 +1441,8 @@ this.element.appendChild(this.rowContainer); } - // TODO: Put in a listener? - // Update composition view contents and position - var cursor = document.querySelector('.terminal-cursor'); - term.compositionView.style.left = cursor.offsetLeft + 'px'; - term.compositionView.style.top = cursor.offsetTop + 'px'; + // TODO: Attach to refresh event instead? + term.compositionHelper.updateCursorPosition(); this.emit('refresh', {element: this.element, start: start, end: end}); }; @@ -2531,7 +2582,7 @@ * @param {KeyboardEvent} ev The keydown event to be handled. */ Terminal.prototype.keyDown = function(ev) { - if (this.isComposing) { + if (this.compositionHelper.isComposing) { return; } From c656ed04b0050d40d85e24e1e0753ecf3736ab1c Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 12 Jul 2016 20:19:26 -0700 Subject: [PATCH 07/24] Use handler not write, fix other edge cases --- src/xterm.js | 123 ++++++++++++++++++++++++++++----------------------- 1 file changed, 68 insertions(+), 55 deletions(-) diff --git a/src/xterm.js b/src/xterm.js index 3b6e2d60..c0f49c89 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -147,43 +147,81 @@ // The position within the input textarea's value of the current composition. this.compositionPosition = { start: null, end: null }; + + this.isSendingComposition = false; } - CompositionHelper.prototype.compositionstart = function(ev) { + CompositionHelper.prototype.compositionstart = function() { this.isComposing = true; this.compositionPosition.start = this.textarea.value.length; + console.log('In compositionstart: compositionPosition=', this.compositionPosition); this.compositionView.textContent = ''; this.compositionView.classList.add('active'); }; CompositionHelper.prototype.compositionupdate = function(ev) { - this.compositionPosition.end = this.textarea.value.length - 1; + this.compositionView.textContent = ev.data; this.updateCursorPosition(); - }; - - CompositionHelper.prototype.compositionend = function(ev) { - this.compositionView.classList.remove('active'); - this.isComposing = false; - // Record composition position here as a new compositionstart event may fire before the - // setTimeout executes - var currentCompositionPosition = this.compositionPosition; - - // 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; - setTimeout(function () { - var input = self.textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end); - self.terminal.write(input); + setTimeout(function() { + self.compositionPosition.end = self.textarea.value.length; + + console.log('In compositionupdate: compositionPosition=', self.compositionPosition); }, 0); }; + CompositionHelper.prototype.compositionend = function() { + this.finalizeComposition(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; + + 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) { + console.log('send input ' + input + ' delayed'); + self.isSendingComposition = false; + var input = self.textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end); + self.terminal.handler(input); + } + }, 0); + } + } + CompositionHelper.prototype.updateCursorPosition = function() { var cursor = document.querySelector('.terminal-cursor'); term.compositionView.style.left = cursor.offsetLeft + 'px'; @@ -639,45 +677,14 @@ this.value = ''; }, true); - // TODO: Refactor into a CompositionHelper object on(term.textarea, 'compositionstart', function(ev) { term.compositionHelper.compositionstart.bind(term.compositionHelper, ev)(); - /*this.isComposing = true; - term.compositionPosition.start = this.value.length; - term.compositionView.textContent = ''; - term.compositionView.classList.add('active');*/ }); on(term.textarea, 'compositionupdate', function(ev) { term.compositionHelper.compositionupdate.bind(term.compositionHelper, ev)(); - /*term.compositionPosition.end = this.value.length - 1; - - // Update composition view contents and position - term.compositionView.textContent = ev.data; - var cursor = document.querySelector('.terminal-cursor'); - term.compositionView.style.left = cursor.offsetLeft + 'px'; - term.compositionView.style.top = cursor.offsetTop + 'px';*/ }); on(term.textarea, 'compositionend', function(ev) { term.compositionHelper.compositionend.bind(term.compositionHelper, ev)(); - /*term.compositionView.classList.remove('active'); - this.isComposing = false; - var textarea = this; - // Record composition position here as a new compositionstart event may fire before the - // setTimeout executes - var compositionPosition = term.compositionPosition; - - // 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. - setTimeout(function () { - var input = textarea.value.substring(compositionPosition.start, compositionPosition.end); - term.write(input); - }, 0);*/ }); }; @@ -2582,8 +2589,14 @@ * @param {KeyboardEvent} ev The keydown event to be handled. */ Terminal.prototype.keyDown = function(ev) { - if (this.compositionHelper.isComposing) { - return; + if (this.compositionHelper.isComposing || this.compositionHelper.isSendingComposition) { + if (ev.keyCode === 229) { + // Continue composing + return; + } else { + // Finish composition immediately + this.compositionHelper.finalizeComposition(null); + } } var self = this; From cab79c473f2698e6051252f6bd0136abc6949622 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 12 Jul 2016 20:26:18 -0700 Subject: [PATCH 08/24] jsdoc and tidy up --- src/xterm.js | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/xterm.js b/src/xterm.js index c0f49c89..07b54b31 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -133,6 +133,14 @@ }; + /** + * 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; @@ -148,29 +156,38 @@ // 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; - console.log('In compositionstart: compositionPosition=', this.compositionPosition); 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.updateCursorPosition(); + this.updateCompositionViewPosition(); var self = this; setTimeout(function() { self.compositionPosition.end = self.textarea.value.length; - - console.log('In compositionupdate: compositionPosition=', self.compositionPosition); }, 0); }; + /** + * Handles the compositionend event, hiding the composition view and sending the composition to + * the handler. + */ CompositionHelper.prototype.compositionend = function() { this.finalizeComposition(true); }; @@ -222,7 +239,10 @@ } } - CompositionHelper.prototype.updateCursorPosition = function() { + /** + * Updates the composition view's position. + */ + CompositionHelper.prototype.updateCompositionViewPosition = function() { var cursor = document.querySelector('.terminal-cursor'); term.compositionView.style.left = cursor.offsetLeft + 'px'; term.compositionView.style.top = cursor.offsetTop + 'px'; @@ -1449,7 +1469,7 @@ } // TODO: Attach to refresh event instead? - term.compositionHelper.updateCursorPosition(); + term.compositionHelper.updateCompositionViewPosition(); this.emit('refresh', {element: this.element, start: start, end: end}); }; From 8faea59e316e1d47e0377b894cce63adc7cb847a Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 12 Jul 2016 20:51:55 -0700 Subject: [PATCH 09/24] Handle non-composition input when IME is active --- src/xterm.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/xterm.js b/src/xterm.js index 07b54b31..f9b9786f 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -239,6 +239,22 @@ } } + CompositionHelper.prototype.handleAnyTextareaChanges = function() { + var oldValue = this.textarea.value; + + var self = this; + setTimeout(function() { + // Ensure no 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); + } + /** * Updates the composition view's position. */ @@ -2619,6 +2635,11 @@ } } + if (ev.keyCode === 229) { + this.compositionHelper.handleAnyTextareaChanges(); + return; + } + var self = this; var result = this.evaluateKeyEscapeSequence(ev); From 237e6819bfd66fb266d475bfe358ef1b944b9ca0 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 12 Jul 2016 20:55:24 -0700 Subject: [PATCH 10/24] Fix tests --- test/test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/test.js b/test/test.js index 70b5db8e..63a33c93 100644 --- a/test/test.js +++ b/test/test.js @@ -81,6 +81,9 @@ describe('xterm.js', function() { xterm.handler = function() {}; xterm.showCursor = function() {}; xterm.clearSelection = function() {}; + xterm.compositionHelper = { + isComposing: false + }; }); describe('On Mac OS', function() { From 8b46e842637a2dfdb3fcaf1f2b775ff4b6b5bb1c Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Tue, 12 Jul 2016 21:42:10 -0700 Subject: [PATCH 11/24] Use correct .terminal-cursor for composition view --- src/xterm.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/xterm.js b/src/xterm.js index f9b9786f..e9e6ec9b 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -259,7 +259,7 @@ * Updates the composition view's position. */ CompositionHelper.prototype.updateCompositionViewPosition = function() { - var cursor = document.querySelector('.terminal-cursor'); + var cursor = this.terminal.element.querySelector('.terminal-cursor'); term.compositionView.style.left = cursor.offsetLeft + 'px'; term.compositionView.style.top = cursor.offsetTop + 'px'; }; From e1c1b07a0b1887e9b7bc8f9abb9be8560c8dfb08 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 13 Jul 2016 08:37:24 -0700 Subject: [PATCH 12/24] Add test cases for Korean --- src/xterm.js | 6 +- test/composition-helper-test.js | 170 ++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 test/composition-helper-test.js diff --git a/src/xterm.js b/src/xterm.js index 0bff3e6d..b34c7503 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -230,7 +230,6 @@ setTimeout(function () { // Ensure that the input has not already been sent if (self.isSendingComposition) { - console.log('send input ' + input + ' delayed'); self.isSendingComposition = false; var input = self.textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end); self.terminal.handler(input); @@ -260,8 +259,8 @@ */ CompositionHelper.prototype.updateCompositionViewPosition = function() { var cursor = this.terminal.element.querySelector('.terminal-cursor'); - term.compositionView.style.left = cursor.offsetLeft + 'px'; - term.compositionView.style.top = cursor.offsetTop + 'px'; + this.compositionView.style.left = cursor.offsetLeft + 'px'; + this.compositionView.style.top = cursor.offsetTop + 'px'; }; @@ -5226,6 +5225,7 @@ */ Terminal.EventEmitter = EventEmitter; + Terminal.CompositionHelper = CompositionHelper; Terminal.inherits = inherits; /** diff --git a/test/composition-helper-test.js b/test/composition-helper-test.js new file mode 100644 index 00000000..502d72cf --- /dev/null +++ b/test/composition-helper-test.js @@ -0,0 +1,170 @@ +var assert = require('chai').assert; +var Terminal = require('../src/xterm'); + +describe('CompositionHelper', function () { + var terminal; + var compositionHelper; + var compositionView; + var textarea; + var handledText; + + beforeEach(function () { + compositionView = { + classList: { + add: function () {}, + remove: function () {}, + }, + style: { + left: 0, + top: 0 + }, + textContent: '' + } + textarea = { + value: '' + } + terminal = { + element: { + querySelector: function () { + return { offsetLeft: 0, offsetTop: 0 }; + } + }, + handler: function (text) { + handledText += text; + console.log('handler - ' + text); + } + } + handledText = ''; + compositionHelper = new Terminal.CompositionHelper(textarea, compositionView, terminal); + }); + + describe('Public API', function () { + it('should define CompositionHelper.prototype.compositionstart', function () { + assert.isDefined(Terminal.CompositionHelper.prototype.compositionstart); + }); + it('should define CompositionHelper.prototype.compositionupdate', function () { + assert.isDefined(Terminal.CompositionHelper.prototype.compositionupdate); + }); + it('should define CompositionHelper.prototype.compositionend', function () { + assert.isDefined(Terminal.CompositionHelper.prototype.compositionend); + }); + it('should define CompositionHelper.prototype.finalizeComposition', function () { + assert.isDefined(Terminal.CompositionHelper.prototype.finalizeComposition); + }); + it('should define CompositionHelper.prototype.handleAnyTextareaChanges', function () { + assert.isDefined(Terminal.CompositionHelper.prototype.handleAnyTextareaChanges); + }); + it('should define CompositionHelper.prototype.updateCompositionViewPosition', function () { + assert.isDefined(Terminal.CompositionHelper.prototype.updateCompositionViewPosition); + }); + it('should define CompositionHelper.isComposing', function () { + assert.isDefined(compositionHelper.isComposing); + }); + it('should define CompositionHelper.isSendingComposition', function () { + assert.isDefined(compositionHelper.isSendingComposition); + }); + }); + + describe('Input', function () { + it('Should insert simple characters', function (done) { + // First character + compositionHelper.compositionstart(); + compositionHelper.compositionupdate({ data: 'ㅇ' }); + textarea.value = 'ㅇ'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionend(); + setTimeout(function() { // wait for any textarea updates + assert.equal(handledText, 'ㅇ'); + // Second character + compositionHelper.compositionstart(); + compositionHelper.compositionupdate({ data: 'ㅇ' }); + textarea.value = 'ㅇㅇ'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionend(); + setTimeout(function() { // wait for any textarea updates + assert.equal(handledText, 'ㅇㅇ'); + done(); + }, 0); + }, 0); + }, 0); + }, 0); + }); + + it('Should insert complex characters', function (done) { + // First character + compositionHelper.compositionstart(); + compositionHelper.compositionupdate({ data: 'ㅇ' }); + textarea.value = 'ㅇ'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionupdate({ data: '아' }); + textarea.value = '아'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionupdate({ data: '앙' }); + textarea.value = '앙'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionend(); + setTimeout(function() { // wait for any textarea updates + assert.equal(handledText, '앙'); + // Second character + compositionHelper.compositionstart(); + compositionHelper.compositionupdate({ data: 'ㅇ' }); + textarea.value = '앙ㅇ'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionupdate({ data: '아' }); + textarea.value = '앙아'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionupdate({ data: '앙' }); + textarea.value = '앙앙'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionend(); + setTimeout(function() { // wait for any textarea updates + assert.equal(handledText, '앙앙'); + done(); + }, 0); + }, 0); + }, 0); + }, 0); + }, 0); + }, 0); + }, 0); + }, 0); + }); + + it('Should insert complex characters that change with following character', function (done) { + // First character + compositionHelper.compositionstart(); + compositionHelper.compositionupdate({ data: 'ㅇ' }); + textarea.value = 'ㅇ'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionupdate({ data: '아' }); + textarea.value = '아'; + setTimeout(function() { // wait for any textarea updates + // Start second character in first character + compositionHelper.compositionupdate({ data: '앙' }); + textarea.value = '앙'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionend(); + compositionHelper.compositionstart(); + compositionHelper.compositionupdate({ data: '아' }); + textarea.value = '아아' + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionend(); + setTimeout(function() { // wait for any textarea updates + assert.equal(handledText, '아아'); + done(); + }, 0); + }, 0); + }, 0); + }, 0); + }, 0); + }); + + it('Should insert multi-line charcters', function () { + // TODO: Implement a hiragana example + }); + + it('Should insert multi-line charcters that are converted to other characters', function () { + // TODO: Implement a hiragana -> kanji example + }); + }); +}); From 8074d754d3cc645c531f2a8baf28b605b8e89e08 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 13 Jul 2016 20:12:49 -0700 Subject: [PATCH 13/24] Only position composition view if a cursor is visible --- src/xterm.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/xterm.js b/src/xterm.js index b34c7503..8166c545 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -259,8 +259,10 @@ */ CompositionHelper.prototype.updateCompositionViewPosition = function() { var cursor = this.terminal.element.querySelector('.terminal-cursor'); - this.compositionView.style.left = cursor.offsetLeft + 'px'; - this.compositionView.style.top = cursor.offsetTop + 'px'; + if (cursor) { + this.compositionView.style.left = cursor.offsetLeft + 'px'; + this.compositionView.style.top = cursor.offsetTop + 'px'; + } }; From 38d08873bc920f89f2fcdc062c39f63ef0669411 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 13 Jul 2016 20:34:43 -0700 Subject: [PATCH 14/24] Don't finalize composition on modifier key presses --- src/xterm.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/xterm.js b/src/xterm.js index 8166c545..30bf987f 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -2627,7 +2627,10 @@ Terminal.prototype.keyDown = function(ev) { if (this.compositionHelper.isComposing || this.compositionHelper.isSendingComposition) { if (ev.keyCode === 229) { - // Continue composing + // Continue composing if the keyCode is the "composition character" + return; + } else if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) { + // Continue composing if the keyCode is a modifier key return; } else { // Finish composition immediately From 4efb8d24acd8754c5f65847ab4cb879a7d45dd09 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 13 Jul 2016 20:34:55 -0700 Subject: [PATCH 15/24] Don't wrap multi-character compositions --- src/xterm.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/xterm.css b/src/xterm.css index 403389ac..7f00a8b7 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -95,6 +95,7 @@ color: #FFF; display: none; position: absolute; + white-space: nowrap; } .terminal .composition-view.active { From 0a7c1bfd98d7e1849775fc30d3842884d0c782ad Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 13 Jul 2016 20:46:43 -0700 Subject: [PATCH 16/24] Position the textarea below the cursor during a composition This will position any IME helper in the correct position --- src/xterm.css | 3 ++- src/xterm.js | 21 +++++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/xterm.css b/src/xterm.css index 7f00a8b7..07b82dd5 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -53,11 +53,12 @@ } .terminal .xterm-helper-textarea { + position: absolute; /* * HACK: to fix IE's blinking cursor * Move textarea out of the screen to the far left, so that the cursor is not visible. */ - text-indent: -9999em; + left: -9999em; opacity: 0; width: 0; height: 0; diff --git a/src/xterm.js b/src/xterm.js index 30bf987f..27ce0450 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -177,7 +177,7 @@ */ CompositionHelper.prototype.compositionupdate = function(ev) { this.compositionView.textContent = ev.data; - this.updateCompositionViewPosition(); + this.updateCompositionElements(); var self = this; setTimeout(function() { self.compositionPosition.end = self.textarea.value.length; @@ -203,6 +203,7 @@ 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. @@ -255,16 +256,28 @@ } /** - * Updates the composition view's position. + * 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.updateCompositionViewPosition = function() { + CompositionHelper.prototype.updateCompositionElements = function() { var cursor = this.terminal.element.querySelector('.terminal-cursor'); if (cursor) { this.compositionView.style.left = cursor.offsetLeft + 'px'; this.compositionView.style.top = cursor.offsetTop + 'px'; } + this.textarea.style.left = cursor.offsetLeft + 'px'; + this.textarea.style.top = (cursor.offsetTop + cursor.offsetHeight) + 'px'; }; + /** + * Clears the textarea's position so that the cursor does not blink on IE. + * @private + */ + CompositionHelper.prototype.clearTextareaPosition = function() { + this.textarea.style.left = undefined; + this.textarea.style.top = undefined; + } + /** * States @@ -1485,7 +1498,7 @@ } // TODO: Attach to refresh event instead? - term.compositionHelper.updateCompositionViewPosition(); + term.compositionHelper.updateCompositionElements(); this.emit('refresh', {element: this.element, start: start, end: end}); }; From ac8db6e8ec0bcf4115c05e48d401e8b2c9ed77a2 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 13 Jul 2016 20:52:55 -0700 Subject: [PATCH 17/24] jsdoc handleAnyTextareaChanges --- src/xterm.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/xterm.js b/src/xterm.js index 27ce0450..d3b500ae 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -239,12 +239,17 @@ } } + /** + * 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() { - // Ensure no composition has started since the timeout + // Ignore if a composition has started since the timeout if (!self.isComposing) { var newValue = self.textarea.value; var diff = newValue.replace(oldValue, ''); From 89d29bbc10ef624a493d96ae06d6318867c6aa13 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 13 Jul 2016 21:02:43 -0700 Subject: [PATCH 18/24] Fix non-composition char input after composition char + test --- src/xterm.js | 11 ++++++++++- test/composition-helper-test.js | 26 +++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/xterm.js b/src/xterm.js index d3b500ae..416422c5 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -232,7 +232,16 @@ // Ensure that the input has not already been sent if (self.isSendingComposition) { self.isSendingComposition = false; - var input = self.textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end); + 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); diff --git a/test/composition-helper-test.js b/test/composition-helper-test.js index 502d72cf..558bcdff 100644 --- a/test/composition-helper-test.js +++ b/test/composition-helper-test.js @@ -21,7 +21,11 @@ describe('CompositionHelper', function () { textContent: '' } textarea = { - value: '' + value: '', + style: { + left: 0, + top: 0 + } } terminal = { element: { @@ -54,8 +58,8 @@ describe('CompositionHelper', function () { it('should define CompositionHelper.prototype.handleAnyTextareaChanges', function () { assert.isDefined(Terminal.CompositionHelper.prototype.handleAnyTextareaChanges); }); - it('should define CompositionHelper.prototype.updateCompositionViewPosition', function () { - assert.isDefined(Terminal.CompositionHelper.prototype.updateCompositionViewPosition); + it('should define CompositionHelper.prototype.updateCompositionElements', function () { + assert.isDefined(Terminal.CompositionHelper.prototype.updateCompositionElements); }); it('should define CompositionHelper.isComposing', function () { assert.isDefined(compositionHelper.isComposing); @@ -166,5 +170,21 @@ describe('CompositionHelper', function () { it('Should insert multi-line charcters that are converted to other characters', function () { // TODO: Implement a hiragana -> kanji example }); + + it('Should insert non-composition charcters input immediately after composition characters', function () { + // First character + compositionHelper.compositionstart(); + compositionHelper.compositionupdate({ data: 'ㅇ' }); + textarea.value = 'ㅇ'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionend(); + // Second character is non-composition character (1) + textarea.value = 'ㅇ1'; + setTimeout(function() { // wait for any textarea updates + assert.equal(handledText, 'ㅇ1'); + done(); + }, 0); + }, 0); + }); }); }); From 14a62c0f8b064dd09f6c54202670c3e212f01248 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 13 Jul 2016 21:22:27 -0700 Subject: [PATCH 19/24] Add test cases for Japanese --- test/composition-helper-test.js | 98 ++++++++++++++++++++++++++++----- 1 file changed, 84 insertions(+), 14 deletions(-) diff --git a/test/composition-helper-test.js b/test/composition-helper-test.js index 558bcdff..7bc4ee48 100644 --- a/test/composition-helper-test.js +++ b/test/composition-helper-test.js @@ -35,7 +35,6 @@ describe('CompositionHelper', function () { }, handler: function (text) { handledText += text; - console.log('handler - ' + text); } } handledText = ''; @@ -71,7 +70,7 @@ describe('CompositionHelper', function () { describe('Input', function () { it('Should insert simple characters', function (done) { - // First character + // First character 'ㅇ' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; @@ -79,7 +78,7 @@ describe('CompositionHelper', function () { compositionHelper.compositionend(); setTimeout(function() { // wait for any textarea updates assert.equal(handledText, 'ㅇ'); - // Second character + // Second character 'ㅇ' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇㅇ'; @@ -95,7 +94,7 @@ describe('CompositionHelper', function () { }); it('Should insert complex characters', function (done) { - // First character + // First character '앙' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; @@ -109,7 +108,7 @@ describe('CompositionHelper', function () { compositionHelper.compositionend(); setTimeout(function() { // wait for any textarea updates assert.equal(handledText, '앙'); - // Second character + // Second character '앙' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = '앙ㅇ'; @@ -135,7 +134,7 @@ describe('CompositionHelper', function () { }); it('Should insert complex characters that change with following character', function (done) { - // First character + // First character '아' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; @@ -143,7 +142,7 @@ describe('CompositionHelper', function () { compositionHelper.compositionupdate({ data: '아' }); textarea.value = '아'; setTimeout(function() { // wait for any textarea updates - // Start second character in first character + // Start second character '아' in first character compositionHelper.compositionupdate({ data: '앙' }); textarea.value = '앙'; setTimeout(function() { // wait for any textarea updates @@ -163,22 +162,93 @@ describe('CompositionHelper', function () { }, 0); }); - it('Should insert multi-line charcters', function () { - // TODO: Implement a hiragana example + it('Should insert multi-characters compositions', function (done) { + // First character 'だ' + compositionHelper.compositionstart(); + compositionHelper.compositionupdate({ data: 'd' }); + textarea.value = 'd'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionupdate({ data: 'だ' }); + textarea.value = 'だ'; + setTimeout(function() { // wait for any textarea updates + // Second character 'あ' + compositionHelper.compositionupdate({ data: 'だあ' }); + textarea.value = 'だあ'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionend(); + setTimeout(function() { // wait for any textarea updates + assert.equal(handledText, 'だあ'); + done(); + }, 0); + }, 0); + }, 0); + }, 0); }); - it('Should insert multi-line charcters that are converted to other characters', function () { - // TODO: Implement a hiragana -> kanji example + it('Should insert multi-character compositions that are converted to other characters with the same length', function (done) { + // First character 'だ' + compositionHelper.compositionstart(); + compositionHelper.compositionupdate({ data: 'd' }); + textarea.value = 'd'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionupdate({ data: 'だ' }); + textarea.value = 'だ'; + setTimeout(function() { // wait for any textarea updates + // Second character 'ー' + compositionHelper.compositionupdate({ data: 'だー' }); + textarea.value = 'だー'; + setTimeout(function() { // wait for any textarea updates + // Convert to katakana 'ダー' + compositionHelper.compositionupdate({ data: 'ダー' }); + textarea.value = 'ダー'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionend(); + setTimeout(function() { // wait for any textarea updates + assert.equal(handledText, 'ダー'); + done(); + }, 0); + }, 0); + }, 0); + }, 0); + }, 0); + }) + + it('Should insert multi-character compositions that are converted to other characters with different lengths', function (done) { + // First character 'い' + compositionHelper.compositionstart(); + compositionHelper.compositionupdate({ data: 'い' }); + textarea.value = 'い'; + setTimeout(function() { // wait for any textarea updates + // Second character 'ま' + compositionHelper.compositionupdate({ data: 'いm' }); + textarea.value = 'いm'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionupdate({ data: 'いま' }); + textarea.value = 'いま'; + setTimeout(function() { // wait for any textarea updates + // Convert to kanji '今' + compositionHelper.compositionupdate({ data: '今' }); + textarea.value = '今'; + setTimeout(function() { // wait for any textarea updates + compositionHelper.compositionend(); + setTimeout(function() { // wait for any textarea updates + assert.equal(handledText, '今'); + done(); + }, 0); + }, 0); + }, 0); + }, 0); + }, 0); }); - it('Should insert non-composition charcters input immediately after composition characters', function () { - // First character + it('Should insert non-composition characters input immediately after composition characters', function (done) { + // First character 'ㅇ' compositionHelper.compositionstart(); compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; setTimeout(function() { // wait for any textarea updates compositionHelper.compositionend(); - // Second character is non-composition character (1) + // Second character '1' (a non-composition character) textarea.value = 'ㅇ1'; setTimeout(function() { // wait for any textarea updates assert.equal(handledText, 'ㅇ1'); From 534a9e7f5a8157f89b67a4a884de2bd2b73d9a74 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 13 Jul 2016 21:50:00 -0700 Subject: [PATCH 20/24] Big clean up Move keydown event to CompositionHelper Move call in refresh to be handled with an event --- src/xterm.js | 61 +++++++++++++++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/src/xterm.js b/src/xterm.js index 416422c5..d8cce195 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -192,6 +192,35 @@ 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. @@ -740,15 +769,10 @@ this.value = ''; }, true); - on(term.textarea, 'compositionstart', function(ev) { - term.compositionHelper.compositionstart.bind(term.compositionHelper, ev)(); - }); - on(term.textarea, 'compositionupdate', function(ev) { - term.compositionHelper.compositionupdate.bind(term.compositionHelper, ev)(); - }); - on(term.textarea, 'compositionend', function(ev) { - term.compositionHelper.compositionend.bind(term.compositionHelper, ev)(); - }); + on(term.textarea, 'compositionstart', term.compositionHelper.compositionstart.bind(term.compositionHelper)); + on(term.textarea, 'compositionupdate', term.compositionHelper.compositionupdate.bind(term.compositionHelper)); + on(term.textarea, 'compositionend', term.compositionHelper.compositionend.bind(term.compositionHelper)); + term.on('refresh', term.compositionHelper.updateCompositionElements.bind(term.compositionHelper)); }; /** @@ -1511,9 +1535,6 @@ this.element.appendChild(this.rowContainer); } - // TODO: Attach to refresh event instead? - term.compositionHelper.updateCompositionElements(); - this.emit('refresh', {element: this.element, start: start, end: end}); }; @@ -2652,21 +2673,7 @@ * @param {KeyboardEvent} ev The keydown event to be handled. */ Terminal.prototype.keyDown = function(ev) { - if (this.compositionHelper.isComposing || this.compositionHelper.isSendingComposition) { - if (ev.keyCode === 229) { - // Continue composing if the keyCode is the "composition character" - return; - } else if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) { - // Continue composing if the keyCode is a modifier key - return; - } else { - // Finish composition immediately - this.compositionHelper.finalizeComposition(null); - } - } - - if (ev.keyCode === 229) { - this.compositionHelper.handleAnyTextareaChanges(); + if (this.compositionHelper.keydown.bind(this.compositionHelper)(ev)) { return; } From fedc1fd325a372b8e56a9dc98a0cc1720953464b Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Fri, 15 Jul 2016 10:17:29 -0700 Subject: [PATCH 21/24] Fix keydown conditional --- src/xterm.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/xterm.js b/src/xterm.js index d6883196..b834702d 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -2673,7 +2673,7 @@ * @param {KeyboardEvent} ev The keydown event to be handled. */ Terminal.prototype.keyDown = function(ev) { - if (this.compositionHelper.keydown.bind(this.compositionHelper)(ev)) { + if (!this.compositionHelper.keydown.bind(this.compositionHelper)(ev)) { return; } From a3a7017f6b0f4478d84a9656fd68c92240606f99 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Fri, 15 Jul 2016 10:22:31 -0700 Subject: [PATCH 22/24] Fix tests --- test/test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/test.js b/test/test.js index e98ce25e..e398eb11 100644 --- a/test/test.js +++ b/test/test.js @@ -88,7 +88,12 @@ describe('xterm.js', function() { xterm.showCursor = function() {}; xterm.clearSelection = function() {}; xterm.compositionHelper = { - isComposing: false + isComposing: false, + keydown: { + bind: function() { + return function() { return true; }; + } + } }; }); From 0010a5a109cdc36975109aa5399f2d252e0e8244 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Fri, 15 Jul 2016 16:33:30 -0700 Subject: [PATCH 23/24] Explicitly return from keydown --- src/xterm.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/xterm.js b/src/xterm.js index b834702d..71541c3f 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -2674,7 +2674,7 @@ */ Terminal.prototype.keyDown = function(ev) { if (!this.compositionHelper.keydown.bind(this.compositionHelper)(ev)) { - return; + return false; } var self = this; From 4595a18197a9f7378077b078c06719ecb16adb38 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Thu, 21 Jul 2016 10:51:43 -0700 Subject: [PATCH 24/24] Tidy up blur/focus handlers --- src/xterm.js | 52 ++++++++++++++++++++++------------------------------ 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/src/xterm.js b/src/xterm.js index 71541c3f..01afe3f0 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -307,9 +307,9 @@ if (cursor) { this.compositionView.style.left = cursor.offsetLeft + 'px'; this.compositionView.style.top = cursor.offsetTop + 'px'; + this.textarea.style.left = cursor.offsetLeft + 'px'; + this.textarea.style.top = (cursor.offsetTop + cursor.offsetHeight) + 'px'; } - this.textarea.style.left = cursor.offsetLeft + 'px'; - this.textarea.style.top = (cursor.offsetTop + cursor.offsetHeight) + 'px'; }; /** @@ -625,18 +625,7 @@ * Focus the terminal. Delegates focus handling to the terminal's DOM element. */ Terminal.prototype.focus = function() { - if (document.activeElement === this.textarea) { - return; - } - - if (this.sendFocus) { - this.send('\x1b[I'); - } - - this.element.classList.add('focus'); - this.showCursor(); - this.textarea.focus(); - Terminal.focus = this; + return this.element.focus(); }; /** @@ -646,11 +635,17 @@ */ Terminal.bindFocus = function (term) { on(term.element, 'focus', function (ev) { + if (Terminal.focus === term) { + return; + } + if (term.sendFocus) { term.send('\x1b[I'); } + term.element.classList.add('focus'); term.showCursor(); + term.textarea.focus(); Terminal.focus = term; term.emit('focus', {terminal: term}); }); @@ -660,18 +655,7 @@ * Blur the terminal. Delegates blur handling to the terminal's DOM element. */ Terminal.prototype.blur = function() { - if (Terminal.focus !== this) { - return; - } - - this.element.classList.remove('focus'); - this.cursorState = 0; - this.refresh(this.y, this.y); - this.textarea.blur(); - if (this.sendFocus) { - this.send('\x1b[0]'); - } - Terminal.focus = null; + return this.element.blur(); }; /** @@ -681,6 +665,12 @@ */ Terminal.bindBlur = function (term) { on(term.element, 'blur', function (ev) { + if (Terminal.focus !== term) { + return; + } + term.element.classList.remove('focus'); + term.refresh(term.y, term.y); + term.textarea.blur(); if (term.sendFocus) { term.send('\x1b[O'); } @@ -696,6 +686,8 @@ Terminal.bindPaste(this); Terminal.bindKeys(this); Terminal.bindCopy(this); + Terminal.bindFocus(this); + Terminal.bindBlur(this); }; /** @@ -881,12 +873,12 @@ this.textarea.setAttribute('autocapitalize', 'off'); this.textarea.setAttribute('spellcheck', 'false'); this.textarea.tabIndex = 0; - this.textarea.onfocus = function() { + this.textarea.addEventListener('focus', function() { self.emit('focus', {terminal: self}); - } - this.textarea.onblur = function() { + }); + this.textarea.addEventListener('blur', function() { self.emit('blur', {terminal: self}); - } + }); this.helperContainer.appendChild(this.textarea); this.compositionView = document.createElement('div');