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; });