// Copyright 2008 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 goog.editor plugin to handle splitting block quotes. * */ goog.provide('goog.editor.plugins.Blockquote'); goog.require('goog.debug.Logger'); goog.require('goog.dom'); goog.require('goog.dom.NodeType'); goog.require('goog.dom.TagName'); goog.require('goog.dom.classes'); goog.require('goog.editor.BrowserFeature'); goog.require('goog.editor.Command'); goog.require('goog.editor.Plugin'); goog.require('goog.editor.node'); goog.require('goog.functions'); /** * Plugin to handle splitting block quotes. This plugin does nothing on its * own and should be used in conjunction with EnterHandler or one of its * subclasses. * @param {boolean} requiresClassNameToSplit Whether to split only blockquotes * that have the given classname. * @param {string=} opt_className The classname to apply to generated * blockquotes. Defaults to 'tr_bq'. * @constructor * @extends {goog.editor.Plugin} */ goog.editor.plugins.Blockquote = function(requiresClassNameToSplit, opt_className) { goog.editor.Plugin.call(this); /** * Whether we only split blockquotes that have {@link classname}, or whether * all blockquote tags should be split on enter. * @type {boolean} * @private */ this.requiresClassNameToSplit_ = requiresClassNameToSplit; /** * Classname to put on blockquotes that are generated via the toolbar for * blockquote, so that we can internally distinguish these from blockquotes * that are used for indentation. This classname can be over-ridden by * clients for styling or other purposes. * @type {string} * @private */ this.className_ = opt_className || goog.getCssName('tr_bq'); }; goog.inherits(goog.editor.plugins.Blockquote, goog.editor.Plugin); /** * Command implemented by this plugin. * @type {string} */ goog.editor.plugins.Blockquote.SPLIT_COMMAND = '+splitBlockquote'; /** * Class ID used to identify this plugin. * @type {string} */ goog.editor.plugins.Blockquote.CLASS_ID = 'Blockquote'; /** * Logging object. * @type {goog.debug.Logger} * @protected */ goog.editor.plugins.Blockquote.prototype.logger = goog.debug.Logger.getLogger('goog.editor.plugins.Blockquote'); /** @override */ goog.editor.plugins.Blockquote.prototype.getTrogClassId = function() { return goog.editor.plugins.Blockquote.CLASS_ID; }; /** * Since our exec command is always called from elsewhere, we make it silent. */ goog.editor.plugins.Blockquote.prototype.isSilentCommand = goog.functions.TRUE; /** * Checks if a node is a blockquote node. If isAlreadySetup is set, it also * makes sure the node has the blockquote classname applied. Otherwise, it * ensures that the blockquote does not already have the classname applied. * @param {Node} node DOM node to check. * @param {boolean} isAlreadySetup True to enforce that the classname must be * set in order for it to count as a blockquote, false to * enforce that the classname must not be set in order for * it to count as a blockquote. * @param {boolean} requiresClassNameToSplit Whether only blockquotes with the * class name should be split. * @param {string} className The official blockquote class name. * @return {boolean} Whether node is a blockquote and if isAlreadySetup is * true, then whether this is a setup blockquote. * @deprecated Use {@link #isSplittableBlockquote}, * {@link #isSetupBlockquote}, or {@link #isUnsetupBlockquote} instead * since this has confusing behavior. */ goog.editor.plugins.Blockquote.isBlockquote = function(node, isAlreadySetup, requiresClassNameToSplit, className) { if (node.tagName != goog.dom.TagName.BLOCKQUOTE) { return false; } if (!requiresClassNameToSplit) { return isAlreadySetup; } var hasClassName = goog.dom.classes.has(/** @type {Element} */ (node), className); return isAlreadySetup ? hasClassName : !hasClassName; }; /** * Checks if a node is a blockquote which can be split. A splittable blockquote * meets the following criteria: *
    *
  1. Node is a blockquote element
  2. *
  3. Node has the blockquote classname if the classname is required to * split
  4. *
