diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..359353554 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,11 @@ + + +## Details +- Browser and browser version: +- OS version: +- xterm.js version: + +### Steps to reproduce + +1. +2. diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000..a8de62a9a --- /dev/null +++ b/.mailmap @@ -0,0 +1,7 @@ +Antonis Kalipetis +Antonis Kalipetis +Daniel Imms +Paris Kasidiaris +Paris Kasidiaris +Thanasis Daglis +Thanasis Daglis diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 000000000..9d8ffb79c --- /dev/null +++ b/AUTHORS @@ -0,0 +1,19 @@ +List of xterm.js contributors. Updated before every release. + +Alexander Olsson +Antonis Kalipetis +Austin Robertson +Benjamin Fischer +Carson Anderson +Christopher Jeffrey +Daniel Imms +Daniel Risacher +Dan Kaplun +Darin Morrison +Jean Bruenn +Jörg Breitbart +Paris Kasidiaris +Paris Kasidiaris +runarberg +Steven Silvester +Thanasis Daglis diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..8b33d183c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# How to contribute to xterm.js + +- [Opening issues for bug reports or feature requests](#opening-issues) +- [Contributing code](#contributing-code) + +## Opening issues + +The preferred way to report bugs or request features is to use +[GitHub issues](http://github.com/sourcelair/xterm.js/issues). Before +opening an issue, read these pointers. + +### Opening issues effectively + +- Include information about **the browser in which the problem occurred**. Even + if you tested several browsers, and the problem occurred in all of them, + mention this fact in the bug report. Also include browser version numbers and + the operating system that you're on. + +- Mention which release of xterm.js you're using. Preferably, try also with + the current HEAD of the master branch, to ensure the problem has not already been + fixed. + +- Mention precisely what went wrong. What did you expect to happen? What happened instead? Describe the + exact steps a maintainer has to take to make the problem occur. + +- If the problem can not be reproduced in the [demo of xterm.js](README.md#demo), please provide an HTML document that demonstrates the problem. + +- Be polite. Issues with an indignant or belligerent tone tend to be moved to the + bottom of the pile. + +## Contributing code + +- Make sure you have a [GitHub account](https://github.com/join) +- Fork [xterm.js](https://github.com/sourcelair/xterm.js/) + ([how to fork a repo](https://help.github.com/articles/fork-a-repo)) +- Make your changes +- If your changes are easy to test or likely to regress, add tests. Tests go into `test`, directory. +- Follow the general code style of the rest of the project (see below). +- Submit a pull request +([how to create a pull request](https://help.github.com/articles/fork-a-repo)). + Don't put more than one feature/fix in a single pull request. + +By contributing code to xterm.js you + + - agree to license the contributed code under xterm.js' [MIT + license](LICENSE). + + - confirm that you have the right to contribute and license the code + in question. (Either you hold all rights on the code, or the rights + holder has explicitly granted the right to use it like this, + through a compatible open source license or through a direct + agreement with you.) diff --git a/README.md b/README.md index 9720507d4..7c5ff60af 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,19 @@ Xterm.js supplies a modular, event-based interface that lets developers build ad ![xterm.js screenshot](xtermjs.png) +## Browser Support + +Since xterm.js is typically implemented as a developer tool, only modern browsers are supported officially. Here is a list of the versions we aim to support: + +- Chrome 48+ +- Edge 13+ +- Firefox 44+ +- Internet Explorer 11+ +- Opera 35+ +- Safari 8+ + +xterm.js may work on earlier versions of the browsers but these are the browsers we strive to keep working. + ## Demo To launch the demo simply run: @@ -29,21 +42,35 @@ To use an addon, just include the JavaScript file after xterm.js and before the ```html - + ``` ```js var xterm = new Terminal(); // init code... -xterm.linkify(); +xterm.fit(); ``` -## Contribution and License Agreement +## Development and Contribution + +To contribute either code, documentation or issues to xterm.js please read the [Contributing document](CONTRIBUTING.md) before. + +The development of xterm.js does not require any special tool. All you need is an editor that supports JavaScript and a browser (if you would like to run the demo you will need Node.js to get all features). + +It is recommended though to use a development tool that uses xterm.js internally, to develop for xterm.js. [Eating our own dogfood](https://en.wikipedia.org/wiki/Eating_your_own_dog_food) has been proved extremely beneficial for this project. Known tools that use xterm.js internally are: + +#### [SourceLair](https://www.sourcelair.com) + +Visit https://lair.io/sourcelair/xterm and follow the instructions. All development will happen in your browser. + +#### [Visual Studio Code](http://code.visualstudio.com/) + +[Download Visual Studio Code](http://code.visualstudio.com/Download), clone xterm.js and you are all set. + +## License Agreement If you contribute code to this project, you are implicitly allowing your code to be distributed under the MIT license. You are also implicitly verifying that all code is your original work. -## License - Copyright (c) 2014-2016, SourceLair, Private Company ([www.sourcelair.com](https://www.sourcelair.com/home)) (MIT License) Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) diff --git a/bin/generate-authors b/bin/generate-authors new file mode 100755 index 000000000..21446ad49 --- /dev/null +++ b/bin/generate-authors @@ -0,0 +1,7 @@ +#! /usr/bin/env sh + +tail --lines=+3 AUTHORS > AUTHORS.tmp +git log --format='%aN <%aE>' >> AUTHORS.tmp +echo "List of xterm.js contributors. Updated before every release.\n" > AUTHORS +sort -u AUTHORS.tmp >> AUTHORS +rm -f AUTHORS.tmp diff --git a/bin/prepare-release b/bin/prepare-release new file mode 100755 index 000000000..49feacaf5 --- /dev/null +++ b/bin/prepare-release @@ -0,0 +1,27 @@ +#! /usr/bin/env sh + +# Usage: ./bin/prepare-release x.y.z +# x.y.z should be semver (e.g. 1.0.0) + +NEW_VERSION=$1 +CURRENT_PACKAGE_JSON_VERSION=$(cat package.json \ + | grep version \ + | head -1 \ + | awk -F: '{ print $2 }' \ + | sed 's/[",]//g' \ + | tr -d '[[:space:]]') +CURRENT_BOWER_JSON_VERSION=$(cat bower.json \ + | grep version \ + | head -1 \ + | awk -F: '{ print $2 }' \ + | sed 's/[",]//g' \ + | tr -d '[[:space:]]') + +# Update version in package.json and bower.json +sed -i "s/\"version\": \"$CURRENT_PACKAGE_JSON_VERSION\"/\"version\": \"$NEW_VERSION\"/g" package.json +sed -i "s/\"version\": \"$CURRENT_BOWER_JSON_VERSION\"/\"version\": \"$NEW_VERSION\"/g" bower.json + +# Update AUTHORS file +sh bin/generate-authors + +git commit -a -m "Bump version to $NEW_VERSION" diff --git a/bower.json b/bower.json index 33fbf7d37..409fe2137 100644 --- a/bower.json +++ b/bower.json @@ -1,5 +1,5 @@ { "name": "xterm.js", - "version": "0.33", + "version": "1.0.0", "ignore": ["demo", "test", ".gitignore"] } diff --git a/package.json b/package.json index 488820066..0141ca2b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xterm", - "version": "0.33.0", + "version": "1.0.0", "ignore": [ "demo", "test", diff --git a/src/xterm.css b/src/xterm.css index b34f41a22..07b82dd5b 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -43,22 +43,40 @@ position: relative; } -.terminal:focus { +.terminal.focus { outline: none; } +.terminal .xterm-helpers { + position: absolute; + top: 0; +} + +.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. + */ + left: -9999em; + opacity: 0; + width: 0; + height: 0; + z-index: -10; +} + .terminal .terminal-cursor { background-color: #fff; 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; } @@ -73,6 +91,18 @@ } } +.terminal .composition-view { + background: #000; + color: #FFF; + display: none; + position: absolute; + white-space: nowrap; +} + +.terminal .composition-view.active { + display: block; +} + /* * Determine default colors for xterm.js */ diff --git a/src/xterm.js b/src/xterm.js index 16502e20a..9afc08fd6 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); @@ -133,6 +133,195 @@ }; + /** + * 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() { + 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 */ @@ -437,7 +626,7 @@ * Focus the terminal. Delegates focus handling to the terminal's DOM element. */ Terminal.prototype.focus = function() { - return this.element.focus(); + return this.textarea.focus(); }; /** @@ -446,11 +635,11 @@ * @static */ Terminal.bindFocus = function (term) { - on(term.element, 'focus', function (ev) { + on(term.textarea, 'focus', function (ev) { if (term.sendFocus) { term.send('\x1b[I'); } - + term.element.classList.add('focus'); term.showCursor(); Terminal.focus = term; term.emit('focus', {terminal: term}); @@ -461,7 +650,7 @@ * Blur the terminal. Delegates blur handling to the terminal's DOM element. */ Terminal.prototype.blur = function() { - return this.element.blur(); + return this.textarea.blur(); }; /** @@ -470,10 +659,12 @@ * @static */ Terminal.bindBlur = function (term) { - on(term.element, 'blur', function (ev) { + on(term.textarea, 'blur', function (ev) { + term.refresh(term.y, term.y); if (term.sendFocus) { term.send('\x1b[O'); } + term.element.classList.remove('focus'); Terminal.focus = null; term.emit('blur', {terminal: term}); }); @@ -483,122 +674,27 @@ * 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); - }; - /** * Bind to paste event and allow both keyboard and right-click pasting, without having the * 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 - * - * @param {Xterm} term The terminal on which to bind key handling - * @static - */ - Terminal.bindKeys = function(term) { - on(term.element, 'keydown', function(ev) { - term.keyDown(ev); - }, true); - - on(term.element, 'keypress', function(ev) { - term.keyPress(ev); - }, true); }; /** @@ -626,52 +722,49 @@ 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); + + 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)); + }; + /** * 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. - * @param {Xterm} term The terminal on which to bind the cut event handling functionality. - * @static - */ - Terminal.bindCut = function(term) { - on(term.element, 'cut', function (ev) { - ev.preventDefault(); - }); - }; - - - /** - * Do not perform the "drop" event. Altering the contents of the - * terminal with drag n drop is unwanted behavior. - * @param {Xterm} term The terminal on which to bind the drop event handling functionality. - * @static - */ - Terminal.bindDrop = function (term) { - on(term.element, 'drop', function (ev) { - term.cancel(ev, true); - }); - }; - - /** - * Cancel click handling on the given terminal - * @param {Xterm} term The terminal on which to bind the click event handling functionality. - * @static - */ - Terminal.click = function (term) { - on(term.element, 'click', function (ev) { - term.cancel(ev, true); + return; // temporary }); }; @@ -747,7 +840,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 @@ -758,11 +850,39 @@ 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'); + // 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'); + this.textarea.setAttribute('autocorrect', 'off'); + this.textarea.setAttribute('autocapitalize', 'off'); + this.textarea.setAttribute('spellcheck', 'false'); + this.textarea.tabIndex = 0; + this.textarea.addEventListener('focus', function() { + self.emit('focus', {terminal: self}); + }); + this.textarea.addEventListener('blur', function() { + 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++) { this.insertRow(); } this.parent.appendChild(this.element); + // Draw the screen. this.refresh(0, this.rows - 1); @@ -2551,6 +2671,11 @@ if (this.customKeydownHandler && this.customKeydownHandler(ev) === false) { return false; } + + if (!this.compositionHelper.keydown.bind(this.compositionHelper)(ev)) { + return false; + } + var self = this; var result = this.evaluateKeyEscapeSequence(ev); @@ -2597,6 +2722,7 @@ // The number of characters to scroll, if this is defined it will cancel the event scrollDisp: undefined }; + var modifiers = ev.shiftKey << 0 | ev.altKey << 1 | ev.ctrlKey << 2 | ev.metaKey << 3; switch (ev.keyCode) { // backspace case 8: @@ -2627,61 +2753,39 @@ break; // left-arrow case 37: - if (ev.altKey) { - result.key = '\x1bb' // Jump a word back - result.cancel = true; - break; - } - if (ev.ctrlKey) { - result.key = '\x1b[5D'; // Jump a word back - break; - } - if (this.applicationCursor) { - result.key = '\x1bOD'; // SS3 as ^[O for 7-bit - break; - } - result.key = '\x1b[D'; + if (modifiers) + result.key = '\x1b[1;' + (modifiers + 1) + 'D'; + else if (this.applicationCursor) + result.key = '\x1bOD'; + else + result.key = '\x1b[D'; break; // right-arrow case 39: - if (ev.altKey) { - result.key = '\x1bf' // Jump a word forward - result.cancel = true; - break; - } - if (ev.ctrlKey) { - result.key = '\x1b[5C'; // Jump a word forward - break; - } - if (this.applicationCursor) { + if (modifiers) + result.key = '\x1b[1;' + (modifiers + 1) + 'C'; + else if (this.applicationCursor) result.key = '\x1bOC'; - break; - } - result.key = '\x1b[C'; + else + result.key = '\x1b[C'; break; // up-arrow case 38: - if (this.applicationCursor) { + if (modifiers) + result.key = '\x1b[1;' + (modifiers + 1) + 'A'; + else if (this.applicationCursor) result.key = '\x1bOA'; - break; - } - if (ev.ctrlKey) { - result.scrollDisp = -1; - } else { + else result.key = '\x1b[A'; - } break; // down-arrow case 40: - if (this.applicationCursor) { + if (modifiers) + result.key = '\x1b[1;' + (modifiers + 1) + 'B'; + else if (this.applicationCursor) result.key = '\x1bOB'; - break; - } - if (ev.ctrlKey) { - result.scrollDisp = 1; - } else { + else result.key = '\x1b[B'; - } break; // insert case 45: @@ -2695,19 +2799,21 @@ case 46: result.key = '\x1b[3~'; break; // home case 36: - if (this.applicationKeypad) { + if (modifiers) + result.key = '\x1b[1;' + (modifiers + 1) + 'H'; + else if (this.applicationCursor) result.key = '\x1bOH'; - break; - } - result.key = '\x1bOH'; + else + result.key = '\x1b[H'; break; // end case 35: - if (this.applicationKeypad) { + if (modifiers) + result.key = '\x1b[1;' + (modifiers + 1) + 'F'; + else if (this.applicationCursor) result.key = '\x1bOF'; - break; - } - result.key = '\x1bOF'; + else + result.key = '\x1b[F'; break; // page up case 33: @@ -2804,6 +2910,8 @@ Terminal.prototype.keyPress = function(ev) { var key; + this.cancel(ev); + if (ev.charCode) { key = ev.charCode; } else if (ev.which == null) { @@ -2822,21 +2930,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; }; @@ -5150,6 +5248,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 000000000..7bc4ee486 --- /dev/null +++ b/test/composition-helper-test.js @@ -0,0 +1,260 @@ +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: '', + style: { + left: 0, + top: 0 + } + } + terminal = { + element: { + querySelector: function () { + return { offsetLeft: 0, offsetTop: 0 }; + } + }, + handler: function (text) { + handledText += 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.updateCompositionElements', function () { + assert.isDefined(Terminal.CompositionHelper.prototype.updateCompositionElements); + }); + 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-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-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 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 '1' (a non-composition character) + textarea.value = 'ㅇ1'; + setTimeout(function() { // wait for any textarea updates + assert.equal(handledText, 'ㅇ1'); + done(); + }, 0); + }, 0); + }); + }); +}); diff --git a/test/test.js b/test/test.js index 7c0caa7c7..b5ba13051 100644 --- a/test/test.js +++ b/test/test.js @@ -24,8 +24,8 @@ describe('xterm.js', function() { assert.equal(xterm.evaluateKeyEscapeSequence({ keyCode: 33 }).key, '\x1b[5~'); // CSI 5 ~ assert.equal(xterm.evaluateKeyEscapeSequence({ keyCode: 34 }).key, '\x1b[6~'); // CSI 6 ~ // End, Home - assert.equal(xterm.evaluateKeyEscapeSequence({ keyCode: 35 }).key, '\x1bOF'); // SS3 F - assert.equal(xterm.evaluateKeyEscapeSequence({ keyCode: 36 }).key, '\x1bOH'); // SS3 H + assert.equal(xterm.evaluateKeyEscapeSequence({ keyCode: 35 }).key, '\x1b[F'); // SS3 F + assert.equal(xterm.evaluateKeyEscapeSequence({ keyCode: 36 }).key, '\x1b[H'); // SS3 H // Left, up, right, down arrows assert.equal(xterm.evaluateKeyEscapeSequence({ keyCode: 37 }).key, '\x1b[D'); // CSI D assert.equal(xterm.evaluateKeyEscapeSequence({ keyCode: 38 }).key, '\x1b[A'); // CSI A @@ -50,10 +50,10 @@ describe('xterm.js', function() { assert.equal(xterm.evaluateKeyEscapeSequence({ keyCode: 123 }).key, '\x1b[24~'); // CSI 2 4 ~ }); it('should return \\x1b[5D for ctrl+left', function() { - assert.equal(xterm.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 37 }).key, '\x1b[5D'); // CSI 5 D + assert.equal(xterm.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 37 }).key, '\x1b[1;5D'); // CSI 5 D }); it('should return \\x1b[5C for ctrl+right', function() { - assert.equal(xterm.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 39 }).key, '\x1b[5C'); // CSI 5 C + assert.equal(xterm.evaluateKeyEscapeSequence({ ctrlKey: true, keyCode: 39 }).key, '\x1b[1;5C'); // CSI 5 C }); }); @@ -113,6 +113,14 @@ describe('xterm.js', function() { xterm.handler = function() {}; xterm.showCursor = function() {}; xterm.clearSelection = function() {}; + xterm.compositionHelper = { + isComposing: false, + keydown: { + bind: function() { + return function() { return true; }; + } + } + }; }); describe('On Mac OS', function() {