plaintextspellchecker.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. // Copyright 2007 The Closure Library Authors. All Rights Reserved.
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS-IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. /**
  15. * @fileoverview Plain text spell checker implementation.
  16. *
  17. * @author eae@google.com (Emil A Eklund)
  18. * @see ../demos/plaintextspellchecker.html
  19. */
  20. goog.provide('goog.ui.PlainTextSpellChecker');
  21. goog.require('goog.Timer');
  22. goog.require('goog.a11y.aria');
  23. goog.require('goog.asserts');
  24. goog.require('goog.dom');
  25. goog.require('goog.dom.TagName');
  26. goog.require('goog.events.EventHandler');
  27. goog.require('goog.events.EventType');
  28. goog.require('goog.events.KeyCodes');
  29. goog.require('goog.events.KeyHandler');
  30. goog.require('goog.spell.SpellCheck');
  31. goog.require('goog.style');
  32. goog.require('goog.ui.AbstractSpellChecker');
  33. goog.require('goog.ui.Component');
  34. goog.require('goog.userAgent');
  35. /**
  36. * Plain text spell checker implementation.
  37. *
  38. * @param {goog.spell.SpellCheck} handler Instance of the SpellCheckHandler
  39. * support object to use. A single instance can be shared by multiple
  40. * editor components.
  41. * @param {goog.dom.DomHelper=} opt_domHelper Optional DOM helper.
  42. * @constructor
  43. * @extends {goog.ui.AbstractSpellChecker}
  44. * @final
  45. */
  46. goog.ui.PlainTextSpellChecker = function(handler, opt_domHelper) {
  47. goog.ui.AbstractSpellChecker.call(this, handler, opt_domHelper);
  48. /**
  49. * Correction UI container.
  50. * @private {!HTMLDivElement}
  51. */
  52. this.overlay_ = this.getDomHelper().createDom(goog.dom.TagName.DIV);
  53. goog.style.setPreWrap(this.overlay_);
  54. /**
  55. * Bound async function (to avoid rebinding it on every call).
  56. * @type {Function}
  57. * @private
  58. */
  59. this.boundContinueAsyncFn_ = goog.bind(this.continueAsync_, this);
  60. /**
  61. * Regular expression for matching line breaks.
  62. * @type {RegExp}
  63. * @private
  64. */
  65. this.endOfLineMatcher_ = new RegExp('(.*)(\n|\r\n){0,1}', 'g');
  66. };
  67. goog.inherits(goog.ui.PlainTextSpellChecker, goog.ui.AbstractSpellChecker);
  68. /**
  69. * Class name for invalid words.
  70. * @type {string}
  71. */
  72. goog.ui.PlainTextSpellChecker.prototype.invalidWordClassName =
  73. goog.getCssName('goog-spellcheck-invalidword');
  74. /**
  75. * Class name for corrected words.
  76. * @type {string}
  77. */
  78. goog.ui.PlainTextSpellChecker.prototype.correctedWordClassName =
  79. goog.getCssName('goog-spellcheck-correctedword');
  80. /**
  81. * Class name for correction pane.
  82. * @type {string}
  83. */
  84. goog.ui.PlainTextSpellChecker.prototype.correctionPaneClassName =
  85. goog.getCssName('goog-spellcheck-correctionpane');
  86. /**
  87. * Number of words to scan to precharge the dictionary.
  88. * @type {number}
  89. * @private
  90. */
  91. goog.ui.PlainTextSpellChecker.prototype.dictionaryPreScanSize_ = 1000;
  92. /**
  93. * Size of window. Used to check if a resize operation actually changed the size
  94. * of the window.
  95. * @type {goog.math.Size|undefined}
  96. * @private
  97. */
  98. goog.ui.PlainTextSpellChecker.prototype.winSize_;
  99. /**
  100. * Event handler for listening to events without leaking.
  101. * @type {goog.events.EventHandler|undefined}
  102. * @private
  103. */
  104. goog.ui.PlainTextSpellChecker.prototype.eventHandler_;
  105. /**
  106. * The object handling keyboard events.
  107. * @type {goog.events.KeyHandler|undefined}
  108. * @private
  109. */
  110. goog.ui.PlainTextSpellChecker.prototype.keyHandler_;
  111. /** @private {number} */
  112. goog.ui.PlainTextSpellChecker.prototype.textArrayIndex_;
  113. /** @private {!Array<string>} */
  114. goog.ui.PlainTextSpellChecker.prototype.textArray_;
  115. /** @private {!Array<boolean>} */
  116. goog.ui.PlainTextSpellChecker.prototype.textArrayProcess_;
  117. /**
  118. * Creates the initial DOM representation for the component.
  119. * @override
  120. */
  121. goog.ui.PlainTextSpellChecker.prototype.createDom = function() {
  122. this.setElementInternal(
  123. this.getDomHelper().createElement(goog.dom.TagName.TEXTAREA));
  124. };
  125. /** @override */
  126. goog.ui.PlainTextSpellChecker.prototype.enterDocument = function() {
  127. goog.ui.PlainTextSpellChecker.superClass_.enterDocument.call(this);
  128. this.eventHandler_ = new goog.events.EventHandler(this);
  129. this.keyHandler_ = new goog.events.KeyHandler(this.overlay_);
  130. this.initSuggestionsMenu();
  131. this.initAccessibility_();
  132. };
  133. /** @override */
  134. goog.ui.PlainTextSpellChecker.prototype.exitDocument = function() {
  135. goog.ui.PlainTextSpellChecker.superClass_.exitDocument.call(this);
  136. if (this.eventHandler_) {
  137. this.eventHandler_.dispose();
  138. this.eventHandler_ = undefined;
  139. }
  140. if (this.keyHandler_) {
  141. this.keyHandler_.dispose();
  142. this.keyHandler_ = undefined;
  143. }
  144. };
  145. /**
  146. * Initializes suggestions menu. Populates menu with separator and ignore option
  147. * that are always valid. Suggestions are later added above the separator.
  148. * @override
  149. */
  150. goog.ui.PlainTextSpellChecker.prototype.initSuggestionsMenu = function() {
  151. goog.ui.PlainTextSpellChecker.superClass_.initSuggestionsMenu.call(this);
  152. this.eventHandler_.listen(
  153. /** @type {goog.ui.PopupMenu} */ (this.getMenu()),
  154. goog.ui.Component.EventType.HIDE, this.onCorrectionHide_);
  155. };
  156. /**
  157. * Checks spelling for all text and displays correction UI.
  158. * @override
  159. */
  160. goog.ui.PlainTextSpellChecker.prototype.check = function() {
  161. var text = this.getElement().value;
  162. this.getElement().readOnly = true;
  163. // Prepare and position correction UI.
  164. goog.dom.removeChildren(this.overlay_);
  165. this.overlay_.className = this.correctionPaneClassName;
  166. if (this.getElement().parentNode != this.overlay_.parentNode) {
  167. this.getElement().parentNode.appendChild(this.overlay_);
  168. }
  169. goog.style.setElementShown(this.overlay_, false);
  170. this.preChargeDictionary_(text);
  171. };
  172. /**
  173. * Final stage of spell checking - displays the correction UI.
  174. * @private
  175. */
  176. goog.ui.PlainTextSpellChecker.prototype.finishCheck_ = function() {
  177. // Show correction UI.
  178. this.positionOverlay_();
  179. goog.style.setElementShown(this.getElement(), false);
  180. goog.style.setElementShown(this.overlay_, true);
  181. var eh = this.eventHandler_;
  182. eh.listen(this.overlay_, goog.events.EventType.CLICK, this.onWordClick_);
  183. eh.listen(
  184. /** @type {goog.events.KeyHandler} */ (this.keyHandler_),
  185. goog.events.KeyHandler.EventType.KEY, this.handleOverlayKeyEvent);
  186. // The position and size of the overlay element needs to be recalculated if
  187. // the browser window is resized.
  188. var win = goog.dom.getWindow(this.getDomHelper().getDocument()) || window;
  189. this.winSize_ = goog.dom.getViewportSize(win);
  190. eh.listen(win, goog.events.EventType.RESIZE, this.onWindowResize_);
  191. goog.ui.PlainTextSpellChecker.superClass_.check.call(this);
  192. };
  193. /**
  194. * Start the scan after the dictionary was loaded.
  195. *
  196. * @param {string} text text to process.
  197. * @private
  198. */
  199. goog.ui.PlainTextSpellChecker.prototype.preChargeDictionary_ = function(text) {
  200. this.eventHandler_.listen(
  201. this.spellCheck, goog.spell.SpellCheck.EventType.READY,
  202. this.onDictionaryCharged_, true);
  203. this.populateDictionary(text, this.dictionaryPreScanSize_);
  204. };
  205. /**
  206. * Loads few initial dictionary words into the cache.
  207. *
  208. * @param {goog.events.Event} e goog.spell.SpellCheck.EventType.READY event.
  209. * @private
  210. */
  211. goog.ui.PlainTextSpellChecker.prototype.onDictionaryCharged_ = function(e) {
  212. e.stopPropagation();
  213. this.eventHandler_.unlisten(
  214. this.spellCheck, goog.spell.SpellCheck.EventType.READY,
  215. this.onDictionaryCharged_, true);
  216. this.checkAsync_(this.getElement().value);
  217. };
  218. /**
  219. * Processes the included and skips the excluded text ranges.
  220. * @return {goog.ui.AbstractSpellChecker.AsyncResult} Whether the spell
  221. * checking is pending or done.
  222. * @private
  223. */
  224. goog.ui.PlainTextSpellChecker.prototype.spellCheckLoop_ = function() {
  225. for (var i = this.textArrayIndex_; i < this.textArray_.length; ++i) {
  226. var text = this.textArray_[i];
  227. if (this.textArrayProcess_[i]) {
  228. var result = this.processTextAsync(this.overlay_, text);
  229. if (result == goog.ui.AbstractSpellChecker.AsyncResult.PENDING) {
  230. this.textArrayIndex_ = i + 1;
  231. goog.Timer.callOnce(this.boundContinueAsyncFn_);
  232. return result;
  233. }
  234. } else {
  235. this.processRange(this.overlay_, text);
  236. }
  237. }
  238. this.textArray_ = [];
  239. this.textArrayProcess_ = [];
  240. return goog.ui.AbstractSpellChecker.AsyncResult.DONE;
  241. };
  242. /**
  243. * Breaks text into included and excluded ranges using the marker RegExp
  244. * supplied by the caller.
  245. *
  246. * @param {string} text text to process.
  247. * @private
  248. */
  249. goog.ui.PlainTextSpellChecker.prototype.initTextArray_ = function(text) {
  250. if (!this.excludeMarker) {
  251. this.textArray_ = [text];
  252. this.textArrayProcess_ = [true];
  253. return;
  254. }
  255. this.textArray_ = [];
  256. this.textArrayProcess_ = [];
  257. this.excludeMarker.lastIndex = 0;
  258. var stringSegmentStart = 0;
  259. var result;
  260. while (result = this.excludeMarker.exec(text)) {
  261. if (result[0].length == 0) {
  262. break;
  263. }
  264. var excludedRange = result[0];
  265. var includedRange =
  266. text.substr(stringSegmentStart, result.index - stringSegmentStart);
  267. if (includedRange) {
  268. this.textArray_.push(includedRange);
  269. this.textArrayProcess_.push(true);
  270. }
  271. this.textArray_.push(excludedRange);
  272. this.textArrayProcess_.push(false);
  273. stringSegmentStart = this.excludeMarker.lastIndex;
  274. }
  275. var leftoverText = text.substr(stringSegmentStart);
  276. if (leftoverText) {
  277. this.textArray_.push(leftoverText);
  278. this.textArrayProcess_.push(true);
  279. }
  280. };
  281. /**
  282. * Starts asynchrnonous spell checking.
  283. *
  284. * @param {string} text text to process.
  285. * @private
  286. */
  287. goog.ui.PlainTextSpellChecker.prototype.checkAsync_ = function(text) {
  288. this.initializeAsyncMode();
  289. this.initTextArray_(text);
  290. this.textArrayIndex_ = 0;
  291. if (this.spellCheckLoop_() ==
  292. goog.ui.AbstractSpellChecker.AsyncResult.PENDING) {
  293. return;
  294. }
  295. this.finishAsyncProcessing();
  296. this.finishCheck_();
  297. };
  298. /**
  299. * Continues asynchrnonous spell checking.
  300. * @private
  301. */
  302. goog.ui.PlainTextSpellChecker.prototype.continueAsync_ = function() {
  303. // First finish with the current segment.
  304. var result = this.continueAsyncProcessing();
  305. if (result == goog.ui.AbstractSpellChecker.AsyncResult.PENDING) {
  306. goog.Timer.callOnce(this.boundContinueAsyncFn_);
  307. return;
  308. }
  309. if (this.spellCheckLoop_() ==
  310. goog.ui.AbstractSpellChecker.AsyncResult.PENDING) {
  311. return;
  312. }
  313. this.finishAsyncProcessing();
  314. this.finishCheck_();
  315. };
  316. /**
  317. * Processes word.
  318. *
  319. * @param {Node} node Node containing word.
  320. * @param {string} word Word to process.
  321. * @param {goog.spell.SpellCheck.WordStatus} status Status of word.
  322. * @override
  323. */
  324. goog.ui.PlainTextSpellChecker.prototype.processWord = function(
  325. node, word, status) {
  326. node.appendChild(this.createWordElement(word, status));
  327. };
  328. /**
  329. * Processes range of text - recognized words and separators.
  330. *
  331. * @param {Node} node Node containing separator.
  332. * @param {string} text text to process.
  333. * @override
  334. */
  335. goog.ui.PlainTextSpellChecker.prototype.processRange = function(node, text) {
  336. this.endOfLineMatcher_.lastIndex = 0;
  337. var result;
  338. while (result = this.endOfLineMatcher_.exec(text)) {
  339. if (result[0].length == 0) {
  340. break;
  341. }
  342. node.appendChild(this.getDomHelper().createTextNode(result[1]));
  343. if (result[2]) {
  344. node.appendChild(this.getDomHelper().createElement(goog.dom.TagName.BR));
  345. }
  346. }
  347. };
  348. /**
  349. * Hides correction UI.
  350. * @override
  351. */
  352. goog.ui.PlainTextSpellChecker.prototype.resume = function() {
  353. var wasVisible = this.isVisible();
  354. goog.ui.PlainTextSpellChecker.superClass_.resume.call(this);
  355. goog.style.setElementShown(this.overlay_, false);
  356. goog.style.setElementShown(this.getElement(), true);
  357. this.getElement().readOnly = false;
  358. if (wasVisible) {
  359. this.getElement().value = goog.dom.getRawTextContent(this.overlay_);
  360. goog.dom.removeChildren(this.overlay_);
  361. var eh = this.eventHandler_;
  362. eh.unlisten(this.overlay_, goog.events.EventType.CLICK, this.onWordClick_);
  363. eh.unlisten(
  364. /** @type {goog.events.KeyHandler} */ (this.keyHandler_),
  365. goog.events.KeyHandler.EventType.KEY, this.handleOverlayKeyEvent);
  366. var win = goog.dom.getWindow(this.getDomHelper().getDocument()) || window;
  367. eh.unlisten(win, goog.events.EventType.RESIZE, this.onWindowResize_);
  368. }
  369. };
  370. /**
  371. * Returns desired element properties for the specified status.
  372. *
  373. * @param {goog.spell.SpellCheck.WordStatus} status Status of word.
  374. * @return {!Object} Properties to apply to word element.
  375. * @override
  376. */
  377. goog.ui.PlainTextSpellChecker.prototype.getElementProperties = function(
  378. status) {
  379. if (status == goog.spell.SpellCheck.WordStatus.INVALID) {
  380. return {'class': this.invalidWordClassName};
  381. } else if (status == goog.spell.SpellCheck.WordStatus.CORRECTED) {
  382. return {'class': this.correctedWordClassName};
  383. }
  384. return {'class': ''};
  385. };
  386. /**
  387. * Handles the click events.
  388. *
  389. * @param {goog.events.BrowserEvent} event Event object.
  390. * @private
  391. */
  392. goog.ui.PlainTextSpellChecker.prototype.onWordClick_ = function(event) {
  393. if (event.target.className == this.invalidWordClassName ||
  394. event.target.className == this.correctedWordClassName) {
  395. this.showSuggestionsMenu(/** @type {!Element} */ (event.target), event);
  396. // Prevent document click handler from closing the menu.
  397. event.stopPropagation();
  398. }
  399. };
  400. /**
  401. * Handles window resize events.
  402. *
  403. * @param {goog.events.BrowserEvent} event Event object.
  404. * @private
  405. */
  406. goog.ui.PlainTextSpellChecker.prototype.onWindowResize_ = function(event) {
  407. var win = goog.dom.getWindow(this.getDomHelper().getDocument()) || window;
  408. var size = goog.dom.getViewportSize(win);
  409. if (size.width != this.winSize_.width ||
  410. size.height != this.winSize_.height) {
  411. goog.style.setElementShown(this.overlay_, false);
  412. goog.style.setElementShown(this.getElement(), true);
  413. // IE requires a slight delay, allowing the resize operation to take effect.
  414. if (goog.userAgent.IE) {
  415. goog.Timer.callOnce(this.resizeOverlay_, 100, this);
  416. } else {
  417. this.resizeOverlay_();
  418. }
  419. this.winSize_ = size;
  420. }
  421. };
  422. /**
  423. * Resizes overlay to match the size of the bound element then displays the
  424. * overlay. Helper for {@link #onWindowResize_}.
  425. *
  426. * @private
  427. */
  428. goog.ui.PlainTextSpellChecker.prototype.resizeOverlay_ = function() {
  429. this.positionOverlay_();
  430. goog.style.setElementShown(this.getElement(), false);
  431. goog.style.setElementShown(this.overlay_, true);
  432. };
  433. /**
  434. * Updates the position and size of the overlay to match the original element.
  435. *
  436. * @private
  437. */
  438. goog.ui.PlainTextSpellChecker.prototype.positionOverlay_ = function() {
  439. goog.style.setPosition(
  440. this.overlay_, goog.style.getPosition(this.getElement()));
  441. goog.style.setSize(this.overlay_, goog.style.getSize(this.getElement()));
  442. };
  443. /** @override */
  444. goog.ui.PlainTextSpellChecker.prototype.disposeInternal = function() {
  445. this.getDomHelper().removeNode(this.overlay_);
  446. delete this.overlay_;
  447. delete this.boundContinueAsyncFn_;
  448. delete this.endOfLineMatcher_;
  449. goog.ui.PlainTextSpellChecker.superClass_.disposeInternal.call(this);
  450. };
  451. /**
  452. * Specify ARIA roles and states as appropriate.
  453. * @private
  454. */
  455. goog.ui.PlainTextSpellChecker.prototype.initAccessibility_ = function() {
  456. goog.asserts.assert(
  457. this.overlay_,
  458. 'The plain text spell checker DOM element cannot be null.');
  459. goog.a11y.aria.setRole(this.overlay_, 'region');
  460. goog.a11y.aria.setState(this.overlay_, 'live', 'assertive');
  461. this.overlay_.tabIndex = 0;
  462. /** @desc Title for Spell Checker's overlay.*/
  463. var MSG_SPELLCHECKER_OVERLAY_TITLE = goog.getMsg('Spell Checker');
  464. this.overlay_.title = MSG_SPELLCHECKER_OVERLAY_TITLE;
  465. };
  466. /**
  467. * Handles key down for overlay.
  468. * @param {goog.events.BrowserEvent} e The browser event.
  469. * @return {boolean} The handled value.
  470. */
  471. goog.ui.PlainTextSpellChecker.prototype.handleOverlayKeyEvent = function(e) {
  472. var handled = false;
  473. switch (e.keyCode) {
  474. case goog.events.KeyCodes.RIGHT:
  475. if (e.ctrlKey) {
  476. handled = this.navigate(goog.ui.AbstractSpellChecker.Direction.NEXT);
  477. }
  478. break;
  479. case goog.events.KeyCodes.LEFT:
  480. if (e.ctrlKey) {
  481. handled =
  482. this.navigate(goog.ui.AbstractSpellChecker.Direction.PREVIOUS);
  483. }
  484. break;
  485. case goog.events.KeyCodes.DOWN:
  486. if (this.getFocusedElementIndex()) {
  487. var el = this.getDomHelper().getElement(
  488. this.makeElementId(this.getFocusedElementIndex()));
  489. if (el) {
  490. var position = goog.style.getPosition(el);
  491. var size = goog.style.getSize(el);
  492. position.x += size.width / 2;
  493. position.y += size.height / 2;
  494. this.showSuggestionsMenu(el, position);
  495. handled = true;
  496. }
  497. }
  498. break;
  499. }
  500. if (handled) {
  501. e.preventDefault();
  502. }
  503. return handled;
  504. };
  505. /**
  506. * Handles correction menu actions.
  507. *
  508. * @param {goog.events.Event} event Action event.
  509. * @override
  510. */
  511. goog.ui.PlainTextSpellChecker.prototype.onCorrectionAction = function(event) {
  512. goog.ui.PlainTextSpellChecker.superClass_.onCorrectionAction.call(
  513. this, event);
  514. // In case of editWord base class has already set the focus (on the input),
  515. // otherwise set the focus back on the word.
  516. if (event.target != this.getMenuEdit()) {
  517. this.reFocus_();
  518. }
  519. };
  520. /**
  521. * Restores focus when the suggestion menu is hidden.
  522. *
  523. * @param {goog.events.BrowserEvent} event Blur event.
  524. * @private
  525. */
  526. goog.ui.PlainTextSpellChecker.prototype.onCorrectionHide_ = function(event) {
  527. this.reFocus_();
  528. };
  529. /**
  530. * Sets the focus back on the previously focused word element.
  531. * @private
  532. */
  533. goog.ui.PlainTextSpellChecker.prototype.reFocus_ = function() {
  534. var el = this.getElementByIndex(this.getFocusedElementIndex());
  535. if (el) {
  536. el.focus();
  537. } else {
  538. this.overlay_.focus();
  539. }
  540. };