* * @param {Node} node DOM node in question. * @return {boolean} Whether the node is a splittable blockquote. */ goog.editor.plugins.Blockquote.prototype.isSplittableBlockquote = function(node) { if (node.tagName != goog.dom.TagName.BLOCKQUOTE) { return false; } if (!this.requiresClassNameToSplit_) { return true; } return goog.dom.classes.has(node, this.className_); }; /** * Checks if a node is a blockquote element which has been setup. * @param {Node} node DOM node to check. * @return {boolean} Whether the node is a blockquote with the required class * name applied. */ goog.editor.plugins.Blockquote.prototype.isSetupBlockquote = function(node) { return node.tagName == goog.dom.TagName.BLOCKQUOTE && goog.dom.classes.has(node, this.className_); }; /** * Checks if a node is a blockquote element which has not been setup yet. * @param {Node} node DOM node to check. * @return {boolean} Whether the node is a blockquote without the required * class name applied. */ goog.editor.plugins.Blockquote.prototype.isUnsetupBlockquote = function(node) { return node.tagName == goog.dom.TagName.BLOCKQUOTE && !this.isSetupBlockquote(node); }; /** * Gets the class name required for setup blockquotes. * @return {string} The blockquote class name. */ goog.editor.plugins.Blockquote.prototype.getBlockquoteClassName = function() { return this.className_; }; /** * Helper routine which walks up the tree to find the topmost * ancestor with only a single child. The ancestor node or the original * node (if no ancestor was found) is then removed from the DOM. * * @param {Node} node The node whose ancestors have to be searched. * @param {Node} root The root node to stop the search at. * @private */ goog.editor.plugins.Blockquote.findAndRemoveSingleChildAncestor_ = function( node, root) { var predicateFunc = function(parentNode) { return parentNode != root && parentNode.childNodes.length == 1; } var ancestor = goog.editor.node.findHighestMatchingAncestor(node, predicateFunc); if (!ancestor) { ancestor = node; } goog.dom.removeNode(ancestor); }; /** * Remove every nodes from the DOM tree that are all white space nodes. * @param {Array.} nodes Nodes to be checked. * @private */ goog.editor.plugins.Blockquote.removeAllWhiteSpaceNodes_ = function(nodes) { for (var i = 0; i < nodes.length; ++i) { if (goog.editor.node.isEmpty(nodes[i], true)) { goog.dom.removeNode(nodes[i]); } } }; /** @override */ goog.editor.plugins.Blockquote.prototype.isSupportedCommand = function( command) { return command == goog.editor.plugins.Blockquote.SPLIT_COMMAND; }; /** * Splits a quoted region if any. To be called on a key press event. When this * function returns true, the event that caused it to be called should be * canceled. * @param {string} command The command to execute. * @param {...*} var_args Single additional argument representing the * current cursor position. In IE, it is a single node. In any other * browser, it is an object with a {@code node} key and an {@code offset} * key. * @return {boolean|undefined} Boolean true when the quoted region has been * split, false or undefined otherwise. * @override */ goog.editor.plugins.Blockquote.prototype.execCommandInternal = function( command, var_args) { var pos = arguments[1]; if (command == goog.editor.plugins.Blockquote.SPLIT_COMMAND && pos && (this.className_ || !this.requiresClassNameToSplit_)) { return goog.editor.BrowserFeature.HAS_W3C_RANGES ? this.splitQuotedBlockW3C_(pos) : this.splitQuotedBlockIE_(/** @type {Node} */ (pos)); } }; /** * Version of splitQuotedBlock_ that uses W3C ranges. * @param {Object} anchorPos The current cursor position. * @return {boolean} Whether the blockquote was split. * @private */ goog.editor.plugins.Blockquote.prototype.splitQuotedBlockW3C_ = function(anchorPos) { var cursorNode = anchorPos.node; var quoteNode = goog.editor.node.findTopMostEditableAncestor( cursorNode.parentNode, goog.bind(this.isSplittableBlockquote, this)); var secondHalf, textNodeToRemove; var insertTextNode = false; // There are two special conditions that we account for here. // // 1. Whenever the cursor is after (one
|) or just before a BR element // (one|
) and the user presses enter, the second quoted block starts // with a BR which appears to the user as an extra newline. This stems // from the fact that we create two text nodes as our split boundaries // and the BR becomes a part of the second half because of this. // // 2. When the cursor is at the end of a text node with no siblings and // the user presses enter, the second blockquote might contain a // empty subtree that ends in a 0 length text node. We account for that // as a post-splitting operation. if (quoteNode) { // selection is in a line that has text in it if (cursorNode.nodeType == goog.dom.NodeType.TEXT) { if (anchorPos.offset == cursorNode.length) { var siblingNode = cursorNode.nextSibling; // This accounts for the condition where the cursor appears at the // end of a text node and right before the BR eg: one|
. We ensure // that we split on the BR in that case. if (siblingNode && siblingNode.tagName == goog.dom.TagName.BR) { cursorNode = siblingNode; // This might be null but splitDomTreeAt accounts for the null case. secondHalf = siblingNode.nextSibling; } else { textNodeToRemove = cursorNode.splitText(anchorPos.offset); secondHalf = textNodeToRemove; } } else { secondHalf = cursorNode.splitText(anchorPos.offset); } } else if (cursorNode.tagName == goog.dom.TagName.BR) { // This might be null but splitDomTreeAt accounts for the null case. secondHalf = cursorNode.nextSibling; } else { // The selection is in a line that is empty, with more than 1 level // of quote. insertTextNode = true; } } else { // Check if current node is a quote node. // This will happen if user clicks in an empty line in the quote, // when there is 1 level of quote. if (this.isSetupBlockquote(cursorNode)) { quoteNode = cursorNode; insertTextNode = true; } } if (insertTextNode) { // Create two empty text nodes to split between. cursorNode = this.insertEmptyTextNodeBeforeRange_(); secondHalf = this.insertEmptyTextNodeBeforeRange_(); } if (!quoteNode) { return false; } secondHalf = goog.editor.node.splitDomTreeAt(cursorNode, secondHalf, quoteNode); goog.dom.insertSiblingAfter(secondHalf, quoteNode); // Set the insertion point. var dh = this.getFieldDomHelper(); var tagToInsert = this.fieldObject.queryCommandValue(goog.editor.Command.DEFAULT_TAG) || goog.dom.TagName.DIV; var container = dh.createElement(/** @type {string} */ (tagToInsert)); container.innerHTML = ' '; // Prevent the div from collapsing. quoteNode.parentNode.insertBefore(container, secondHalf); dh.getWindow().getSelection().collapse(container, 0); // We need to account for the condition where the second blockquote // might contain an empty DOM tree. This arises from trying to split // at the end of an empty text node. We resolve this by walking up the tree // till we either reach the blockquote or till we hit a node with more // than one child. The resulting node is then removed from the DOM. if (textNodeToRemove) { goog.editor.plugins.Blockquote.findAndRemoveSingleChildAncestor_( textNodeToRemove, secondHalf); } goog.editor.plugins.Blockquote.removeAllWhiteSpaceNodes_( [quoteNode, secondHalf]); return true; }; /** * Inserts an empty text node before the field's range. * @return {!Node} The empty text node. * @private */ goog.editor.plugins.Blockquote.prototype.insertEmptyTextNodeBeforeRange_ = function() { var range = this.fieldObject.getRange(); var node = this.getFieldDomHelper().createTextNode(''); range.insertNode(node, true); return node; }; /** * IE version of splitQuotedBlock_. * @param {Node} splitNode The current cursor position. * @return {boolean} Whether the blockquote was split. * @private */ goog.editor.plugins.Blockquote.prototype.splitQuotedBlockIE_ = function(splitNode) { var dh = this.getFieldDomHelper(); var quoteNode = goog.editor.node.findTopMostEditableAncestor( splitNode.parentNode, goog.bind(this.isSplittableBlockquote, this)); if (!quoteNode) { return false; } var clone = splitNode.cloneNode(false); // Whenever the cursor is just before a BR element (one|
) and the user // presses enter, the second quoted block starts with a BR which appears // to the user as an extra newline. This stems from the fact that the // dummy span that we create (splitNode) occurs before the BR and we split // on that. if (splitNode.nextSibling && splitNode.nextSibling.tagName == goog.dom.TagName.BR) { splitNode = splitNode.nextSibling; } var secondHalf = goog.editor.node.splitDomTreeAt(splitNode, clone, quoteNode); goog.dom.insertSiblingAfter(secondHalf, quoteNode); // Set insertion point. var tagToInsert = this.fieldObject.queryCommandValue(goog.editor.Command.DEFAULT_TAG) || goog.dom.TagName.DIV; var div = dh.createElement(/** @type {string} */ (tagToInsert)); quoteNode.parentNode.insertBefore(div, secondHalf); // The div needs non-whitespace contents in order for the insertion point // to get correctly inserted. div.innerHTML = ' '; // Moving the range 1 char isn't enough when you have markup. // This moves the range to the end of the nbsp. var range = dh.getDocument().selection.createRange(); range.moveToElementText(splitNode); range.move('character', 2); range.select(); // Remove the no-longer-necessary nbsp. div.innerHTML = ''; // Clear the original selection. range.pasteHTML(''); // We need to remove clone from the DOM but just removing clone alone will // not suffice. Let's assume we have the following DOM structure and the // cursor is placed after the first numbered list item "one". // //
//
a
  1. one|
//
b
//
// // After pressing enter, we have the following structure. // //
//
a
  1. one|
//
//
 
//
//
//
b
//
// // The clone is contained in a subtree which should be removed. This stems // from the fact that we invoke splitDomTreeAt with the dummy span // as the starting splitting point and this results in the empty subtree //
. // // We resolve this by walking up the tree till we either reach the // blockquote or till we hit a node with more than one child. The resulting // node is then removed from the DOM. goog.editor.plugins.Blockquote.findAndRemoveSingleChildAncestor_( clone, secondHalf); goog.editor.plugins.Blockquote.removeAllWhiteSpaceNodes_( [quoteNode, secondHalf]); return true; };