// Copyright 2007 The Closure Library Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * @fileoverview Rich text spell checker implementation. * * @author eae@google.com (Emil A Eklund) * @see ../demos/richtextspellchecker.html */ goog.provide('goog.ui.RichTextSpellChecker'); goog.require('goog.Timer'); goog.require('goog.asserts'); goog.require('goog.dom'); goog.require('goog.dom.NodeType'); goog.require('goog.dom.Range'); goog.require('goog.events.EventHandler'); goog.require('goog.events.EventType'); goog.require('goog.events.KeyCodes'); goog.require('goog.events.KeyHandler'); goog.require('goog.math.Coordinate'); goog.require('goog.spell.SpellCheck'); goog.require('goog.string.StringBuffer'); goog.require('goog.style'); goog.require('goog.ui.AbstractSpellChecker'); goog.require('goog.ui.Component'); goog.require('goog.ui.PopupMenu'); /** * Rich text spell checker implementation. * * @param {goog.spell.SpellCheck} handler Instance of the SpellCheckHandler * support object to use. A single instance can be shared by multiple editor * components. * @param {goog.dom.DomHelper=} opt_domHelper Optional DOM helper. * @constructor * @extends {goog.ui.AbstractSpellChecker} */ goog.ui.RichTextSpellChecker = function(handler, opt_domHelper) { goog.ui.AbstractSpellChecker.call(this, handler, opt_domHelper); /** * String buffer for use in reassembly of the original text. * @type {goog.string.StringBuffer} * @private */ this.workBuffer_ = new goog.string.StringBuffer(); /** * Bound async function (to avoid rebinding it on every call). * @type {Function} * @private */ this.boundContinueAsyncFn_ = goog.bind(this.continueAsync_, this); /** * Event handler for listening to events without leaking. * @private {!goog.events.EventHandler} */ this.eventHandler_ = new goog.events.EventHandler(this); this.registerDisposable(this.eventHandler_); /** * The object handling keyboard events. * @private {!goog.events.KeyHandler} */ this.keyHandler_ = new goog.events.KeyHandler(); this.registerDisposable(this.keyHandler_); }; goog.inherits(goog.ui.RichTextSpellChecker, goog.ui.AbstractSpellChecker); goog.tagUnsealableClass(goog.ui.RichTextSpellChecker); /** * Root node for rich editor. * @type {Node} * @private */ goog.ui.RichTextSpellChecker.prototype.rootNode_; /** * Indicates whether the root node for the rich editor is an iframe. * @private {boolean} */ goog.ui.RichTextSpellChecker.prototype.rootNodeIframe_ = false; /** * Current node where spell checker has interrupted to go to the next stack * frame. * @type {Node} * @private */ goog.ui.RichTextSpellChecker.prototype.currentNode_; /** * Counter of inserted elements. Used in processing loop to attempt to preserve * existing nodes if they contain no misspellings. * @type {number} * @private */ goog.ui.RichTextSpellChecker.prototype.elementsInserted_ = 0; /** * Number of words to scan to precharge the dictionary. * @type {number} * @private */ goog.ui.RichTextSpellChecker.prototype.dictionaryPreScanSize_ = 1000; /** * Class name for word spans. * @type {string} */ goog.ui.RichTextSpellChecker.prototype.wordClassName = goog.getCssName('goog-spellcheck-word'); /** * DomHelper to be used for interacting with the editable document/element. * * @type {goog.dom.DomHelper|undefined} * @private */ goog.ui.RichTextSpellChecker.prototype.editorDom_; /** * Tag name portion of the marker for the text that does not need to be checked * for spelling. * * @type {Array} */ goog.ui.RichTextSpellChecker.prototype.excludeTags; /** * CSS Style text for invalid words. As it's set inside the rich edit iframe * classes defined in the parent document are not available, thus the style is * set inline. * @type {string} */ goog.ui.RichTextSpellChecker.prototype.invalidWordCssText = 'background: yellow;'; /** * Creates the initial DOM representation for the component. * * @throws {Error} Not supported. Use decorate. * @see #decorate * @override */ goog.ui.RichTextSpellChecker.prototype.createDom = function() { throw Error('Render not supported for goog.ui.RichTextSpellChecker.'); }; /** * Decorates the element for the UI component. * * @param {Element} element Element to decorate. * @override */ goog.ui.RichTextSpellChecker.prototype.decorateInternal = function(element) { this.setElementInternal(element); this.rootNodeIframe_ = element.contentDocument || element.contentWindow; if (this.rootNodeIframe_) { var doc = element.contentDocument || element.contentWindow.document; this.rootNode_ = doc.body; this.editorDom_ = goog.dom.getDomHelper(doc); } else { this.rootNode_ = element; this.editorDom_ = goog.dom.getDomHelper(element); } }; /** @override */ goog.ui.RichTextSpellChecker.prototype.enterDocument = function() { goog.ui.RichTextSpellChecker.superClass_.enterDocument.call(this); var rootElement = goog.asserts.assertElement( this.rootNode_, 'The rootNode_ of a richtextspellchecker must be an Element.'); this.keyHandler_.attach(rootElement); this.initSuggestionsMenu(); }; /** @override */ goog.ui.RichTextSpellChecker.prototype.initSuggestionsMenu = function() { goog.ui.RichTextSpellChecker.base(this, 'initSuggestionsMenu'); var menu = goog.asserts.assertInstanceof( this.getMenu(), goog.ui.PopupMenu, 'The menu of a richtextspellchecker must be a PopupMenu.'); this.eventHandler_.listen( menu, goog.ui.Component.EventType.HIDE, this.onCorrectionHide_); }; /** * Checks spelling for all text and displays correction UI. * @override */ goog.ui.RichTextSpellChecker.prototype.check = function() { this.blockReadyEvents(); this.preChargeDictionary_(this.rootNode_, this.dictionaryPreScanSize_); this.unblockReadyEvents(); this.eventHandler_.listen( this.spellCheck, goog.spell.SpellCheck.EventType.READY, this.onDictionaryCharged_, true); this.spellCheck.processPending(); }; /** * Processes nodes recursively. * * @param {Node} node Node to start with. * @param {number} words Max number of words to process. * @private */ goog.ui.RichTextSpellChecker.prototype.preChargeDictionary_ = function( node, words) { while (node) { var next = this.nextNode_(node); if (this.isExcluded_(node)) { node = next; continue; } if (node.nodeType == goog.dom.NodeType.TEXT) { if (node.nodeValue) { words -= this.populateDictionary(node.nodeValue, words); if (words <= 0) { return; } } } else if (node.nodeType == goog.dom.NodeType.ELEMENT) { if (node.firstChild) { next = node.firstChild; } } node = next; } }; /** * Starts actual processing after the dictionary is charged. * @param {goog.events.Event} e goog.spell.SpellCheck.EventType.READY event. * @private */ goog.ui.RichTextSpellChecker.prototype.onDictionaryCharged_ = function(e) { e.stopPropagation(); this.eventHandler_.unlisten( this.spellCheck, goog.spell.SpellCheck.EventType.READY, this.onDictionaryCharged_, true); // Now actually do the spell checking. this.clearWordElements(); this.initializeAsyncMode(); this.elementsInserted_ = 0; var result = this.processNode_(this.rootNode_); if (result == goog.ui.AbstractSpellChecker.AsyncResult.PENDING) { goog.Timer.callOnce(this.boundContinueAsyncFn_); return; } this.finishAsyncProcessing(); this.finishCheck_(); }; /** * Continues asynchrnonous spell checking. * @private */ goog.ui.RichTextSpellChecker.prototype.continueAsync_ = function() { var result = this.continueAsyncProcessing(); if (result == goog.ui.AbstractSpellChecker.AsyncResult.PENDING) { goog.Timer.callOnce(this.boundContinueAsyncFn_); return; } result = this.processNode_(this.currentNode_); if (result == goog.ui.AbstractSpellChecker.AsyncResult.PENDING) { goog.Timer.callOnce(this.boundContinueAsyncFn_); return; } this.finishAsyncProcessing(); this.finishCheck_(); }; /** * Finalizes spelling check. * @private */ goog.ui.RichTextSpellChecker.prototype.finishCheck_ = function() { delete this.currentNode_; this.spellCheck.processPending(); if (!this.isVisible()) { this.eventHandler_ .listen(this.rootNode_, goog.events.EventType.CLICK, this.onWordClick_) .listen( this.keyHandler_, goog.events.KeyHandler.EventType.KEY, this.handleRootNodeKeyEvent); } goog.ui.RichTextSpellChecker.superClass_.check.call(this); }; /** * Finds next node in our enumeration of the tree. * * @param {Node} node The node to which we're computing the next node for. * @return {Node} The next node or null if none was found. * @private */ goog.ui.RichTextSpellChecker.prototype.nextNode_ = function(node) { while (node != this.rootNode_) { if (node.nextSibling) { return node.nextSibling; } node = node.parentNode; } return null; }; /** * Determines if the node is text node without any children. * * @param {Node} node The node to check. * @return {boolean} Whether the node is a text leaf node. * @private */ goog.ui.RichTextSpellChecker.prototype.isTextLeaf_ = function(node) { return node != null && node.nodeType == goog.dom.NodeType.TEXT && !node.firstChild; }; /** @override */ goog.ui.RichTextSpellChecker.prototype.setExcludeMarker = function(marker) { if (marker) { if (typeof marker == 'string') { marker = [marker]; } this.excludeTags = []; this.excludeMarker = []; for (var i = 0; i < marker.length; i++) { var parts = marker[i].split('.'); if (parts.length == 2) { this.excludeTags.push(parts[0]); this.excludeMarker.push(parts[1]); } else { this.excludeMarker.push(parts[0]); this.excludeTags.push(undefined); } } } }; /** * Determines if the node is excluded from checking. * * @param {Node} node The node to check. * @return {boolean} Whether the node is excluded. * @private */ goog.ui.RichTextSpellChecker.prototype.isExcluded_ = function(node) { if (this.excludeMarker && node.className) { for (var i = 0; i < this.excludeMarker.length; i++) { var excludeTag = this.excludeTags[i]; var excludeClass = this.excludeMarker[i]; var isExcluded = !!(excludeClass && node.className.indexOf(excludeClass) != -1 && (!excludeTag || node.tagName == excludeTag)); if (isExcluded) { return true; } } } return false; }; /** * Processes nodes recursively. * * @param {Node} node Node where to start. * @return {goog.ui.AbstractSpellChecker.AsyncResult|undefined} Result code. * @private */ goog.ui.RichTextSpellChecker.prototype.processNode_ = function(node) { delete this.currentNode_; while (node) { var next = this.nextNode_(node); if (this.isExcluded_(node)) { node = next; continue; } if (node.nodeType == goog.dom.NodeType.TEXT) { var deleteNode = true; if (node.nodeValue) { var currentElements = this.elementsInserted_; var result = this.processTextAsync(node, node.nodeValue); if (result == goog.ui.AbstractSpellChecker.AsyncResult.PENDING) { // This marks node for deletion (empty nodes get deleted couple // of lines down this function). This is so our algorithm terminates. // In this case the node may be needlessly recreated, but it // happens rather infrequently and saves a lot of code. node.nodeValue = ''; this.currentNode_ = node; return result; } // If we did not add nodes in processing, the current element is still // valid. Let's preserve it! if (currentElements == this.elementsInserted_) { deleteNode = false; } } if (deleteNode) { goog.dom.removeNode(node); } } else if (node.nodeType == goog.dom.NodeType.ELEMENT) { // If this is a spell checker element... if (node.className == this.wordClassName) { // First, reconsolidate the text nodes inside the element - editing // in IE splits them up. var runner = node.firstChild; while (runner) { if (this.isTextLeaf_(runner)) { while (this.isTextLeaf_(runner.nextSibling)) { // Yes, this is not super efficient in IE, but it will almost // never happen. runner.nodeValue += runner.nextSibling.nodeValue; goog.dom.removeNode(runner.nextSibling); } } runner = runner.nextSibling; } // Move its contents out and reprocess it on the next iteration. if (node.firstChild) { next = node.firstChild; while (node.firstChild) { node.parentNode.insertBefore(node.firstChild, node); } } // get rid of the empty shell. goog.dom.removeNode(node); } else { if (node.firstChild) { next = node.firstChild; } } } node = next; } }; /** * Processes word. * * @param {Node} node Node containing word. * @param {string} word Word to process. * @param {goog.spell.SpellCheck.WordStatus} status Status of the word. * @protected * @override */ goog.ui.RichTextSpellChecker.prototype.processWord = function( node, word, status) { node.parentNode.insertBefore(this.createWordElement(word, status), node); this.elementsInserted_++; }; /** * Processes recognized text and separators. * * @param {Node} node Node containing separator. * @param {string} text Text to process. * @protected * @override */ goog.ui.RichTextSpellChecker.prototype.processRange = function(node, text) { // The text does not change, it only gets split, so if the lengths are the // same, the text is the same, so keep the existing node. if (node.nodeType == goog.dom.NodeType.TEXT && node.nodeValue.length == text.length) { return; } node.parentNode.insertBefore(this.editorDom_.createTextNode(text), node); this.elementsInserted_++; }; /** @override */ goog.ui.RichTextSpellChecker.prototype.getElementByIndex = function(id) { return this.editorDom_.getElement(this.makeElementId(id)); }; /** * Updates or replaces element based on word status. * @see goog.ui.AbstractSpellChecker.prototype.updateElement_ * * Overridden from AbstractSpellChecker because we need to be mindful of * deleting the currentNode_ - this can break our pending processing. * * @param {Element} el Word element. * @param {string} word Word to update status for. * @param {goog.spell.SpellCheck.WordStatus} status Status of word. * @protected * @override */ goog.ui.RichTextSpellChecker.prototype.updateElement = function( el, word, status) { if (status == goog.spell.SpellCheck.WordStatus.VALID && el != this.currentNode_ && el.nextSibling != this.currentNode_) { this.removeMarkup(el); } else { goog.dom.setProperties(el, this.getElementProperties(status)); } }; /** * Hides correction UI. * @override */ goog.ui.RichTextSpellChecker.prototype.resume = function() { goog.ui.RichTextSpellChecker.superClass_.resume.call(this); this.restoreNode_(this.rootNode_); this.eventHandler_ .unlisten(this.rootNode_, goog.events.EventType.CLICK, this.onWordClick_) .unlisten( this.keyHandler_, goog.events.KeyHandler.EventType.KEY, this.handleRootNodeKeyEvent); }; /** * Processes nodes recursively, removes all spell checker markup, and * consolidates text nodes. * * @param {Node} node node on which to recurse. * @private */ goog.ui.RichTextSpellChecker.prototype.restoreNode_ = function(node) { while (node) { if (this.isExcluded_(node)) { node = node.nextSibling; continue; } // Contents of the child of the element is usually 1 text element, but the // user can actually add multiple nodes in it during editing. So we move // all the children out, prepend, and reprocess (pointer is set back to // the first node that's been moved out, and the loop repeats). if (node.nodeType == goog.dom.NodeType.ELEMENT && node.className == this.wordClassName) { var firstElement = node.firstChild; var next; for (var child = firstElement; child; child = next) { next = child.nextSibling; node.parentNode.insertBefore(child, node); } next = firstElement || node.nextSibling; goog.dom.removeNode(node); node = next; continue; } // If this is a chain of text elements, we're trying to consolidate it. var textLeaf = this.isTextLeaf_(node); if (textLeaf) { var textNodes = 1; var next = node.nextSibling; while (this.isTextLeaf_(node.previousSibling)) { node = node.previousSibling; ++textNodes; } while (this.isTextLeaf_(next)) { next = next.nextSibling; ++textNodes; } if (textNodes > 1) { this.workBuffer_.append(node.nodeValue); while (this.isTextLeaf_(node.nextSibling)) { this.workBuffer_.append(node.nextSibling.nodeValue); goog.dom.removeNode(node.nextSibling); } node.nodeValue = this.workBuffer_.toString(); this.workBuffer_.clear(); } } // Process child nodes, if any. if (node.firstChild) { this.restoreNode_(node.firstChild); } node = node.nextSibling; } }; /** * Returns desired element properties for the specified status. * * @param {goog.spell.SpellCheck.WordStatus} status Status of the word. * @return {!Object} Properties to apply to word element. * @protected * @override */ goog.ui.RichTextSpellChecker.prototype.getElementProperties = function(status) { return { 'class': this.wordClassName, 'style': (status == goog.spell.SpellCheck.WordStatus.INVALID) ? this.invalidWordCssText : '' }; }; /** * Handler for click events. * * @param {goog.events.BrowserEvent} event Event object. * @private */ goog.ui.RichTextSpellChecker.prototype.onWordClick_ = function(event) { var target = /** @type {Element} */ (event.target); if (event.target.className == this.wordClassName && this.spellCheck.checkWord(goog.dom.getTextContent(target)) == goog.spell.SpellCheck.WordStatus.INVALID) { this.showSuggestionsMenu(target, event); // Prevent document click handler from closing the menu. event.stopPropagation(); } }; /** @override */ goog.ui.RichTextSpellChecker.prototype.disposeInternal = function() { goog.ui.RichTextSpellChecker.superClass_.disposeInternal.call(this); this.rootNode_ = null; this.editorDom_ = null; }; /** * Returns whether the editor node is an iframe. * * @return {boolean} true the editor node is an iframe, otherwise false. * @protected */ goog.ui.RichTextSpellChecker.prototype.isEditorIframe = function() { return this.rootNodeIframe_; }; /** * Handles keyboard events inside the editor to allow keyboard navigation * between misspelled words and activation of the suggestion menu. * * @param {goog.events.BrowserEvent} e the key event. * @return {boolean} The handled value. * @protected */ goog.ui.RichTextSpellChecker.prototype.handleRootNodeKeyEvent = function(e) { var handled = false; switch (e.keyCode) { case goog.events.KeyCodes.RIGHT: if (e.ctrlKey) { handled = this.navigate(goog.ui.AbstractSpellChecker.Direction.NEXT); } break; case goog.events.KeyCodes.LEFT: if (e.ctrlKey) { handled = this.navigate(goog.ui.AbstractSpellChecker.Direction.PREVIOUS); } break; case goog.events.KeyCodes.DOWN: if (this.getFocusedElementIndex()) { var el = this.editorDom_.getElement( this.makeElementId(this.getFocusedElementIndex())); if (el) { var position = goog.style.getClientPosition(el); if (this.isEditorIframe()) { var iframePosition = goog.style.getClientPosition(this.getElementStrict()); position = goog.math.Coordinate.sum(iframePosition, position); } var size = goog.style.getSize(el); position.x += size.width / 2; position.y += size.height / 2; this.showSuggestionsMenu(el, position); handled = true; } } break; } if (handled) { e.preventDefault(); } return handled; }; /** @override */ goog.ui.RichTextSpellChecker.prototype.onCorrectionAction = function(event) { goog.ui.RichTextSpellChecker.base(this, 'onCorrectionAction', event); // In case of editWord base class has already set the focus (on the input), // otherwise set the focus back on the word. if (event.target != this.getMenuEdit()) { this.reFocus_(); } }; /** * Restores focus when the suggestion menu is hidden. * * @param {goog.events.BrowserEvent} event Blur event. * @private */ goog.ui.RichTextSpellChecker.prototype.onCorrectionHide_ = function(event) { this.reFocus_(); }; /** * Sets the focus back on the previously focused word element. * @private */ goog.ui.RichTextSpellChecker.prototype.reFocus_ = function() { this.getElementStrict().focus(); var el = this.getElementByIndex(this.getFocusedElementIndex()); if (el) { this.focusOnElement(el); } }; /** @override */ goog.ui.RichTextSpellChecker.prototype.focusOnElement = function(element) { goog.dom.Range.createCaret(element, 0).select(); };