Source: widgets/keyboard.js

/**
 * @fileOverview Requirejs module containing the on-screen keyboard widget.
 * @preserve Copyright (c) 2013-present British Broadcasting Corporation. All rights reserved.
 * @license See https://github.com/fmtvp/tal/blob/master/LICENSE for full licence
 */

define(
    'antie/widgets/keyboard',
    [
        'antie/widgets/grid',
        'antie/widgets/button',
        'antie/widgets/label',
        'antie/events/keyevent',
        'antie/events/textchangeevent'
    ],
    function(Grid, Button, Label, KeyEvent, TextChangeEvent) {
        'use strict';

        /**
         * On-screen keyboard widget.
         * @name antie.widgets.Keyboard
         * @class
         * @extends antie.widgets.Grid
         * @param {String} [id] The unique ID of the widget. If excluded, a temporary internal ID will be used (but not included in any output).
         * @param {Integer} cols The number of columns of keys on the keyboard.
         * @param {Integer} rows The number of rows of keys on the keyboard.
         * @param {String} keys A string of characters which make up the keys, starting top-left and working row-by-row to the bottom-right.
         * @param {boolean} horizontalWrapping Enable or disable horizontal wrapping.
         * @param {boolean} verticalWrapping Enable or disable vertical wrapping.
         *          Special characters include:
         *              "-" DEL
         *              " " SPACE
         *              "_" <spacer>
         */
        var Keyboard = Grid.extend(/** @lends antie.widgets.Keyboard.prototype */ {
            /**
             * @constructor
             * @ignore
             */
            init: function init (id, cols, rows, keys, horizontalWrapping, verticalWrapping) {
                init.base.call(this, id, cols, rows, horizontalWrapping, verticalWrapping);

                this.addClass('keyboard');

                var self = this;
                var device = self.getCurrentApplication().getDevice();

                this._multitapConfig = device.getConfig().input.multitap;
                this._focussedCharacter = null;
                this._currentText = '';
                this._letterButtons = [];
                this._keys = keys;
                this._multiTap = false;
                this._multiTapTimeout = null;
                this._multiTapLastKey = null;
                this._multiTapLastKeyIndex = 0;
                this._capitalisation = Keyboard.CAPITALISATION_UPPER;
                this._maximumLength = null;

                // Select event handler for buttons within the keyboard
                this.addEventListener('select', function(evt) {
                    self._onSelectHandler(evt);
                });

                // Event listener to handle keyboard/numeric button press events.
                this.addEventListener('keydown', function(evt) {
                    self._onKeyDownHandler(evt);
                });

                this._populateTheGridWithKeyButtons(id, cols, rows);
            },

            // Private function to append a properly-capitalised character to the end of the string.
            _appendCharacter: function _appendCharacter (letter) {
                // allow no more characters to be appended if a maximum length has been reached
                if((this._maximumLength !== null) && (this._currentText.length >= this._maximumLength)) {
                    return false;
                }

                if( (this._capitalisation !== Keyboard.CAPITALISATION_LOWER) && (
                    (this._capitalisation === Keyboard.CAPITALISATION_UPPER) ||
                        this._currentText.length === 0 ||
                        this._currentText[this._currentText.length-1] === ' '
                )) {
                    letter = letter.toUpperCase();
                } else {
                    letter = letter.toLowerCase();
                }
                this._currentText += letter;

                this._correctTitleCase();

                return true;
            },

            _correctTitleCase: function _correctTitleCase () {
                if(this._capitalisation === Keyboard.CAPITALISATION_TITLE) {
                    this._currentText = this._currentText.replace(Keyboard.LAST_WORD_REGEXP, function(match) {
                        match = match.substring(0, 1).toUpperCase() + match.substring(1);
                        return match.replace(Keyboard.SHORT_WORD_REGEXP, function(match) {
                            return match.toLowerCase();
                        });
                    });
                    this._currentText = this._currentText.substring(0, 1).toUpperCase() + this._currentText.substring(1);
                }
            },

            _onSelectHandler: function _onSelectHandler (evt) {
                var letter = evt.target.getDataItem();
                var changed = false;
                switch(letter) {
                case 'DEL':
                    if(this._currentText.length > 0) {
                        this._currentText = this._currentText.substring(0, this._currentText.length - 1);
                        this._correctTitleCase();
                        changed = true;
                    }
                    break;
                case 'SPACE':
                    changed = this._appendCharacter(' ');
                    break;
                default:
                    changed = this._appendCharacter(letter);
                    break;
                }

                if(changed) {
                    this._updateClasses();
                    this.bubbleEvent(new TextChangeEvent(this, this._currentText, evt.target, false));
                }
            },

            _onKeyDownHandler: function _onKeyDownHandler (evt) {
                if(evt.keyChar) {
                    evt.stopPropagation();
                    // If the device supports multitap, multitap is enabled and a number is pressed...
                    if(this._multitapConfig && this._multiTap && /[0-9]/.test(evt.keyChar)) {
                        var atMaxLength = ((this._maximumLength !== null) && (this._currentText.length >= this._maximumLength));

                        if(this._multiTapTimeout) {
                            clearTimeout(this._multiTapTimeout);
                        }

                        var chars = this._multitapConfig[evt.keyChar];
                        if((evt.keyChar === this._multiTapLastKey) && this._multiTapTimeout) {
                            this._currentText = this._currentText.substring(0, this._currentText.length - 1);
                        } else if (atMaxLength){
                            //at last character and trying to start a new multitap key
                            return;
                        } else {
                            this._multiTapLastKeyIndex = -1;
                        }

                        // Find the next character for the pressed key that's available on this keyboard
                        do {
                            this._multiTapLastKeyIndex++;
                            if(this._multiTapLastKeyIndex >= chars.length) {
                                this._multiTapLastKeyIndex = 0;
                            }
                        } while(!this._letterButtons[chars[this._multiTapLastKeyIndex]]);

                        this._focussedCharacter = chars[this._multiTapLastKeyIndex];
                        this._letterButtons[chars[this._multiTapLastKeyIndex]].focus();
                        this._appendCharacter(chars[this._multiTapLastKeyIndex]);

                        this._multiTapLastKey = evt.keyChar;

                        this._updateClasses();

                        // Fire a text change event, but notify listeners that it may change due to being multitap
                        this.bubbleEvent(new TextChangeEvent(this, this._currentText, null, true));
                        var self = this;
                        this._multiTapTimeout = setTimeout(function() {
                            self._multiTapTimeout = null;
                            // Fire a new text change event to notify listeners that the multi-tap timeout has finished
                            self.bubbleEvent(new TextChangeEvent(this, self._currentText, null, false));
                        }, 1000);
                    } else {
                        // Select and focus the button on the keyboard for the pressed key
                        var button = this._letterButtons[evt.keyChar];
                        if(button) {
                            this._focussedCharacter = evt.keyChar;
                            button.focus();
                            button.select();
                        }
                    }
                } else if(evt.keyCode === KeyEvent.VK_BACK_SPACE) {
                    if(this._currentText.length > 0) {
                        this._currentText = this._currentText.substring(0, this._currentText.length - 1);
                        this._correctTitleCase();

                        this._updateClasses();

                        this.bubbleEvent(new TextChangeEvent(this, this._currentText, null, false));
                    }
                } else if(evt.keyCode === KeyEvent.VK_RIGHT) {
                    if(this._multiTapTimeout) {
                        this._multiTapTimeout = null;
                        // Fire a new text change event to notify listeners that the multi-tap timeout has finished
                        this.bubbleEvent(new TextChangeEvent(this, this._currentText, null, false));
                        evt.stopPropagation();
                    }
                }
            },

            _populateTheGridWithKeyButtons: function _populateTheGridWithKeyButtons (id, cols, rows) {
                for(var col = 0; col < cols; col++) {
                    for(var row = 0; row < rows; row++) {
                        var keyIndexId = (row*cols)+col;
                        var letter = this._keys[keyIndexId];

                        if(letter === ' ') {
                            letter = 'SPACE';
                        } else if(letter === '-') {
                            letter = 'DEL';
                        } else if(letter === '_') {
                            continue;
                        }

                        var button = new Button(this.id + '_' + letter + '_' + col + '_' + row);
                        button.setDataItem(letter);
                        button.addClass('key'+letter);
                        button.addClass('keyboardButton');
                        button.appendChildWidget(new Label(letter));

                        this._letterButtons[this._keys[keyIndexId]] = button;

                        this.setWidgetAt(col, row, button);
                    }
                }
            },

            /**
             * Sets whether to support multi-tap on this keyboard. Note: the device must also support it.
             * @param {Boolean} multiTap Pass <code>true</code> to enable multi-tap.
             */
            setMultiTap: function setMultiTap (multiTap) {
                this._multiTap = multiTap;
            },
            /**
             * Gets whether to multi-tap is supported by this keyboard.
             * @returns Boolean <code>true</code> if multi-tap is supported by this keyboard.
             */
            getMultiTap: function getMultiTap () {
                return this._multiTap;
            },
            /**
             * Sets the current text entered/to-be-edited by this keyboard.
             * @param {String} text String to be edited by this keyboard.
             */
            setText: function setText (text) {
                this._currentText = text;
                this._updateClasses();
            },
            /**
             * Gets the text entered/edited by this keyboard.
             * @returns The text entered/edited by this keyboard.
             */
            getText: function getText () {
                return this._currentText;
            },
            /**
             * Sets the capitalisation mode of the keyboard. {@see Keyboard.CAPITALISATION_UPPER},
             * {@see Keyboard.CAPITALISATION_LOWER} and {@see Keyboard.CAPITALISATION_TITLE}
             * @param {Integer} capitalisation The capitalisation mode to use.
             */
            setCapitalisation: function setCapitalisation (capitalisation) {
                this._capitalisation = capitalisation;
            },
            /**
             * Gets the current capitalisation mode of the keyboard.
             * @returns The capitalisation mode of the keyboard.
             */
            getCapitalisation: function getCapitalisation () {
                return this._capitalisation;
            },
            /**
             * Sets the active child widget as the button for the specified character.
             * @param {String} character The character who's button should be the active child widget.
             */
            setActiveChildKey: function setActiveChildKey (character) {
                this._focussedCharacter = character;
                this.setActiveChildWidget(this._letterButtons[character]);
            },
            focus: function focus () {
                this._letterButtons[this._focussedCharacter].focus();
            },
            setFocusToActiveChildKey: function setFocusToActiveChildKey (character){
                this.setActiveChildKey(character);
                this.focus();
            },
            /**
             * Sets the maximum number of characters that can be entered
             * @param {Integer} length The maxmimum number of characters that can be entered. Pass <code>null</code> to allow infinite characters.
             */
            setMaximumLength: function setMaximumLength (length) {
                this._maximumLength = length;
            },
            /**
             * Gets the maximum number of characters that can be entered
             * @returns The maximum number of characters that can be entered
             */
            getMaximumLength: function getMaximumLength () {
                return this._maximumLength;
            },

            _updateClasses: function _updateClasses () {
                if((this._maximumLength !== null) && (this._currentText !== null) && (this._currentText.length >= this._maximumLength)) {
                    this.addClass('maxlength');
                    // Move focus to the DEL key, as it's
                    // the only one that can be used now.
                    this.setActiveChildKey('-');
                } else {
                    this.removeClass('maxlength');
                }
            }
        });

        Keyboard.LAST_WORD_REGEXP = /([^\s]+)$/;
        Keyboard.SHORT_WORD_REGEXP = /^(to|is|in|on|it|and|at|by|a|an|of|if)$/i;

        Keyboard.CAPITALISATION_UPPER = 0;
        Keyboard.CAPITALISATION_LOWER = 1;
        Keyboard.CAPITALISATION_TITLE = 2;

        return Keyboard;
    }
);