ace/lib/ace/ext/chromevox.js

827 lines
20 KiB
JavaScript

define(function(require, exports, module) {
/* ChromeVox Ace namespace. */
var cvoxAce = {};
/* Typedefs for Closure compiler. */
/**
* @typedef {{
rate: number,
pitch: number,
volume: number,
relativePitch: number,
punctuationEcho: string
}}
*/
/* TODO(peterxiao): Export this typedef through cvox.Api. */
cvoxAce.SpeechProperty;
/**
* @typedef {{
* row: number,
* column: number
* }}
*/
cvoxAce.Cursor;
/**
* @typedef {{
type: string,
value: string
}}
}
*/
cvoxAce.Token;
/**
* These are errors and information that Ace will display in the gutter.
* @typedef {{
row: number,
column: number,
value: string
}}
}
*/
cvoxAce.Annotation;
/* Speech Properties. */
/**
* Speech property for speaking constant tokens.
* @type {cvoxAce.SpeechProperty}
*/
var CONSTANT_PROP = {
'rate': 0.8,
'pitch': 0.4,
'volume': 0.9
};
/**
* Default speech property for speaking tokens.
* @type {cvoxAce.SpeechProperty}
*/
var DEFAULT_PROP = {
'rate': 1,
'pitch': 0.5,
'volume': 0.9
};
/**
* Speech property for speaking entity tokens.
* @type {cvoxAce.SpeechProperty}
*/
var ENTITY_PROP = {
'rate': 0.8,
'pitch': 0.8,
'volume': 0.9
};
/**
* Speech property for speaking keywords.
* @type {cvoxAce.SpeechProperty}
*/
var KEYWORD_PROP = {
'rate': 0.8,
'pitch': 0.3,
'volume': 0.9
};
/**
* Speech property for speaking storage tokens.
* @type {cvoxAce.SpeechProperty}
*/
var STORAGE_PROP = {
'rate': 0.8,
'pitch': 0.7,
'volume': 0.9
};
/**
* Speech property for speaking variable tokens.
* @type {cvoxAce.SpeechProperty}
*/
var VARIABLE_PROP = {
'rate': 0.8,
'pitch': 0.8,
'volume': 0.9
};
/**
* Speech property for speaking deleted text.
* @type {cvoxAce.SpeechProperty}
*/
var DELETED_PROP = {
'punctuationEcho': 'none',
'relativePitch': -0.6
};
/* Constants for Earcons. */
var ERROR_EARCON = 'ALERT_NONMODAL';
var MODE_SWITCH_EARCON = 'ALERT_MODAL';
var NO_MATCH_EARCON = 'INVALID_KEYPRESS';
/* Constants for vim state. */
var INSERT_MODE_STATE = 'insertMode';
var COMMAND_MODE_STATE = 'start';
/**
* Context menu commands.
*/
var Command = {
SPEAK_ANNOT: 'annots',
SPEAK_ALL_ANNOTS: 'all_annots',
TOGGLE_LOCATION: 'toggle_location',
SPEAK_MODE: 'mode',
SPEAK_ROW_COL: 'row_col',
TOGGLE_DISPLACEMENT: 'toggle_displacement',
FOCUS_TEXT: 'focus_text'
};
/**
* Key prefix for each shortcut.
*/
var KEY_PREFIX = 'CONTROL + SHIFT ';
/* Globals. */
cvoxAce.editor = null;
/**
* Last cursor position.
* @type {cvoxAce.Cursor}
*/
var lastCursor = null;
/**
* Table of annotations.
* @typedef {!Object.<number, Object<number, cvoxAce.Annotation>>}
*/
var annotTable = {};
/**
* Whether to speak character, word, and then line. This allows blind users
* to know the location of the cursor when they change lines.
* @typedef {boolean}
*/
var shouldSpeakRowLocation = false;
/**
* Whether to speak displacement.
* @typedef {boolean}
*/
var shouldSpeakDisplacement = false;
/**
* Whether text was changed to cause a cursor change event.
* @typedef {boolean}
*/
var changed = false;
/**
* Current state vim is in.
*/
var vimState = null;
/**
* Mapping from key code to shortcut.
*/
var keyCodeToShortcutMap = {};
/**
* Mapping from command to shortcut.
*/
var cmdToShortcutMap = {};
/**
* Get shortcut string from keyCode.
* @param {number} keyCode Key code of shortcut.
* @return {string} String representation of shortcut.
*/
var getKeyShortcutString = function(keyCode) {
return KEY_PREFIX + String.fromCharCode(keyCode);
};
/**
* Return if in vim mode.
* @return {boolean} True if in Vim mode.
*/
var isVimMode = function() {
var keyboardHandler = cvoxAce.editor.keyBinding.getKeyboardHandler();
return keyboardHandler.$id === 'ace/keyboard/vim';
};
/**
* Gets the current token.
* @param {!cvoxAce.Cursor} cursor Current position of the cursor.
* @return {!cvoxAce.Token} Token at the current position.
*/
var getCurrentToken = function(cursor) {
return cvoxAce.editor.getSession().getTokenAt(cursor.row, cursor.column + 1);
};
/**
* Gets the current line the cursor is under.
* @param {!cvoxAce.Cursor} cursor Current cursor position.
*/
var getCurrentLine = function(cursor) {
return cvoxAce.editor.getSession().getLine(cursor.row);
};
/**
* Event handler for row changes. When the user changes rows we want to speak
* the line so the user can work on this line. If shouldSpeakRowLocation is on
* then we speak the character, then the row, then the line so the user knows
* where the cursor is.
* @param {!cvoxAce.Cursor} currCursor Current cursor position.
*/
var onRowChange = function(currCursor) {
/* Notify that this line has an annotation. */
if (annotTable[currCursor.row]) {
cvox.Api.playEarcon(ERROR_EARCON);
}
if (shouldSpeakRowLocation) {
cvox.Api.stop();
speakChar(currCursor);
speakTokenQueue(getCurrentToken(currCursor));
speakLine(currCursor.row, 1);
} else {
speakLine(currCursor.row, 0);
}
};
/**
* Returns whether the cursor is at the beginning of a word. A word is
* a grouping of alphanumeric characters including underscores.
* @param {!cvoxAce.Cursor} cursor Current cursor position.
* @return {boolean} Whether there is word.
*/
var isWord = function(cursor) {
var line = getCurrentLine(cursor);
var lineSuffix = line.substr(cursor.column - 1);
if (cursor.column === 0) {
lineSuffix = ' ' + line;
}
/* Use regex to tell if the suffix is at the start of a new word. */
var firstWordRegExp = /^\W(\w+)/;
var words = firstWordRegExp.exec(lineSuffix);
return words !== null;
};
/**
* A mapping of syntax type to speech properties.
*/
var rules = {
'constant': CONSTANT_PROP,
'entity': ENTITY_PROP,
'keyword': KEYWORD_PROP,
'storage': STORAGE_PROP,
'variable': VARIABLE_PROP
};
/**
* Speak the line with syntax properties.
* @param {number} row Row to speak.
* @param {number} queue Queue mode to speak.
*/
var speakLine = function(row, queue) {
var tokens = cvoxAce.editor.getSession().getTokens(row);
if (tokens.length === 0) {
return;
}
var firstToken = tokens[0];
/* Filter out first token. */
tokens = tokens.filter(function(token) {
return token !== firstToken;
});
/* Speak first token separately to flush if queue. */
speakToken_(firstToken, queue);
/* Speak rest of tokens. */
tokens.forEach(speakTokenQueue);
};
/**
* Speak the token based on the syntax of the token, flushing.
* @param {!cvoxAce.Token} token Token to speak.
* @param {number} queue Queue mode.
*/
var speakTokenFlush = function(token) {
speakToken_(token, 0);
};
/**
* Speak the token based on the syntax of the token, queueing.
* @param {!cvoxAce.Token} token Token to speak.
* @param {number} queue Queue mode.
*/
var speakTokenQueue = function(token) {
speakToken_(token, 1);
};
/**
* Speak the token based on the syntax of the token.
* @private
* @param {!cvoxAce.Token} token Token to speak.
* @param {number} queue Queue mode.
*/
var speakToken_ = function(token, queue) {
/* Types are period delimited. In this case, we only syntax speak the outer
* most type of token. */
if (!token || !token.type) {
return;
}
var split = token.type.split('.');
if (split.length === 0) {
return;
}
var type = split[0];
var prop = rules[type];
if (!prop) {
prop = DEFAULT_PROP;
}
cvox.Api.speak(token.value, queue, prop);
};
/**
* Speaks the character under the cursor. This is queued.
* @param {!cvoxAce.Cursor} cursor Current cursor position.
* @return {string} Character.
*/
var speakChar = function(cursor) {
var line = getCurrentLine(cursor);
cvox.Api.speak(line[cursor.column], 1);
};
/**
* Speaks the jump from lastCursor to currCursor. This function assumes the
* jump takes place on the current line.
* @param {!cvoxAce.Cursor} lastCursor Previous cursor position.
* @param {!cvoxAce.Cursor} currCursor Current cursor position.
*/
var speakDisplacement = function(lastCursor, currCursor) {
cvox.Api.stop();
var line = getCurrentLine(currCursor);
/* Get the text that we jumped past. */
var displace = line.substring(lastCursor.column, currCursor.column);
/* When going forward one space, we speak where we land instead. */
if (currCursor.column - lastCursor.column === 1) {
displace = line.substring(lastCursor.column + 1, currCursor.column + 1);
}
/* Speak out loud spaces. */
displace = displace.replace(/ /g, ' space ');
cvox.Api.speak(displace, 1);
};
/**
* Speaks the word if the cursor jumped to a new word or to the beginning
* of the line. Otherwise speak the charactor.
* @param {!cvoxAce.Cursor} lastCursor Previous cursor position.
* @param {!cvoxAce.Cursor} currCursor Current cursor position.
*/
var speakCharOrWordOrLine = function(lastCursor, currCursor) {
/* Say word only if jump. */
if (Math.abs(lastCursor.column - currCursor.column) !== 1) {
var currLineLength = getCurrentLine(currCursor).length;
/* Speak line if jumping to beginning or end of line. */
if (currCursor.column === 0 || currCursor.column === currLineLength) {
speakLine(currCursor.row, 0);
return;
}
if (isWord(currCursor)) {
cvox.Api.stop();
speakTokenQueue(getCurrentToken(currCursor));
return;
}
}
speakChar(currCursor);
};
/**
* Event handler for column changes. If shouldSpeakDisplacement is on, then
* we just speak displacements in row changes. Otherwise, we either speak
* the character for single character movements, the word when jumping to the
* next word, or the entire line if jumping to beginning or end of the line.
* @param {!cvoxAce.Cursor} lastCursor Previous cursor position.
* @param {!cvoxAce.Cursor} currCursor Current cursor position.
*/
var onColumnChange = function(lastCursor, currCursor) {
if (shouldSpeakDisplacement) {
speakDisplacement(lastCursor, currCursor);
} else {
speakCharOrWordOrLine(lastCursor, currCursor);
}
};
/**
* Event handler for cursor changes. Classify cursor changes as either row or
* column changes, then delegate accordingly.
* @param {!Event} evt The event.
*/
var onCursorChange = function(evt) {
/* Do not speak if cursor change was a result of text insertion. We want to
* speak the text that was inserted and not where the cursor lands. */
if (changed) {
changed = false;
return;
}
var currCursor = cvoxAce.editor.selection.getCursor();
if (currCursor.row !== lastCursor.row) {
onRowChange(currCursor);
} else {
onColumnChange(lastCursor, currCursor);
}
lastCursor = currCursor;
};
/**
* Event handler for source changes. We want auditory feedback for inserting
* and deleting text.
* @param {!Event} evt The event.
*/
var onChange = function(evt) {
var data = evt.data;
switch (data.action) {
case 'removeText':
cvox.Api.speak(data.text, 0, DELETED_PROP);
/* Let the future cursor change event know it's from text change. */
changed = true;
break;
case 'insertText':
cvox.Api.speak(data.text, 0);
/* Let the future cursor change event know it's from text change. */
changed = true;
break;
}
};
/**
* Returns whether or not the annotation is new.
* @param {!cvoxAce.Annotation} annot Annotation in question.
* @return {boolean} Whether annot is new.
*/
var isNewAnnotation = function(annot) {
var row = annot.row;
var col = annot.column;
return !annotTable[row] || !annotTable[row][col];
};
/**
* Populates the annotation table.
* @param {!Array.<cvoxAce.Annotation>} annotations Array of annotations.
*/
var populateAnnotations = function(annotations) {
annotTable = {};
for (var i = 0; i < annotations.length; i++) {
var annotation = annotations[i];
var row = annotation.row;
var col = annotation.column;
if (!annotTable[row]) {
annotTable[row] = {};
}
annotTable[row][col] = annotation;
}
};
/**
* Event handler for annotation changes. We want to notify the user when an
* a new annotation appears.
* @param {!Event} evt Event.
*/
var onAnnotationChange = function(evt) {
var annotations = cvoxAce.editor.getSession().getAnnotations();
var newAnnotations = annotations.filter(isNewAnnotation);
if (newAnnotations.length > 0) {
cvox.Api.playEarcon(ERROR_EARCON);
}
populateAnnotations(annotations);
};
/**
* Speak annotation.
* @param {!cvoxAce.Annotation} annot Annotation to speak.
*/
var speakAnnot = function(annot) {
var annotText = annot.type + ' ' + annot.text + ' on ' +
rowColToString(annot.row, annot.column);
annotText = annotText.replace(';', 'semicolon');
cvox.Api.speak(annotText, 1);
};
/**
* Speak annotations in a row.
* @param {number} row Row of annotations to speak.
*/
var speakAnnotsByRow = function(row) {
var annots = annotTable[row];
for (var col in annots) {
speakAnnot(annots[col]);
}
};
/**
* Get a string representation of a row and column.
* @param {boolean} row Zero indexed row.
* @param {boolean} col Zero indexed column.
* @return {string} Row and column to be spoken.
*/
var rowColToString = function(row, col) {
return 'row ' + (row + 1) + ' column ' + (col + 1);
};
/**
* Speaks the row and column.
*/
var speakCurrRowAndCol = function() {
cvox.Api.speak(rowColToString(lastCursor.row, lastCursor.column));
};
/**
* Speaks all annotations.
*/
var speakAllAnnots = function() {
for (var row in annotTable) {
speakAnnotsByRow(row);
}
};
/**
* Speak the vim mode. If no vim mode, this function does nothing.
*/
var speakMode = function() {
if (!isVimMode()) {
return;
}
switch (cvoxAce.editor.keyBinding.$data.state) {
case INSERT_MODE_STATE:
cvox.Api.speak('Insert mode');
break;
case COMMAND_MODE_STATE:
cvox.Api.speak('Command mode');
break;
}
};
/**
* Toggle speak location.
*/
var toggleSpeakRowLocation = function() {
shouldSpeakRowLocation = !shouldSpeakRowLocation;
/* Auditory feedback of the change. */
if (shouldSpeakRowLocation) {
cvox.Api.speak('Speak location on row change enabled.');
} else {
cvox.Api.speak('Speak location on row change disabled.');
}
};
/**
* Toggle speak displacement.
*/
var toggleSpeakDisplacement = function() {
speakDisplacement = !speakDisplacement;
/* Auditory feedback of the change. */
if (speakDisplacement) {
cvox.Api.speak('Speak displacement on column changes.');
} else {
cvox.Api.speak('Speak current character or word on column changes.');
}
};
/**
* Event handler for key down events. Gets the right shortcut from the map,
* and calls the associated function.
* @param {!Event} evt Keyboard event.
*/
var onKeyDown = function(evt) {
if (evt.ctrlKey && evt.shiftKey) {
var shortcut = keyCodeToShortcutMap[evt.keyCode];
if (shortcut) {
shortcut.func();
}
}
};
/**
* Event handler for status change events. Auditory feedback of changing
* between vim states.
* @param {!Event} evt Change status event.
* @param {!Object} editor Editor state.
*/
var onChangeStatus = function(evt, editor) {
if (!isVimMode()) {
return;
}
var state = editor.keyBinding.$data.state;
if (state === vimState) {
/* State hasn't changed, do nothing. */
return;
}
switch (state) {
case INSERT_MODE_STATE:
cvox.Api.playEarcon(MODE_SWITCH_EARCON);
/* When in insert mode, we want to speak out keys as feedback. */
cvox.Api.setKeyEcho(true);
break;
case COMMAND_MODE_STATE:
cvox.Api.playEarcon(MODE_SWITCH_EARCON);
/* When in command mode, we want don't speak out keys because those keys
* are not being inserted in the document. */
cvox.Api.setKeyEcho(false);
break;
}
vimState = state;
};
/**
* Handles context menu events. This is a ChromeVox feature where hitting
* the shortcut ChromeVox + comma will open up a search bar where you can
* type in various commands. All keyboard shortcuts are also commands that
* can be invoked. This handles the event that ChromeVox sends to the page.
* @param {Event} evt Event received.
*/
var contextMenuHandler = function(evt) {
var cmd = evt.detail['customCommand'];
var shortcut = cmdToShortcutMap[cmd];
if (shortcut) {
shortcut.func();
/* ChromeVox will bring focus to an element near the cursor instead of the
* text input. */
cvoxAce.editor.focus();
}
};
/**
* Initialize the ChromeVox context menu.
*/
var initContextMenu = function() {
var ACTIONS = SHORTCUTS.map(function(shortcut) {
return {
desc: shortcut.desc + getKeyShortcutString(shortcut.keyCode),
cmd: shortcut.cmd
};
});
/* Attach ContextMenuActions. */
var body = document.querySelector('body');
body.setAttribute('contextMenuActions', JSON.stringify(ACTIONS));
/* Listen for ContextMenu events. */
body.addEventListener('ATCustomEvent', contextMenuHandler, true);
};
/**
* Event handler for find events. When there is a match, we want to speak the
* line we are now at. Otherwise, we want to notify the user there was no
* match
* @param {!Event} evt The event.
*/
var onFindSearchbox = function(evt) {
if (evt.match) {
/* There is still a match! Speak the line. */
speakLine(lastCursor.row, 0);
} else {
/* No match, give auditory feedback! */
cvox.Api.playEarcon(NO_MATCH_EARCON);
}
};
/**
* Focus to text input.
*/
var focus = function() {
cvoxAce.editor.focus();
};
/**
* Shortcut definitions.
*/
var SHORTCUTS = [
{
/* 1 key. */
keyCode: 49,
func: function() {
speakAnnotsByRow(lastCursor.row);
},
cmd: Command.SPEAK_ANNOT,
desc: 'Speak annotations on line'
},
{
/* 2 key. */
keyCode: 50,
func: speakAllAnnots,
cmd: Command.SPEAK_ALL_ANNOTS,
desc: 'Speak all annotations'
},
{
/* 3 key. */
keyCode: 51,
func: speakMode,
cmd: Command.SPEAK_MODE,
desc: 'Speak Vim mode'
},
{
/* 4 key. */
keyCode: 52,
func: toggleSpeakRowLocation,
cmd: Command.TOGGLE_LOCATION,
desc: 'Toggle speak row location'
},
{
/* 5 key. */
keyCode: 53,
func: speakCurrRowAndCol,
cmd: Command.SPEAK_ROW_COL,
desc: 'Speak row and column'
},
{
/* 6 key. */
keyCode: 54,
func: toggleSpeakDisplacement,
cmd: Command.TOGGLE_DISPLACEMENT,
desc: 'Toggle speak displacement'
},
{
/* 7 key. */
keyCode: 55,
func: focus,
cmd: Command.FOCUS_TEXT,
desc: 'Focus text'
}
];
/**
* Initialize the theme.
* @param {Object} editor Editor to use.
*/
var init = function(editor) {
cvoxAce.editor = editor;
lastCursor = editor.selection.getCursor();
/* Construct maps. */
SHORTCUTS.forEach(function(shortcut) {
keyCodeToShortcutMap[shortcut.keyCode] = shortcut;
cmdToShortcutMap[shortcut.cmd] = shortcut;
});
/* Set up listeners. */
cvoxAce.editor.getSession().selection.on('changeCursor', onCursorChange);
cvoxAce.editor.getSession().on('change', onChange);
cvoxAce.editor.getSession().on('changeAnnotation', onAnnotationChange);
cvoxAce.editor.on('changeStatus', onChangeStatus);
cvoxAce.editor.on('findSearchBox', onFindSearchbox);
window.addEventListener('keydown', onKeyDown);
/* Assume we start in command mode if vim. */
if (isVimMode()) {
cvox.Api.setKeyEcho(false);
}
initContextMenu();
cvoxAce.editor.focus();
};
/**
* Returns if cvox exists, and the api exists.
* @return {boolean} Whether not Cvox Api exists.
*/
function cvoxApiExists() {
return (typeof(cvox) !== 'undefined') && cvox && cvox.Api;
}
/**
* Number of tries for Cvox loading.
* @type {number}
*/
var tries = 0;
/**
* Max number of tries to watch for Cvox loading.
* @type {number}
*/
var MAX_TRIES = 15;
/**
* Check for ChromeVox load.
* @param {Object} editor Editor to use.
*/
function watchForCvoxLoad(editor) {
if (cvoxApiExists()) {
init(editor);
} else {
tries++;
if (tries >= MAX_TRIES) {
return;
}
window.setTimeout(watchForCvoxLoad, 500, editor);
}
}
var Editor = require('../editor').Editor;
require('../config').defineOptions(Editor.prototype, 'editor', {
enableChromevoxEnhancements: {
set: function(val) {
if (val) {
watchForCvoxLoad(this);
}
},
value: true // turn it on by default or check for window.cvox
}
});
});