blob: 36eaf020be5b45688688833ba4e2b163e97de445 [file] [log] [blame]
// Copyright 2018 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.
let SelectToSpeakState = chrome.accessibilityPrivate.SelectToSpeakState;
/**
* Callbacks for InputHandler.
* |canStartSelecting| returns true if the user can start selecting a region
* with the mouse. |onSelectingStateChanged| is called when the user starts or
* ends selecting with the mouse. |onSelectionChanged| is called when the
* region selected with the mouse changes size. |onKeystrokeSelection| is called
* when a keystroke is completed indicating that highlighted text is selected to
* be used. |onRequestCancel| is called when the user has indicated that the
* current selection or speech should be canceled. |onTextReceived| is called
* when a copy-paste event results in text to be spoken.
* @typedef {{
* canStartSelecting: function(): boolean,
* onSelectingStateChanged: function(boolean, number, number),
* onSelectionChanged: function({left: number, top: number, width: number,
* height: number}),
* onKeystrokeSelection: function(),
* onRequestCancel: function(),
* onTextReceived: function(string)
* }}
*/
let SelectToSpeakCallbacks;
/**
* Class to handle user-input, from mouse, keyboard, and copy-paste events.
* @param {SelectToSpeakCallbacks} callbacks
* @constructor
*/
let InputHandler = function(callbacks) {
/** @private {SelectToSpeakCallbacks} */
this.callbacks_ = callbacks;
/** @private {boolean} */
this.trackingMouse_ = false;
/** @private {boolean} */
this.didTrackMouse_ = false;
/** @private {boolean} */
this.isSearchKeyDown_ = false;
/** @private {boolean} */
this.isSelectionKeyDown_ = false;
/** @private {!Set<number>} */
this.keysCurrentlyDown_ = new Set();
/** @private {!Set<number>} */
this.keysPressedTogether_ = new Set();
/** @private {{x: number, y: number}} */
this.mouseStart_ = {x: 0, y: 0};
/** @private {{x: number, y: number}} */
this.mouseEnd_ = {x: 0, y: 0};
/**
* The timestamp at which clipboard data read was requested by the user
* doing a "read selection" keystroke on a Google Docs app. If a
* clipboard change event comes in within CLIPBOARD_READ_MAX_DELAY_MS,
* Select-to-Speak will read that text out loud.
* @private {Date}
*/
this.lastReadClipboardDataTime_ = new Date(0);
/**
* The timestamp at which the last clipboard data clear was requested.
* Used to make sure we don't clear the clipboard on a user's request,
* but only after the clipboard was used to read selected text.
* @private {Date}
*/
this.lastClearClipboardDataTime_ = new Date(0);
/**
* Called when the mouse is moved or dragged and the user is in a
* mode where select-to-speak is capturing mouse events (for example
* holding down Search).
*
* @param {!Event} evt The DOM event
* @return {boolean} True if the default action should be performed.
* @private
*/
this.onMouseMove_ = function(evt) {
if (!this.trackingMouse_)
return false;
var rect = RectUtils.rectFromPoints(
this.mouseStart_.x, this.mouseStart_.y, evt.screenX, evt.screenY);
this.callbacks_.onSelectionChanged(rect);
return false;
};
/**
* @private
*/
this.onClipboardDataChanged_ = function() {
if (new Date() - this.lastReadClipboardDataTime_ <
InputHandler.CLIPBOARD_READ_MAX_DELAY_MS) {
// The data has changed, and we are ready to read it.
// Get it using a paste.
document.execCommand('paste');
}
};
/**
* @private
*/
this.onClipboardCopy_ = function(evt) {
if (new Date() - this.lastClearClipboardDataTime_ <
InputHandler.CLIPBOARD_CLEAR_MAX_DELAY_MS) {
// onClipboardPaste has just completed reading the clipboard for speech.
// This is used to clear the clipboard.
evt.clipboardData.setData('text/plain', '');
evt.preventDefault();
this.lastClearClipboardDataTime_ = new Date(0);
}
};
/**
* @private
*/
this.onClipboardPaste_ = function(evt) {
if (new Date() - this.lastReadClipboardDataTime_ <
InputHandler.CLIPBOARD_READ_MAX_DELAY_MS) {
// Read the current clipboard data.
evt.preventDefault();
this.callbacks_.onTextReceived(evt.clipboardData.getData('text/plain'));
this.lastReadClipboardDataTime_ = new Date(0);
// Clear the clipboard data by copying nothing (the current document).
// Do this in a timeout to avoid a recursive warning per
// https://crbug.com/363288.
setTimeout(() => {
this.lastClearClipboardDataTime_ = new Date();
document.execCommand('copy');
}, 0);
}
};
};
InputHandler.prototype = {
/**
* Set up event listeners for mouse and keyboard events. These are
* forwarded to us from the SelectToSpeakEventHandler so they should
* be interpreted as global events on the whole screen, not local to
* any particular window.
* @public
*/
setUpEventListeners: function() {
document.addEventListener('keydown', this.onKeyDown_.bind(this));
document.addEventListener('keyup', this.onKeyUp_.bind(this));
document.addEventListener('mousedown', this.onMouseDown_.bind(this));
document.addEventListener('mousemove', this.onMouseMove_.bind(this));
document.addEventListener('mouseup', this.onMouseUp_.bind(this));
chrome.clipboard.onClipboardDataChanged.addListener(
this.onClipboardDataChanged_.bind(this));
document.addEventListener('paste', this.onClipboardPaste_.bind(this));
document.addEventListener('copy', this.onClipboardCopy_.bind(this));
},
/**
* Change whether or not we are tracking the mouse.
* @param {boolean} tracking True if we should start tracking the mouse, false
* otherwise.
* @public
*/
setTrackingMouse: function(tracking) {
this.trackingMouse_ = tracking;
},
/**
* Gets the rect that has been drawn by clicking and dragging the mouse.
* @public
*/
getMouseRect: function() {
return RectUtils.rectFromPoints(
this.mouseStart_.x, this.mouseStart_.y, this.mouseEnd_.x,
this.mouseEnd_.y);
},
/**
* Sets the date at which we last wanted the clipboard data to be read.
* @public
*/
onRequestReadClipboardData: function() {
this.lastReadClipboardDataTime_ = new Date();
},
/**
* Called when the mouse is pressed and the user is in a mode where
* select-to-speak is capturing mouse events (for example holding down
* Search).
* Visible for testing.
*
* @param {!Event} evt The DOM event
* @return {boolean} True if the default action should be performed;
* we always return false because we don't want any other event
* handlers to run.
* @public
*/
onMouseDown_: function(evt) {
// If the user hasn't clicked 'search', or if they are currently
// trying to highlight a selection, don't track the mouse.
if (this.callbacks_.canStartSelecting() &&
(!this.isSearchKeyDown_ || this.isSelectionKeyDown_))
return false;
this.callbacks_.onSelectingStateChanged(
true /* is selecting */, evt.screenX, evt.screenY);
this.trackingMouse_ = true;
this.didTrackMouse_ = true;
this.mouseStart_ = {x: evt.screenX, y: evt.screenY};
this.onMouseMove_(evt);
return false;
},
/**
* Called when the mouse is released and the user is in a
* mode where select-to-speak is capturing mouse events (for example
* holding down Search).
* Visible for testing.
*
* @param {!Event} evt
* @return {boolean} True if the default action should be performed.
* @public
*/
onMouseUp_: function(evt) {
if (!this.trackingMouse_)
return false;
this.onMouseMove_(evt);
this.trackingMouse_ = false;
if (!this.keysCurrentlyDown_.has(SelectToSpeak.SEARCH_KEY_CODE)) {
// This is only needed to cancel something started with the search key.
this.didTrackMouse_ = false;
}
this.mouseEnd_ = {x: evt.screenX, y: evt.screenY};
var ctrX = Math.floor((this.mouseStart_.x + this.mouseEnd_.x) / 2);
var ctrY = Math.floor((this.mouseStart_.y + this.mouseEnd_.y) / 2);
this.callbacks_.onSelectingStateChanged(
false /* is no longer selecting */, ctrX, ctrY);
return false;
},
/**
* Visible for testing.
* @param {!Event} evt
* @public
*/
onKeyDown_: function(evt) {
this.keysCurrentlyDown_.add(evt.keyCode);
this.keysPressedTogether_.add(evt.keyCode);
if (this.keysPressedTogether_.size == 1 &&
evt.keyCode == SelectToSpeak.SEARCH_KEY_CODE) {
this.isSearchKeyDown_ = true;
} else if (
this.keysCurrentlyDown_.size == 2 &&
evt.keyCode == SelectToSpeak.READ_SELECTION_KEY_CODE &&
!this.trackingMouse_) {
// Only go into selection mode if we aren't already tracking the mouse.
this.isSelectionKeyDown_ = true;
} else if (!this.trackingMouse_) {
// Some other key was pressed.
this.isSearchKeyDown_ = false;
}
},
/**
* Visible for testing.
* @param {!Event} evt
* @public
*/
onKeyUp_: function(evt) {
if (evt.keyCode == SelectToSpeak.READ_SELECTION_KEY_CODE) {
if (this.isSelectionKeyDown_ && this.keysPressedTogether_.size == 2 &&
this.keysPressedTogether_.has(evt.keyCode) &&
this.keysPressedTogether_.has(SelectToSpeak.SEARCH_KEY_CODE)) {
this.callbacks_.onKeystrokeSelection();
}
this.isSelectionKeyDown_ = false;
} else if (evt.keyCode == SelectToSpeak.SEARCH_KEY_CODE) {
this.isSearchKeyDown_ = false;
// If we were in the middle of tracking the mouse, cancel it.
if (this.trackingMouse_) {
this.trackingMouse_ = false;
this.callbacks_.onRequestCancel();
}
}
// Stop speech when the user taps and releases Control or Search
// without using the mouse or pressing any other keys along the way.
if (!this.didTrackMouse_ &&
(evt.keyCode == SelectToSpeak.SEARCH_KEY_CODE ||
evt.keyCode == SelectToSpeak.CONTROL_KEY_CODE) &&
this.keysPressedTogether_.has(evt.keyCode) &&
this.keysPressedTogether_.size == 1) {
this.trackingMouse_ = false;
this.callbacks_.onRequestCancel();
}
this.keysCurrentlyDown_.delete(evt.keyCode);
if (this.keysCurrentlyDown_.size == 0) {
this.keysPressedTogether_.clear();
this.didTrackMouse_ = false;
}
},
};
// Number of milliseconds to wait after requesting a clipboard read
// before clipboard change and paste events are ignored.
InputHandler.CLIPBOARD_READ_MAX_DELAY_MS = 1000;
// Number of milliseconds to wait after requesting a clipboard copy
// before clipboard copy events are ignored, used to clear the clipboard
// after reading data in a paste event.
InputHandler.CLIPBOARD_CLEAR_MAX_DELAY_MS = 500;