Source: ARCore/keyboards/abstract_keyboard.js

define(["lib/jquery",            "lib/underscore",           "common/util/log",
        "common/util/dom_utils", "common/util/binding_list", "common/util/id_generator",
        "common/util/check"],
function( $,                       _,                          Log,
          DOMUtils,                BindingList,                IDGenerator,
          check){

    "use strict";

    var logger = Log("AbstractKeyboard");

    // delay between when a key is pressed and when it starts repeating
    // set to false to prevent repeating
    var REPEAT_FIRST_DELAY = 1000;

    // delay between repeats one they start
    var REPEAT_SECOND_DELAY = 200;

    // key codes for special keys.
    // see: http://www.cambiaresearch.com/articles/15/javascript-char-codes-key-codes
    var specialKeys = {
        TAB:       9,
        BACKSPACE: 8,
        ENTER:     13,
        LEFT:      37,
        RIGHT:     39
    };


    // inserts text into a text box at the caret position. based on
    // http://stackoverflow.com/a/4384173/2005979 and
    // base.insertText from https://github.com/Mottie/Keyboard/blob/master/js/jquery.keyboard.js
    var insertAtCaret = function(element, text) {
        var startPos = element.selectionStart;
        var endPos = element.selectionEnd;
        var val = element.value;

        // insert character
        if(text === specialKeys.BACKSPACE) {
            element.value = val.substring(0, startPos-1) + val.substring(endPos, val.length);
        }
        else {
            element.value = val.substring(0, startPos) + text + val.substring(endPos, val.length);
        }

        element.focus();

        // set new caret position
        if(text === specialKeys.BACKSPACE) {
            moveCaret(element, startPos - 1);
        }
        else {
            moveCaret(element, startPos + text.length);
        }
    };

    // calculates the width and height of the text contained in a text input
    // or textarea. based on http://stackoverflow.com/a/2117698/2005979
    var textMetrics = function(el, text, preserveWidth) {
        var h = 0, w = 0, $el = $(el);

        // create a hidden element
        var div = document.createElement("div");
        var $div = $(div);
        document.body.appendChild(div);
        $div.css({
            position: "absolute",
            left: -1000,
            top: -1000,
            display: "none"
        });

        // copy text from input to hidden element
        $div.text(text);
        $div.css("white-space", "pre");

        // copy over styles
        var styles = ["font-size","font-style", "font-weight", "font-family","line-height", "text-transform", "letter-spacing"];
        _.each(styles, function(style) {
            $div.css(style, $el.css(style));
        });

        // preserve with the desired
        if(preserveWidth) {
            $div.css("width", $el.width());
        }

        h = $div.outerHeight();
        w = $div.outerWidth();

        $div.remove();

        return {
            height: h,
            width: w,
        };
    };

    // moves the caret to a given position
    var moveCaret = function(el, pos) {
        el.setSelectionRange(pos, pos);

        // update scrollLeft and scrollTop to ensure the caret is in view
        var $el = $(el);

        var caretPos;
        if(el.tagName === "TEXTAREA") {
            caretPos = textMetrics(el, el.value.substring(0, pos), true).height;
            var lineHeight = parseInt( $el.css("line-height"), 10) || parseInt($el.css("font-size"), 10) + 4;
            var height = $el.height();
            var top = el.scrollTop;
            var bottom = top + height;

            if((caretPos-lineHeight) < top) {
                el.scrollTop -= (height / 2);
            }
            else if((caretPos+lineHeight) > bottom) {
                el.scrollTop += (height / 2);
            }
        }
        else {
            caretPos = textMetrics(el, el.value.substring(0, pos)).width;
            var width = $el.width();
            var left = el.scrollLeft;
            var right = left + width;
            if(caretPos < left) {
                el.scrollLeft -= (width / 2);
            }
            else if(caretPos > right) {
                el.scrollLeft += (width / 2);
            }
        }

    };

    // handlers for special keys. gets the currently focussed element as
    // an argument. only called if an ediatable element is focussed. if they
    // return a string, that string is inserted at the caret position.
    //
    // no need to do KEY_BACKSPACE; that's special-cased in insertAtCaret
    var keyHandlers = {};
    keyHandlers[specialKeys.TAB] = function() {
        return "\t";
    };
    keyHandlers[specialKeys.ENTER] = function() {
        return "\n";
    };
    keyHandlers[specialKeys.LEFT] = function(el) {
        moveCaret(el, el.selectionStart-1);
    };
    keyHandlers[specialKeys.RIGHT] = function(el) {
        moveCaret(el, el.selectionStart+1);
    };


    /**
     * Constructs a new AbstractKeyboard. Subclasses should call this to get a
     * basic keyboard before extending it with a concrete implementation.
     * Subclasses should pass in an object as `$protected`, which will be
     * populated with `keydown(key)` and `keyup(keydownId)`, which allow
     * keyboards to simulate a key being pressed or released. `key` is either a
     * printable character or one of the constants that is in
     * `AbstractKeyboard.KEYS` to represent special keys: `TAB`, `BACKSPACE`,
     * `ENTER`, `LEFT`, and `RIGHT`. `keydown` returns an id that
     * can be passed to `keyup` to mark that key as released.
     *
     * AbstractKeyboards are *not* frozen, so you can subclass AbstractKeyboard
     * using parasitic inheritance:
     *
     *     function MySpecialKeyboard() {
     *         var $protected = {};
     *         var self = AbstractKeyboard($protected);
     *
     *         self.pressAKeyRepeatedly = function(key, numTimes) {
     *             for(var i = 0; i < numTimes; i++) {
     *                 $protected.keyup($protected.keydown(key));
     *             }
     *         };
     *
     *         Object.freeze(self);
     *         return self;
     *     }
     *
     * @param {Object} $protected
     * Subclasses should pass an object for this argument. It will be populated
     * with protected members available only to subclasses.
     *
     * @private
     * @class An abstract base class for keyboards.
     * @name AbstractKeyboard
     */
    var AbstractKeyboard = function($protected) {
        /** @alias AbstractKeyboard.prototype */
        var self = {};

        // listeners registered by keypressed
        var keypressListeners = BindingList();

        // id returned by keydown -> setTimeout id
        var timeouts = {};

         // fires a lens:keypress event, informs subscribers, and modifies the
        // focussed element if it is a text box
        var press = function(key) {
            var props = {
                key: key,
                char: key,
                keyCode: key,
                charCode: key,
                which: key
            };

            keypressListeners.callAll(props);

            DOMUtils.fireBubbling("lens:keypress", document.activeElement, props);

            var active = $(document.activeElement);
            if(active.is("input:text") || active.is("textarea")) {
                // character to type
                var chr = key;

                // run the key handler for special keys. if it returns a
                // string, that string becomes the inserted chr.
                if(_.isNumber(key) && keyHandlers[key]) {
                    chr = keyHandlers[key](active[0]);
                }

                // if we have a character to type, insert it at the current
                // carat position.
                if(chr) {
                    insertAtCaret(active[0], chr);
                }
            }
        };

        // simulated a key being pressed. Returns an ID that can be passed to
        // keyup.
        $protected.keydown = function(key) {
            press(key);

            // we use multiple calls to setTimeout, so we need to have a stable
            // ID that we map to whatever the current setTimeout ID is.
            var keydownId = IDGenerator.uuid();

            // repeatedly press the key if it's held down.
            if(REPEAT_FIRST_DELAY !== false) {
                var timeoutId = window.setTimeout(function() {
                    var repeater = function() {
                        press(key);
                        var timeoutId = setTimeout(repeater, REPEAT_SECOND_DELAY);
                        timeouts[keydownId] = timeoutId;
                    };
                    repeater();
                }, REPEAT_FIRST_DELAY);

                timeouts[keydownId] = timeoutId;
            }

            return keydownId;
        };

        // simulates a key being released. Takes an ID returned from keydown.
        $protected.keyup = function(keydownId) {
            window.clearTimeout(timeouts[keydownId]);
        };

        /**
         * Dismisses the keyboard.
         */
        self.dismiss = function() {
            logger.error("dismiss is unimplemented");
        };

        /**
         * Binds a handler to the keypress event.
         *
         * @param {function} handler Handler to bind
         */
        self.keyPressed = function(handler) {
            check(handler, Function);
            return keypressListeners.add(handler);
        };

        // dont' freeze self; we need to be able to subclass it
        return self;
    };

    /**
     * Map of special keys to character codes that can be passed to keyup.
     * @private
     */
    AbstractKeyboard.KEYS = specialKeys;

    return AbstractKeyboard;
});