// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * The repeat delay in milliseconds before a key starts repeating. Use the * same rate as Chromebook. * (See chrome/browser/chromeos/language_preferences.cc) * @const * @type {number} */ var REPEAT_DELAY_MSEC = 500; /** * The repeat interval or number of milliseconds between subsequent * keypresses. Use the same rate as Chromebook. * @const * @type {number} */ var REPEAT_INTERVAL_MSEC = 50; /** * The double click/tap interval. * @const * @type {number} */ var DBL_INTERVAL_MSEC = 300; /** * The index of the name of the keyset when searching for all keysets. * @const * @type {number} */ var REGEX_KEYSET_INDEX = 1; /** * The integer number of matches when searching for keysets. * @const * @type {number} */ var REGEX_MATCH_COUNT = 2; /** * The boolean to decide if keyboard should transit to upper case keyset * when spacebar is pressed. If a closing punctuation is followed by a * spacebar, keyboard should automatically transit to upper case. * @type {boolean} */ var enterUpperOnSpace = false; /** * A structure to track the currently repeating key on the keyboard. */ var repeatKey = { /** * The timer for the delay before repeating behaviour begins. * @type {number|undefined} */ timer: undefined, /** * The interval timer for issuing keypresses of a repeating key. * @type {number|undefined} */ interval: undefined, /** * The key which is currently repeating. * @type {BaseKey|undefined} */ key: undefined, /** * Cancel the repeat timers of the currently active key. */ cancel: function() { clearTimeout(this.timer); clearInterval(this.interval); this.timer = undefined; this.interval = undefined; this.key = undefined; } }; /** * The minimum movement interval needed to trigger cursor move on * horizontal and vertical way. * @const * @type {number} */ var MIN_SWIPE_DIST_X = 50; var MIN_SWIPE_DIST_Y = 20; /** * The maximum swipe distance that will trigger hintText of a key * to be typed. * @const * @type {number} */ var MAX_SWIPE_FLICK_DIST = 60; /** * The boolean to decide if it is swipe in process or finished. * @type {boolean} */ var swipeInProgress = false; // Flag values for ctrl, alt and shift as defined by EventFlags // in "event_constants.h". // @enum {number} var Modifier = { NONE: 0, ALT: 8, CONTROL: 4, SHIFT: 2 }; /** * A structure to track the current swipe status. */ var swipeTracker = { /** * The latest PointerMove event in the swipe. * @type {Object} */ currentEvent: undefined, /** * Whether or not a swipe changes direction. * @type {false} */ isComplex: false, /** * The count of horizontal and vertical movement. * @type {number} */ offset_x : 0, offset_y : 0, /** * Last touch coordinate. * @type {number} */ pre_x : 0, pre_y : 0, /** * The PointerMove event which triggered the swipe. * @type {Object} */ startEvent: undefined, /** * The flag of current modifier key. * @type {number} */ swipeFlags : 0, /** * Current swipe direction. * @type {number} */ swipeDirection : 0, /** * The number of times we've swiped within a single swipe. * @type {number} */ swipeIndex: 0, /** * Returns the combined direction of the x and y offsets. * @return {number} The latest direction. */ getOffsetDirection: function() { // TODO (rsadam): Use angles to figure out the direction. var direction = 0; // Checks for horizontal swipe. if (Math.abs(this.offset_x) > MIN_SWIPE_DIST_X) { if (this.offset_x > 0) { direction |= SwipeDirection.RIGHT; } else { direction |= SwipeDirection.LEFT; } } // Checks for vertical swipe. if (Math.abs(this.offset_y) > MIN_SWIPE_DIST_Y) { if (this.offset_y < 0) { direction |= SwipeDirection.UP; } else { direction |= SwipeDirection.DOWN; } } return direction; }, /** * Populates the swipe update details. * @param {boolean} endSwipe Whether this is the final event for this * swipe. * @return {Object} The current state of the swipeTracker. */ populateDetails: function(endSwipe) { var detail = {}; detail.direction = this.swipeDirection; detail.index = this.swipeIndex; detail.status = this.swipeStatus; detail.endSwipe = endSwipe; detail.startEvent = this.startEvent; detail.currentEvent = this.currentEvent; detail.isComplex = this.isComplex; return detail; }, /** * Reset all the values when swipe finished. */ resetAll: function() { this.offset_x = 0; this.offset_y = 0; this.pre_x = 0; this.pre_y = 0; this.swipeFlags = 0; this.swipeDirection = 0; this.swipeIndex = 0; this.startEvent = undefined; this.currentEvent = undefined; this.isComplex = false; }, /** * Updates the swipe path with the current event. * @param {Object} event The PointerEvent that triggered this update. * @return {boolean} Whether or not to notify swipe observers. */ update: function(event) { if(!event.isPrimary) return false; // Update priors. this.offset_x += event.screenX - this.pre_x; this.offset_y += event.screenY - this.pre_y; this.pre_x = event.screenX; this.pre_y = event.screenY; // Check if movement crosses minimum thresholds in each direction. var direction = this.getOffsetDirection(); if (direction == 0) return false; // If swipeIndex is zero the current event is triggering the swipe. if (this.swipeIndex == 0) { this.startEvent = event; } else if (direction != this.swipeDirection) { // Toggle the isComplex flag. this.isComplex = true; } // Update the swipe tracker. this.swipeDirection = direction; this.offset_x = 0; this.offset_y = 0; this.currentEvent = event; this.swipeIndex++; return true; }, }; Polymer('kb-keyboard', { alt: null, config: null, control: null, dblDetail_: null, dblTimer_: null, inputType: null, lastPressedKey: null, shift: null, sounds: {}, stale: true, swipeHandler: null, voiceInput_: null, //TODO(rsadam@): Add a control to let users change this. volume: DEFAULT_VOLUME, /** * The default input type to keyboard layout map. The key must be one of * the input box type values. * @type {object} */ inputTypeToLayoutMap: { number: "numeric", text: "qwerty", password: "qwerty" }, /** * Caches the specified sound on the keyboard. * @param {string} soundId The name of the .wav file in the "sounds" directory. */ addSound: function(soundId) { // Check if already loaded. if (soundId == Sound.NONE || this.sounds[soundId]) return; var pool = []; for (var i = 0; i < SOUND_POOL_SIZE; i++) { var audio = document.createElement('audio'); audio.preload = "auto"; audio.id = soundId; audio.src = "../sounds/" + soundId + ".wav"; audio.volume = this.volume; pool.push(audio); } this.sounds[soundId] = pool; }, /** * Changes the current keyset. * @param {Object} detail The detail of the event that called this * function. */ changeKeyset: function(detail) { if (detail.relegateToShift && this.shift) { this.keyset = this.shift.textKeyset; this.activeKeyset.nextKeyset = undefined; return true; } var toKeyset = detail.toKeyset; if (toKeyset) { this.keyset = toKeyset; this.activeKeyset.nextKeyset = detail.nextKeyset; return true; } return false; }, keysetChanged: function() { var keyset = this.activeKeyset; // Show the keyset if it has been initialized. if (keyset) keyset.show(); }, configChanged: function() { this.layout = this.config.layout; }, ready: function() { this.voiceInput_ = new VoiceInput(this); this.swipeHandler = this.move.bind(this); var self = this; getKeyboardConfig(function(config) { self.config = config; }); }, /** * Registers a callback for state change events. * @param{!Function} callback Callback function to register. */ addKeysetChangedObserver: function(callback) { this.addEventListener('stateChange', callback); }, /** * Called when the type of focused input box changes. If a keyboard layout * is defined for the current input type, that layout will be loaded. * Otherwise, the keyboard layout for 'text' type will be loaded. */ inputTypeChanged: function() { // Disable layout switching at accessbility mode. if (this.config && this.config.a11ymode) return; // TODO(bshe): Toggle visibility of some keys in a keyboard layout // according to the input type. var layout = this.inputTypeToLayoutMap[this.inputType]; if (!layout) layout = this.inputTypeToLayoutMap.text; this.layout = layout; }, /** * When double click/tap event is enabled, the second key-down and key-up * events on the same key should be skipped. Return true when the event * with |detail| should be skipped. * @param {Object} detail The detail of key-up or key-down event. */ skipEvent: function(detail) { if (this.dblDetail_) { if (this.dblDetail_.char != detail.char) { // The second key down is not on the same key. Double click/tap // should be ignored. this.dblDetail_ = null; clearTimeout(this.dblTimer_); } else if (this.dblDetail_.clickCount == 1) { return true; } } return false; }, /** * Handles a swipe update. * param {Object} detail The swipe update details. */ onSwipeUpdate: function(detail) { var direction = detail.direction; if (!direction) console.error("Swipe direction cannot be: " + direction); // Triggers swipe editting if it's a purely horizontal swipe. if (!(direction & (SwipeDirection.UP | SwipeDirection.DOWN))) { // Nothing to do if the swipe has ended. if (detail.endSwipe) return; var modifiers = 0; // TODO (rsadam): This doesn't take into account index shifts caused // by vertical swipes. if (detail.index % 2 != 0) { modifiers |= Modifier.SHIFT; modifiers |= Modifier.CONTROL; } MoveCursor(direction, modifiers); return; } // Triggers swipe hintText if it's a purely vertical swipe. if (this.activeKeyset.flick && !(direction & (SwipeDirection.LEFT | SwipeDirection.RIGHT))) { // Check if event is relevant to us. if ((!detail.endSwipe) || (detail.isComplex)) return; // Too long a swipe. var distance = Math.abs(detail.startEvent.screenY - detail.currentEvent.screenY); if (distance > MAX_SWIPE_FLICK_DIST) return; var triggerKey = detail.startEvent.target; if (triggerKey && triggerKey.onFlick) triggerKey.onFlick(detail); } }, /** * This function is bound to swipeHandler. Updates the current swipe * status so that PointerEvents can be converted to Swipe events. * @param {PointerEvent} event. */ move: function(event) { if (!swipeTracker.update(event)) return; // Conversion was successful, swipe is now in progress. swipeInProgress = true; if (this.lastPressedKey) { this.lastPressedKey.classList.remove('active'); this.lastPressedKey = null; } this.onSwipeUpdate(swipeTracker.populateDetails(false)); }, /** * Handles key-down event that is sent by kb-key-base. * @param {CustomEvent} event The key-down event dispatched by * kb-key-base. * @param {Object} detail The detail of pressed kb-key. */ keyDown: function(event, detail) { if (this.skipEvent(detail)) return; if (this.lastPressedKey) { this.lastPressedKey.classList.remove('active'); this.lastPressedKey.autoRelease(); } this.lastPressedKey = event.target; this.lastPressedKey.classList.add('active'); repeatKey.cancel(); this.playSound(detail.sound); var char = detail.char; switch(char) { case 'Shift': this.classList.remove('caps-locked'); break; case 'Alt': case 'Ctrl': var modifier = char.toLowerCase() + "-active"; // Removes modifier if already active. if (this.classList.contains(modifier)) this.classList.remove(modifier); break; case 'Invalid': // Not all Invalid keys are transition keys. Reset control keys if // we pressed a transition key. if (event.target.toKeyset || detail.relegateToShift) this.onNonControlKeyTyped(); break; default: // Notify shift key. if (this.shift) this.shift.onNonControlKeyDown(); if (this.ctrl) this.ctrl.onNonControlKeyDown(); if (this.alt) this.alt.onNonControlKeyDown(); break; } if(this.changeKeyset(detail)) return; if (detail.repeat) { this.keyTyped(detail); this.onNonControlKeyTyped(); repeatKey.key = this.lastPressedKey; var self = this; repeatKey.timer = setTimeout(function() { repeatKey.timer = undefined; repeatKey.interval = setInterval(function() { self.playSound(detail.sound); self.keyTyped(detail); }, REPEAT_INTERVAL_MSEC); }, Math.max(0, REPEAT_DELAY_MSEC - REPEAT_INTERVAL_MSEC)); } }, /** * Handles key-out event that is sent by kb-shift-key. * @param {CustomEvent} event The key-out event dispatched by * kb-shift-key. * @param {Object} detail The detail of pressed kb-shift-key. */ keyOut: function(event, detail) { this.changeKeyset(detail); }, /** * Enable/start double click/tap event recognition. * @param {CustomEvent} event The enable-dbl event dispatched by * kb-shift-key. * @param {Object} detail The detail of pressed kb-shift-key. */ enableDbl: function(event, detail) { if (!this.dblDetail_) { this.dblDetail_ = detail; this.dblDetail_.clickCount = 0; var self = this; this.dblTimer_ = setTimeout(function() { self.dblDetail_.callback = null; self.dblDetail_ = null; }, DBL_INTERVAL_MSEC); } }, /** * Enable the selection while swipe. * @param {CustomEvent} event The enable-dbl event dispatched by * kb-shift-key. */ enableSel: function(event) { // TODO(rsadam): Disabled for now. May come back if we revert swipe // selection to not do word selection. }, /** * Handles pointerdown event. This is used for swipe selection process. * to get the start pre_x and pre_y. And also add a pointermove handler * to start handling the swipe selection event. * @param {PointerEvent} event The pointerup event that received by * kb-keyboard. */ down: function(event) { var layout = getKeysetLayout(this.activeKeysetId); var key = layout.findClosestKey(event.clientX, event.clientY); if (key) key.down(event); if (event.isPrimary) { swipeTracker.pre_x = event.screenX; swipeTracker.pre_y = event.screenY; this.addEventListener("pointermove", this.swipeHandler, false); } }, /** * Handles pointerup event. This is used for double tap/click events. * @param {PointerEvent} event The pointerup event that bubbled to * kb-keyboard. */ up: function(event) { var layout = getKeysetLayout(this.activeKeysetId); var key = layout.findClosestKey(event.clientX, event.clientY); if (key) key.up(event); // When touch typing, it is very possible that finger moves slightly out // of the key area before releases. The key should not be dropped in // this case. // TODO(rsadam@) Change behaviour such that the key drops and the second // key gets pressed. if (this.lastPressedKey && this.lastPressedKey.pointerId == event.pointerId) { this.lastPressedKey.autoRelease(); } if (this.dblDetail_) { this.dblDetail_.clickCount++; if (this.dblDetail_.clickCount == 2) { this.dblDetail_.callback(); this.changeKeyset(this.dblDetail_); clearTimeout(this.dblTimer_); this.classList.add('caps-locked'); this.dblDetail_ = null; } } // TODO(zyaozhujun): There are some edge cases to deal with later. // (for instance, what if a second finger trigger a down and up // event sequence while swiping). // When pointer up from the screen, a swipe selection session finished, // all the data should be reset to prepare for the next session. if (event.isPrimary && swipeInProgress) { swipeInProgress = false; this.onSwipeUpdate(swipeTracker.populateDetails(true)) swipeTracker.resetAll(); } this.removeEventListener('pointermove', this.swipeHandler, false); }, /** * Handles PointerOut event. This is used for when a swipe gesture goes * outside of the keyboard window. * @param {Object} event The pointerout event that bubbled to the * kb-keyboard. */ out: function(event) { repeatKey.cancel(); // Ignore if triggered from one of the keys. if (this.compareDocumentPosition(event.relatedTarget) & Node.DOCUMENT_POSITION_CONTAINED_BY) return; if (swipeInProgress) this.onSwipeUpdate(swipeTracker.populateDetails(true)) // Touched outside of the keyboard area, so disables swipe. swipeInProgress = false; swipeTracker.resetAll(); this.removeEventListener('pointermove', this.swipeHandler, false); }, /** * Handles a TypeKey event. This is used for when we programmatically * want to type a specific key. * @param {CustomEvent} event The TypeKey event that bubbled to the * kb-keyboard. */ type: function(event) { this.keyTyped(event.detail); }, /** * Handles key-up event that is sent by kb-key-base. * @param {CustomEvent} event The key-up event dispatched by kb-key-base. * @param {Object} detail The detail of pressed kb-key. */ keyUp: function(event, detail) { if (this.skipEvent(detail)) return; if (swipeInProgress) return; if (detail.activeModifier) { var modifier = detail.activeModifier.toLowerCase() + "-active"; this.classList.add(modifier); } // Adds the current keyboard modifiers to the detail. if (this.ctrl) detail.controlModifier = this.ctrl.isActive(); if (this.alt) detail.altModifier = this.alt.isActive(); if (this.lastPressedKey) this.lastPressedKey.classList.remove('active'); // Keyset transition key. This is needed to transition from upper // to lower case when we are not in caps mode, as well as when // we're ending chording. this.changeKeyset(detail); if (this.lastPressedKey && this.lastPressedKey.charValue != event.target.charValue) { return; } if (repeatKey.key == event.target) { repeatKey.cancel(); this.lastPressedKey = null; return; } var toLayoutId = detail.toLayout; // Layout transition key. if (toLayoutId) this.layout = toLayoutId; var char = detail.char; this.lastPressedKey = null; // Characters that should not be typed. switch(char) { case 'Invalid': case 'Shift': case 'Ctrl': case 'Alt': enterUpperOnSpace = false; swipeTracker.swipeFlags = 0; return; case 'Microphone': this.voiceInput_.onDown(); return; default: break; } // Tries to type the character. Resorts to insertText if that fails. if(!this.keyTyped(detail)) insertText(char); // Post-typing logic. switch(char) { case '\n': case ' ': if(enterUpperOnSpace) { enterUpperOnSpace = false; if (this.shift) { var shiftDetail = this.shift.onSpaceAfterPunctuation(); // Check if transition defined. this.changeKeyset(shiftDetail); } else { console.error('Capitalization on space after punctuation \ enabled, but cannot find target keyset.'); } // Immediately return to maintain shift-state. Space is a // non-control key and would otherwise trigger a reset of the // shift key, causing a transition to lower case. return; } break; case '.': case '?': case '!': enterUpperOnSpace = this.shouldUpperOnSpace(); break; default: enterUpperOnSpace = false; break; } // Reset control keys. this.onNonControlKeyTyped(); }, /** * Handles key-longpress event that is sent by kb-key-base. * @param {CustomEvent} event The key-longpress event dispatched by * kb-key-base. * @param {Object} detail The detail of pressed key. */ keyLongpress: function(event, detail) { // If the gesture is long press, remove the pointermove listener. this.removeEventListener('pointermove', this.swipeHandler, false); // Keyset transtion key. if (this.changeKeyset(detail)) { // Locks the keyset before removing active to prevent flicker. this.classList.add('caps-locked'); // Makes last pressed key inactive if transit to a new keyset on long // press. if (this.lastPressedKey) this.lastPressedKey.classList.remove('active'); } }, /** * Plays the specified sound. * @param {Sound} sound The id of the audio tag. */ playSound: function(sound) { if (!SOUND_ENABLED || !sound || sound == Sound.NONE) return; var pool = this.sounds[sound]; if (!pool) { console.error("Cannot find audio tag: " + sound); return; } // Search the sound pool for a free resource. for (var i = 0; i < pool.length; i++) { if (pool[i].paused) { pool[i].play(); return; } } }, /** * Whether we should transit to upper case when seeing a space after * punctuation. * @return {boolean} */ shouldUpperOnSpace: function() { // TODO(rsadam): Add other input types in which we should not // transition to upper after a space. return this.inputTypeValue != 'password'; }, /** * Handler for the 'set-layout' event. * @param {!Event} event The triggering event. * @param {{layout: string}} details Details of the event, which contains * the name of the layout to activate. */ setLayout: function(event, details) { this.layout = details.layout; }, /** * Handles a change in the keyboard layout. Auto-selects the default * keyset for the new layout. */ layoutChanged: function() { this.stale = true; if (!this.selectDefaultKeyset()) { console.error('No default keyset found for layout: ' + this.layout); return; } this.activeKeyset.show(); }, /** * Notifies the modifier keys that a non-control key was typed. This * lets them reset sticky behaviour. A non-control key is defined as * any key that is not Control, Alt, or Shift. */ onNonControlKeyTyped: function() { if (this.shift) this.shift.onNonControlKeyTyped(); if (this.ctrl) this.ctrl.onNonControlKeyTyped(); if (this.alt) this.alt.onNonControlKeyTyped(); this.classList.remove('ctrl-active'); this.classList.remove('alt-active'); }, /** * Callback function for when volume is changed. */ volumeChanged: function() { var toChange = Object.keys(this.sounds); for (var i = 0; i < toChange.length; i++) { var pool = this.sounds[toChange[i]]; for (var j = 0; j < pool.length; j++) { pool[j].volume = this.volume; } } }, /** * Id for the active keyset. * @type {string} */ get activeKeysetId() { return this.layout + '-' + this.keyset; }, /** * The active keyset DOM object. * @type {kb-keyset} */ get activeKeyset() { return this.querySelector('#' + this.activeKeysetId); }, /** * The current input type. * @type {string} */ get inputTypeValue() { return this.inputType; }, /** * Changes the input type if it's different from the current * type, else resets the keyset to the default keyset. * @type {string} */ set inputTypeValue(value) { if (value == this.inputType) this.selectDefaultKeyset(); else this.inputType = value; }, /** * The keyboard is ready for input once the target keyset appears * in the distributed nodes for the keyboard. * @return {boolean} Indicates if the keyboard is ready for input. */ isReady: function() { var keyset = this.activeKeyset; if (!keyset) return false; var nodes = this.$.content.getDistributedNodes(); for (var i = 0; i < nodes.length; i++) { if (nodes[i].id && nodes[i].id == keyset.id) return true; } return false; }, /** * Generates fabricated key events to simulate typing on a * physical keyboard. * @param {Object} detail Attributes of the key being typed. * @return {boolean} Whether the key type succeeded. */ keyTyped: function(detail) { var builder = this.$.keyCodeMetadata; if (this.ctrl) detail.controlModifier = this.ctrl.isActive(); if (this.alt) detail.altModifier = this.alt.isActive(); var downEvent = builder.createVirtualKeyEvent(detail, "keydown"); if (downEvent) { sendKeyEvent(downEvent); sendKeyEvent(builder.createVirtualKeyEvent(detail, "keyup")); return true; } return false; }, /** * Selects the default keyset for a layout. * @return {boolean} True if successful. This method can fail if the * keysets corresponding to the layout have not been injected. */ selectDefaultKeyset: function() { var keysets = this.querySelectorAll('kb-keyset'); // Full name of the keyset is of the form 'layout-keyset'. var regex = new RegExp('^' + this.layout + '-(.+)'); var keysetsLoaded = false; for (var i = 0; i < keysets.length; i++) { var matches = keysets[i].id.match(regex); if (matches && matches.length == REGEX_MATCH_COUNT) { keysetsLoaded = true; // Without both tests for a default keyset, it is possible to get // into a state where multiple layouts are displayed. A // reproducable test case is do the following set of keyset // transitions: qwerty -> system -> dvorak -> qwerty. // TODO(kevers): Investigate why this is the case. if (keysets[i].isDefault || keysets[i].getAttribute('isDefault') == 'true') { this.keyset = matches[REGEX_KEYSET_INDEX]; this.classList.remove('caps-locked'); this.classList.remove('alt-active'); this.classList.remove('ctrl-active'); // Caches shift key. this.shift = this.querySelector('kb-shift-key'); if (this.shift) this.shift.reset(); // Caches control key. this.ctrl = this.querySelector('kb-modifier-key[char=Ctrl]'); if (this.ctrl) this.ctrl.reset(); // Caches alt key. this.alt = this.querySelector('kb-modifier-key[char=Alt]'); if (this.alt) this.alt.reset(); this.fire('stateChange', { state: 'keysetLoaded', value: this.keyset, }); keyboardLoaded(); return true; } } } if (keysetsLoaded) console.error('No default keyset found for ' + this.layout); return false; } });