ext-chromevox.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. ace.define("ace/ext/chromevox",["require","exports","module","ace/editor","ace/config"], function(require, exports, module) {
  2. var cvoxAce = {};
  3. cvoxAce.SpeechProperty;
  4. cvoxAce.Cursor;
  5. cvoxAce.Token;
  6. cvoxAce.Annotation;
  7. var CONSTANT_PROP = {
  8. 'rate': 0.8,
  9. 'pitch': 0.4,
  10. 'volume': 0.9
  11. };
  12. var DEFAULT_PROP = {
  13. 'rate': 1,
  14. 'pitch': 0.5,
  15. 'volume': 0.9
  16. };
  17. var ENTITY_PROP = {
  18. 'rate': 0.8,
  19. 'pitch': 0.8,
  20. 'volume': 0.9
  21. };
  22. var KEYWORD_PROP = {
  23. 'rate': 0.8,
  24. 'pitch': 0.3,
  25. 'volume': 0.9
  26. };
  27. var STORAGE_PROP = {
  28. 'rate': 0.8,
  29. 'pitch': 0.7,
  30. 'volume': 0.9
  31. };
  32. var VARIABLE_PROP = {
  33. 'rate': 0.8,
  34. 'pitch': 0.8,
  35. 'volume': 0.9
  36. };
  37. var DELETED_PROP = {
  38. 'punctuationEcho': 'none',
  39. 'relativePitch': -0.6
  40. };
  41. var ERROR_EARCON = 'ALERT_NONMODAL';
  42. var MODE_SWITCH_EARCON = 'ALERT_MODAL';
  43. var NO_MATCH_EARCON = 'INVALID_KEYPRESS';
  44. var INSERT_MODE_STATE = 'insertMode';
  45. var COMMAND_MODE_STATE = 'start';
  46. var REPLACE_LIST = [
  47. {
  48. substr: ';',
  49. newSubstr: ' semicolon '
  50. },
  51. {
  52. substr: ':',
  53. newSubstr: ' colon '
  54. }
  55. ];
  56. var Command = {
  57. SPEAK_ANNOT: 'annots',
  58. SPEAK_ALL_ANNOTS: 'all_annots',
  59. TOGGLE_LOCATION: 'toggle_location',
  60. SPEAK_MODE: 'mode',
  61. SPEAK_ROW_COL: 'row_col',
  62. TOGGLE_DISPLACEMENT: 'toggle_displacement',
  63. FOCUS_TEXT: 'focus_text'
  64. };
  65. var KEY_PREFIX = 'CONTROL + SHIFT ';
  66. cvoxAce.editor = null;
  67. var lastCursor = null;
  68. var annotTable = {};
  69. var shouldSpeakRowLocation = false;
  70. var shouldSpeakDisplacement = false;
  71. var changed = false;
  72. var vimState = null;
  73. var keyCodeToShortcutMap = {};
  74. var cmdToShortcutMap = {};
  75. var getKeyShortcutString = function(keyCode) {
  76. return KEY_PREFIX + String.fromCharCode(keyCode);
  77. };
  78. var isVimMode = function() {
  79. var keyboardHandler = cvoxAce.editor.keyBinding.getKeyboardHandler();
  80. return keyboardHandler.$id === 'ace/keyboard/vim';
  81. };
  82. var getCurrentToken = function(cursor) {
  83. return cvoxAce.editor.getSession().getTokenAt(cursor.row, cursor.column + 1);
  84. };
  85. var getCurrentLine = function(cursor) {
  86. return cvoxAce.editor.getSession().getLine(cursor.row);
  87. };
  88. var onRowChange = function(currCursor) {
  89. if (annotTable[currCursor.row]) {
  90. cvox.Api.playEarcon(ERROR_EARCON);
  91. }
  92. if (shouldSpeakRowLocation) {
  93. cvox.Api.stop();
  94. speakChar(currCursor);
  95. speakTokenQueue(getCurrentToken(currCursor));
  96. speakLine(currCursor.row, 1);
  97. } else {
  98. speakLine(currCursor.row, 0);
  99. }
  100. };
  101. var isWord = function(cursor) {
  102. var line = getCurrentLine(cursor);
  103. var lineSuffix = line.substr(cursor.column - 1);
  104. if (cursor.column === 0) {
  105. lineSuffix = ' ' + line;
  106. }
  107. var firstWordRegExp = /^\W(\w+)/;
  108. var words = firstWordRegExp.exec(lineSuffix);
  109. return words !== null;
  110. };
  111. var rules = {
  112. 'constant': {
  113. prop: CONSTANT_PROP
  114. },
  115. 'entity': {
  116. prop: ENTITY_PROP
  117. },
  118. 'keyword': {
  119. prop: KEYWORD_PROP
  120. },
  121. 'storage': {
  122. prop: STORAGE_PROP
  123. },
  124. 'variable': {
  125. prop: VARIABLE_PROP
  126. },
  127. 'meta': {
  128. prop: DEFAULT_PROP,
  129. replace: [
  130. {
  131. substr: '</',
  132. newSubstr: ' closing tag '
  133. },
  134. {
  135. substr: '/>',
  136. newSubstr: ' close tag '
  137. },
  138. {
  139. substr: '<',
  140. newSubstr: ' tag start '
  141. },
  142. {
  143. substr: '>',
  144. newSubstr: ' tag end '
  145. }
  146. ]
  147. }
  148. };
  149. var DEFAULT_RULE = {
  150. prop: DEFAULT_RULE
  151. };
  152. var expand = function(value, replaceRules) {
  153. var newValue = value;
  154. for (var i = 0; i < replaceRules.length; i++) {
  155. var replaceRule = replaceRules[i];
  156. var regexp = new RegExp(replaceRule.substr, 'g');
  157. newValue = newValue.replace(regexp, replaceRule.newSubstr);
  158. }
  159. return newValue;
  160. };
  161. var mergeTokens = function(tokens, start, end) {
  162. var newToken = {};
  163. newToken.value = '';
  164. newToken.type = tokens[start].type;
  165. for (var j = start; j < end; j++) {
  166. newToken.value += tokens[j].value;
  167. }
  168. return newToken;
  169. };
  170. var mergeLikeTokens = function(tokens) {
  171. if (tokens.length <= 1) {
  172. return tokens;
  173. }
  174. var newTokens = [];
  175. var lastLikeIndex = 0;
  176. for (var i = 1; i < tokens.length; i++) {
  177. var lastLikeToken = tokens[lastLikeIndex];
  178. var currToken = tokens[i];
  179. if (getTokenRule(lastLikeToken) !== getTokenRule(currToken)) {
  180. newTokens.push(mergeTokens(tokens, lastLikeIndex, i));
  181. lastLikeIndex = i;
  182. }
  183. }
  184. newTokens.push(mergeTokens(tokens, lastLikeIndex, tokens.length));
  185. return newTokens;
  186. };
  187. var isRowWhiteSpace = function(row) {
  188. var line = cvoxAce.editor.getSession().getLine(row);
  189. var whiteSpaceRegexp = /^\s*$/;
  190. return whiteSpaceRegexp.exec(line) !== null;
  191. };
  192. var speakLine = function(row, queue) {
  193. var tokens = cvoxAce.editor.getSession().getTokens(row);
  194. if (tokens.length === 0 || isRowWhiteSpace(row)) {
  195. cvox.Api.playEarcon('EDITABLE_TEXT');
  196. return;
  197. }
  198. tokens = mergeLikeTokens(tokens);
  199. var firstToken = tokens[0];
  200. tokens = tokens.filter(function(token) {
  201. return token !== firstToken;
  202. });
  203. speakToken_(firstToken, queue);
  204. tokens.forEach(speakTokenQueue);
  205. };
  206. var speakTokenFlush = function(token) {
  207. speakToken_(token, 0);
  208. };
  209. var speakTokenQueue = function(token) {
  210. speakToken_(token, 1);
  211. };
  212. var getTokenRule = function(token) {
  213. if (!token || !token.type) {
  214. return;
  215. }
  216. var split = token.type.split('.');
  217. if (split.length === 0) {
  218. return;
  219. }
  220. var type = split[0];
  221. var rule = rules[type];
  222. if (!rule) {
  223. return DEFAULT_RULE;
  224. }
  225. return rule;
  226. };
  227. var speakToken_ = function(token, queue) {
  228. var rule = getTokenRule(token);
  229. var value = expand(token.value, REPLACE_LIST);
  230. if (rule.replace) {
  231. value = expand(value, rule.replace);
  232. }
  233. cvox.Api.speak(value, queue, rule.prop);
  234. };
  235. var speakChar = function(cursor) {
  236. var line = getCurrentLine(cursor);
  237. cvox.Api.speak(line[cursor.column], 1);
  238. };
  239. var speakDisplacement = function(lastCursor, currCursor) {
  240. var line = getCurrentLine(currCursor);
  241. var displace = line.substring(lastCursor.column, currCursor.column);
  242. displace = displace.replace(/ /g, ' space ');
  243. cvox.Api.speak(displace);
  244. };
  245. var speakCharOrWordOrLine = function(lastCursor, currCursor) {
  246. if (Math.abs(lastCursor.column - currCursor.column) !== 1) {
  247. var currLineLength = getCurrentLine(currCursor).length;
  248. if (currCursor.column === 0 || currCursor.column === currLineLength) {
  249. speakLine(currCursor.row, 0);
  250. return;
  251. }
  252. if (isWord(currCursor)) {
  253. cvox.Api.stop();
  254. speakTokenQueue(getCurrentToken(currCursor));
  255. return;
  256. }
  257. }
  258. speakChar(currCursor);
  259. };
  260. var onColumnChange = function(lastCursor, currCursor) {
  261. if (!cvoxAce.editor.selection.isEmpty()) {
  262. speakDisplacement(lastCursor, currCursor);
  263. cvox.Api.speak('selected', 1);
  264. }
  265. else if (shouldSpeakDisplacement) {
  266. speakDisplacement(lastCursor, currCursor);
  267. } else {
  268. speakCharOrWordOrLine(lastCursor, currCursor);
  269. }
  270. };
  271. var onCursorChange = function(evt) {
  272. if (changed) {
  273. changed = false;
  274. return;
  275. }
  276. var currCursor = cvoxAce.editor.selection.getCursor();
  277. if (currCursor.row !== lastCursor.row) {
  278. onRowChange(currCursor);
  279. } else {
  280. onColumnChange(lastCursor, currCursor);
  281. }
  282. lastCursor = currCursor;
  283. };
  284. var onSelectionChange = function(evt) {
  285. if (cvoxAce.editor.selection.isEmpty()) {
  286. cvox.Api.speak('unselected');
  287. }
  288. };
  289. var onChange = function(delta) {
  290. switch (delta.action) {
  291. case 'remove':
  292. cvox.Api.speak(delta.text, 0, DELETED_PROP);
  293. changed = true;
  294. break;
  295. case 'insert':
  296. cvox.Api.speak(delta.text, 0);
  297. changed = true;
  298. break;
  299. }
  300. };
  301. var isNewAnnotation = function(annot) {
  302. var row = annot.row;
  303. var col = annot.column;
  304. return !annotTable[row] || !annotTable[row][col];
  305. };
  306. var populateAnnotations = function(annotations) {
  307. annotTable = {};
  308. for (var i = 0; i < annotations.length; i++) {
  309. var annotation = annotations[i];
  310. var row = annotation.row;
  311. var col = annotation.column;
  312. if (!annotTable[row]) {
  313. annotTable[row] = {};
  314. }
  315. annotTable[row][col] = annotation;
  316. }
  317. };
  318. var onAnnotationChange = function(evt) {
  319. var annotations = cvoxAce.editor.getSession().getAnnotations();
  320. var newAnnotations = annotations.filter(isNewAnnotation);
  321. if (newAnnotations.length > 0) {
  322. cvox.Api.playEarcon(ERROR_EARCON);
  323. }
  324. populateAnnotations(annotations);
  325. };
  326. var speakAnnot = function(annot) {
  327. var annotText = annot.type + ' ' + annot.text + ' on ' +
  328. rowColToString(annot.row, annot.column);
  329. annotText = annotText.replace(';', 'semicolon');
  330. cvox.Api.speak(annotText, 1);
  331. };
  332. var speakAnnotsByRow = function(row) {
  333. var annots = annotTable[row];
  334. for (var col in annots) {
  335. speakAnnot(annots[col]);
  336. }
  337. };
  338. var rowColToString = function(row, col) {
  339. return 'row ' + (row + 1) + ' column ' + (col + 1);
  340. };
  341. var speakCurrRowAndCol = function() {
  342. cvox.Api.speak(rowColToString(lastCursor.row, lastCursor.column));
  343. };
  344. var speakAllAnnots = function() {
  345. for (var row in annotTable) {
  346. speakAnnotsByRow(row);
  347. }
  348. };
  349. var speakMode = function() {
  350. if (!isVimMode()) {
  351. return;
  352. }
  353. switch (cvoxAce.editor.keyBinding.$data.state) {
  354. case INSERT_MODE_STATE:
  355. cvox.Api.speak('Insert mode');
  356. break;
  357. case COMMAND_MODE_STATE:
  358. cvox.Api.speak('Command mode');
  359. break;
  360. }
  361. };
  362. var toggleSpeakRowLocation = function() {
  363. shouldSpeakRowLocation = !shouldSpeakRowLocation;
  364. if (shouldSpeakRowLocation) {
  365. cvox.Api.speak('Speak location on row change enabled.');
  366. } else {
  367. cvox.Api.speak('Speak location on row change disabled.');
  368. }
  369. };
  370. var toggleSpeakDisplacement = function() {
  371. shouldSpeakDisplacement = !shouldSpeakDisplacement;
  372. if (shouldSpeakDisplacement) {
  373. cvox.Api.speak('Speak displacement on column changes.');
  374. } else {
  375. cvox.Api.speak('Speak current character or word on column changes.');
  376. }
  377. };
  378. var onKeyDown = function(evt) {
  379. if (evt.ctrlKey && evt.shiftKey) {
  380. var shortcut = keyCodeToShortcutMap[evt.keyCode];
  381. if (shortcut) {
  382. shortcut.func();
  383. }
  384. }
  385. };
  386. var onChangeStatus = function(evt, editor) {
  387. if (!isVimMode()) {
  388. return;
  389. }
  390. var state = editor.keyBinding.$data.state;
  391. if (state === vimState) {
  392. return;
  393. }
  394. switch (state) {
  395. case INSERT_MODE_STATE:
  396. cvox.Api.playEarcon(MODE_SWITCH_EARCON);
  397. cvox.Api.setKeyEcho(true);
  398. break;
  399. case COMMAND_MODE_STATE:
  400. cvox.Api.playEarcon(MODE_SWITCH_EARCON);
  401. cvox.Api.setKeyEcho(false);
  402. break;
  403. }
  404. vimState = state;
  405. };
  406. var contextMenuHandler = function(evt) {
  407. var cmd = evt.detail['customCommand'];
  408. var shortcut = cmdToShortcutMap[cmd];
  409. if (shortcut) {
  410. shortcut.func();
  411. cvoxAce.editor.focus();
  412. }
  413. };
  414. var initContextMenu = function() {
  415. var ACTIONS = SHORTCUTS.map(function(shortcut) {
  416. return {
  417. desc: shortcut.desc + getKeyShortcutString(shortcut.keyCode),
  418. cmd: shortcut.cmd
  419. };
  420. });
  421. var body = document.querySelector('body');
  422. body.setAttribute('contextMenuActions', JSON.stringify(ACTIONS));
  423. body.addEventListener('ATCustomEvent', contextMenuHandler, true);
  424. };
  425. var onFindSearchbox = function(evt) {
  426. if (evt.match) {
  427. speakLine(lastCursor.row, 0);
  428. } else {
  429. cvox.Api.playEarcon(NO_MATCH_EARCON);
  430. }
  431. };
  432. var focus = function() {
  433. cvoxAce.editor.focus();
  434. };
  435. var SHORTCUTS = [
  436. {
  437. keyCode: 49,
  438. func: function() {
  439. speakAnnotsByRow(lastCursor.row);
  440. },
  441. cmd: Command.SPEAK_ANNOT,
  442. desc: 'Speak annotations on line'
  443. },
  444. {
  445. keyCode: 50,
  446. func: speakAllAnnots,
  447. cmd: Command.SPEAK_ALL_ANNOTS,
  448. desc: 'Speak all annotations'
  449. },
  450. {
  451. keyCode: 51,
  452. func: speakMode,
  453. cmd: Command.SPEAK_MODE,
  454. desc: 'Speak Vim mode'
  455. },
  456. {
  457. keyCode: 52,
  458. func: toggleSpeakRowLocation,
  459. cmd: Command.TOGGLE_LOCATION,
  460. desc: 'Toggle speak row location'
  461. },
  462. {
  463. keyCode: 53,
  464. func: speakCurrRowAndCol,
  465. cmd: Command.SPEAK_ROW_COL,
  466. desc: 'Speak row and column'
  467. },
  468. {
  469. keyCode: 54,
  470. func: toggleSpeakDisplacement,
  471. cmd: Command.TOGGLE_DISPLACEMENT,
  472. desc: 'Toggle speak displacement'
  473. },
  474. {
  475. keyCode: 55,
  476. func: focus,
  477. cmd: Command.FOCUS_TEXT,
  478. desc: 'Focus text'
  479. }
  480. ];
  481. var onFocus = function(_, editor) {
  482. cvoxAce.editor = editor;
  483. editor.getSession().selection.on('changeCursor', onCursorChange);
  484. editor.getSession().selection.on('changeSelection', onSelectionChange);
  485. editor.getSession().on('change', onChange);
  486. editor.getSession().on('changeAnnotation', onAnnotationChange);
  487. editor.on('changeStatus', onChangeStatus);
  488. editor.on('findSearchBox', onFindSearchbox);
  489. editor.container.addEventListener('keydown', onKeyDown);
  490. lastCursor = editor.selection.getCursor();
  491. };
  492. var init = function(editor) {
  493. onFocus(null, editor);
  494. SHORTCUTS.forEach(function(shortcut) {
  495. keyCodeToShortcutMap[shortcut.keyCode] = shortcut;
  496. cmdToShortcutMap[shortcut.cmd] = shortcut;
  497. });
  498. editor.on('focus', onFocus);
  499. if (isVimMode()) {
  500. cvox.Api.setKeyEcho(false);
  501. }
  502. initContextMenu();
  503. };
  504. function cvoxApiExists() {
  505. return (typeof(cvox) !== 'undefined') && cvox && cvox.Api;
  506. }
  507. var tries = 0;
  508. var MAX_TRIES = 15;
  509. function watchForCvoxLoad(editor) {
  510. if (cvoxApiExists()) {
  511. init(editor);
  512. } else {
  513. tries++;
  514. if (tries >= MAX_TRIES) {
  515. return;
  516. }
  517. window.setTimeout(watchForCvoxLoad, 500, editor);
  518. }
  519. }
  520. var Editor = require('../editor').Editor;
  521. require('../config').defineOptions(Editor.prototype, 'editor', {
  522. enableChromevoxEnhancements: {
  523. set: function(val) {
  524. if (val) {
  525. watchForCvoxLoad(this);
  526. }
  527. },
  528. value: true // turn it on by default or check for window.cvox
  529. }
  530. });
  531. });
  532. (function() {
  533. ace.require(["ace/ext/chromevox"], function() {});
  534. })();