blockquote.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. // Copyright 2008 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 goog.editor plugin to handle splitting block quotes.
  16. *
  17. * @author robbyw@google.com (Robby Walker)
  18. */
  19. goog.provide('goog.editor.plugins.Blockquote');
  20. goog.require('goog.dom');
  21. goog.require('goog.dom.NodeType');
  22. goog.require('goog.dom.TagName');
  23. goog.require('goog.dom.classlist');
  24. goog.require('goog.editor.BrowserFeature');
  25. goog.require('goog.editor.Command');
  26. goog.require('goog.editor.Plugin');
  27. goog.require('goog.editor.node');
  28. goog.require('goog.functions');
  29. goog.require('goog.log');
  30. /**
  31. * Plugin to handle splitting block quotes. This plugin does nothing on its
  32. * own and should be used in conjunction with EnterHandler or one of its
  33. * subclasses.
  34. * @param {boolean} requiresClassNameToSplit Whether to split only blockquotes
  35. * that have the given classname.
  36. * @param {string=} opt_className The classname to apply to generated
  37. * blockquotes. Defaults to 'tr_bq'.
  38. * @constructor
  39. * @extends {goog.editor.Plugin}
  40. * @final
  41. */
  42. goog.editor.plugins.Blockquote = function(
  43. requiresClassNameToSplit, opt_className) {
  44. goog.editor.Plugin.call(this);
  45. /**
  46. * Whether we only split blockquotes that have {@link classname}, or whether
  47. * all blockquote tags should be split on enter.
  48. * @type {boolean}
  49. * @private
  50. */
  51. this.requiresClassNameToSplit_ = requiresClassNameToSplit;
  52. /**
  53. * Classname to put on blockquotes that are generated via the toolbar for
  54. * blockquote, so that we can internally distinguish these from blockquotes
  55. * that are used for indentation. This classname can be over-ridden by
  56. * clients for styling or other purposes.
  57. * @type {string}
  58. * @private
  59. */
  60. this.className_ = opt_className || goog.getCssName('tr_bq');
  61. };
  62. goog.inherits(goog.editor.plugins.Blockquote, goog.editor.Plugin);
  63. /**
  64. * Command implemented by this plugin.
  65. * @type {string}
  66. */
  67. goog.editor.plugins.Blockquote.SPLIT_COMMAND = '+splitBlockquote';
  68. /**
  69. * Class ID used to identify this plugin.
  70. * @type {string}
  71. */
  72. goog.editor.plugins.Blockquote.CLASS_ID = 'Blockquote';
  73. /**
  74. * Logging object.
  75. * @type {goog.log.Logger}
  76. * @protected
  77. * @override
  78. */
  79. goog.editor.plugins.Blockquote.prototype.logger =
  80. goog.log.getLogger('goog.editor.plugins.Blockquote');
  81. /** @override */
  82. goog.editor.plugins.Blockquote.prototype.getTrogClassId = function() {
  83. return goog.editor.plugins.Blockquote.CLASS_ID;
  84. };
  85. /**
  86. * Since our exec command is always called from elsewhere, we make it silent.
  87. * @override
  88. */
  89. goog.editor.plugins.Blockquote.prototype.isSilentCommand = goog.functions.TRUE;
  90. /**
  91. * Checks if a node is a blockquote which can be split. A splittable blockquote
  92. * meets the following criteria:
  93. * <ol>
  94. * <li>Node is a blockquote element</li>
  95. * <li>Node has the blockquote classname if the classname is required to
  96. * split</li>
  97. * </ol>
  98. *
  99. * @param {Node} node DOM node in question.
  100. * @return {boolean} Whether the node is a splittable blockquote.
  101. */
  102. goog.editor.plugins.Blockquote.prototype.isSplittableBlockquote = function(
  103. node) {
  104. if (/** @type {!Element} */ (node).tagName != goog.dom.TagName.BLOCKQUOTE) {
  105. return false;
  106. }
  107. if (!this.requiresClassNameToSplit_) {
  108. return true;
  109. }
  110. return goog.dom.classlist.contains(
  111. /** @type {!Element} */ (node), this.className_);
  112. };
  113. /**
  114. * Checks if a node is a blockquote element which has been setup.
  115. * @param {Node} node DOM node to check.
  116. * @return {boolean} Whether the node is a blockquote with the required class
  117. * name applied.
  118. */
  119. goog.editor.plugins.Blockquote.prototype.isSetupBlockquote = function(node) {
  120. return /** @type {!Element} */ (node).tagName ==
  121. goog.dom.TagName.BLOCKQUOTE &&
  122. goog.dom.classlist.contains(
  123. /** @type {!Element} */ (node), this.className_);
  124. };
  125. /**
  126. * Checks if a node is a blockquote element which has not been setup yet.
  127. * @param {Node} node DOM node to check.
  128. * @return {boolean} Whether the node is a blockquote without the required
  129. * class name applied.
  130. */
  131. goog.editor.plugins.Blockquote.prototype.isUnsetupBlockquote = function(node) {
  132. return /** @type {!Element} */ (node).tagName ==
  133. goog.dom.TagName.BLOCKQUOTE &&
  134. !this.isSetupBlockquote(node);
  135. };
  136. /**
  137. * Gets the class name required for setup blockquotes.
  138. * @return {string} The blockquote class name.
  139. */
  140. goog.editor.plugins.Blockquote.prototype.getBlockquoteClassName = function() {
  141. return this.className_;
  142. };
  143. /**
  144. * Helper routine which walks up the tree to find the topmost
  145. * ancestor with only a single child. The ancestor node or the original
  146. * node (if no ancestor was found) is then removed from the DOM.
  147. *
  148. * @param {Node} node The node whose ancestors have to be searched.
  149. * @param {Node} root The root node to stop the search at.
  150. * @private
  151. */
  152. goog.editor.plugins.Blockquote.findAndRemoveSingleChildAncestor_ = function(
  153. node, root) {
  154. var predicateFunc = function(parentNode) {
  155. return parentNode != root && parentNode.childNodes.length == 1;
  156. };
  157. var ancestor =
  158. goog.editor.node.findHighestMatchingAncestor(node, predicateFunc);
  159. if (!ancestor) {
  160. ancestor = node;
  161. }
  162. goog.dom.removeNode(ancestor);
  163. };
  164. /**
  165. * Remove every nodes from the DOM tree that are all white space nodes.
  166. * @param {Array<Node>} nodes Nodes to be checked.
  167. * @private
  168. */
  169. goog.editor.plugins.Blockquote.removeAllWhiteSpaceNodes_ = function(nodes) {
  170. for (var i = 0; i < nodes.length; ++i) {
  171. if (goog.editor.node.isEmpty(nodes[i], true)) {
  172. goog.dom.removeNode(nodes[i]);
  173. }
  174. }
  175. };
  176. /** @override */
  177. goog.editor.plugins.Blockquote.prototype.isSupportedCommand = function(
  178. command) {
  179. return command == goog.editor.plugins.Blockquote.SPLIT_COMMAND;
  180. };
  181. /**
  182. * Splits a quoted region if any. To be called on a key press event. When this
  183. * function returns true, the event that caused it to be called should be
  184. * canceled.
  185. * @param {string} command The command to execute.
  186. * @param {...*} var_args Single additional argument representing the current
  187. * cursor position. If BrowserFeature.HAS_W3C_RANGES it is an object with a
  188. * {@code node} key and an {@code offset} key. In other cases (legacy IE)
  189. * it is a single node.
  190. * @return {boolean|undefined} Boolean true when the quoted region has been
  191. * split, false or undefined otherwise.
  192. * @override
  193. */
  194. goog.editor.plugins.Blockquote.prototype.execCommandInternal = function(
  195. command, var_args) {
  196. var pos = arguments[1];
  197. if (command == goog.editor.plugins.Blockquote.SPLIT_COMMAND && pos &&
  198. (this.className_ || !this.requiresClassNameToSplit_)) {
  199. return goog.editor.BrowserFeature.HAS_W3C_RANGES ?
  200. this.splitQuotedBlockW3C_(pos) :
  201. this.splitQuotedBlockIE_(/** @type {Node} */ (pos));
  202. }
  203. };
  204. /**
  205. * Version of splitQuotedBlock_ that uses W3C ranges.
  206. * @param {Object} anchorPos The current cursor position.
  207. * @return {boolean} Whether the blockquote was split.
  208. * @private
  209. */
  210. goog.editor.plugins.Blockquote.prototype.splitQuotedBlockW3C_ = function(
  211. anchorPos) {
  212. var cursorNode = anchorPos.node;
  213. var quoteNode = goog.editor.node.findTopMostEditableAncestor(
  214. cursorNode.parentNode, goog.bind(this.isSplittableBlockquote, this));
  215. var secondHalf, textNodeToRemove;
  216. var insertTextNode = false;
  217. // There are two special conditions that we account for here.
  218. //
  219. // 1. Whenever the cursor is after (one<BR>|) or just before a BR element
  220. // (one|<BR>) and the user presses enter, the second quoted block starts
  221. // with a BR which appears to the user as an extra newline. This stems
  222. // from the fact that we create two text nodes as our split boundaries
  223. // and the BR becomes a part of the second half because of this.
  224. //
  225. // 2. When the cursor is at the end of a text node with no siblings and
  226. // the user presses enter, the second blockquote might contain a
  227. // empty subtree that ends in a 0 length text node. We account for that
  228. // as a post-splitting operation.
  229. if (quoteNode) {
  230. // selection is in a line that has text in it
  231. if (cursorNode.nodeType == goog.dom.NodeType.TEXT) {
  232. if (anchorPos.offset == cursorNode.length) {
  233. var siblingNode = cursorNode.nextSibling;
  234. // This accounts for the condition where the cursor appears at the
  235. // end of a text node and right before the BR eg: one|<BR>. We ensure
  236. // that we split on the BR in that case.
  237. if (siblingNode && siblingNode.tagName == goog.dom.TagName.BR) {
  238. cursorNode = siblingNode;
  239. // This might be null but splitDomTreeAt accounts for the null case.
  240. secondHalf = siblingNode.nextSibling;
  241. } else {
  242. textNodeToRemove = cursorNode.splitText(anchorPos.offset);
  243. secondHalf = textNodeToRemove;
  244. }
  245. } else {
  246. secondHalf = cursorNode.splitText(anchorPos.offset);
  247. }
  248. } else if (cursorNode.tagName == goog.dom.TagName.BR) {
  249. // This might be null but splitDomTreeAt accounts for the null case.
  250. secondHalf = cursorNode.nextSibling;
  251. } else {
  252. // The selection is in a line that is empty, with more than 1 level
  253. // of quote.
  254. insertTextNode = true;
  255. }
  256. } else {
  257. // Check if current node is a quote node.
  258. // This will happen if user clicks in an empty line in the quote,
  259. // when there is 1 level of quote.
  260. if (this.isSetupBlockquote(cursorNode)) {
  261. quoteNode = cursorNode;
  262. insertTextNode = true;
  263. }
  264. }
  265. if (insertTextNode) {
  266. // Create two empty text nodes to split between.
  267. cursorNode = this.insertEmptyTextNodeBeforeRange_();
  268. secondHalf = this.insertEmptyTextNodeBeforeRange_();
  269. }
  270. if (!quoteNode) {
  271. return false;
  272. }
  273. secondHalf =
  274. goog.editor.node.splitDomTreeAt(cursorNode, secondHalf, quoteNode);
  275. goog.dom.insertSiblingAfter(secondHalf, quoteNode);
  276. // Set the insertion point.
  277. var dh = this.getFieldDomHelper();
  278. var tagToInsert = this.getFieldObject().queryCommandValue(
  279. goog.editor.Command.DEFAULT_TAG) ||
  280. goog.dom.TagName.DIV;
  281. var container = dh.createElement(/** @type {string} */ (tagToInsert));
  282. container.innerHTML = '&nbsp;'; // Prevent the div from collapsing.
  283. quoteNode.parentNode.insertBefore(container, secondHalf);
  284. dh.getWindow().getSelection().collapse(container, 0);
  285. // We need to account for the condition where the second blockquote
  286. // might contain an empty DOM tree. This arises from trying to split
  287. // at the end of an empty text node. We resolve this by walking up the tree
  288. // till we either reach the blockquote or till we hit a node with more
  289. // than one child. The resulting node is then removed from the DOM.
  290. if (textNodeToRemove) {
  291. goog.editor.plugins.Blockquote.findAndRemoveSingleChildAncestor_(
  292. textNodeToRemove, secondHalf);
  293. }
  294. goog.editor.plugins.Blockquote.removeAllWhiteSpaceNodes_(
  295. [quoteNode, secondHalf]);
  296. return true;
  297. };
  298. /**
  299. * Inserts an empty text node before the field's range.
  300. * @return {!Node} The empty text node.
  301. * @private
  302. */
  303. goog.editor.plugins.Blockquote.prototype.insertEmptyTextNodeBeforeRange_ =
  304. function() {
  305. var range = this.getFieldObject().getRange();
  306. var node = this.getFieldDomHelper().createTextNode('');
  307. range.insertNode(node, true);
  308. return node;
  309. };
  310. /**
  311. * IE version of splitQuotedBlock_.
  312. * @param {Node} splitNode The current cursor position.
  313. * @return {boolean} Whether the blockquote was split.
  314. * @private
  315. */
  316. goog.editor.plugins.Blockquote.prototype.splitQuotedBlockIE_ = function(
  317. splitNode) {
  318. var dh = this.getFieldDomHelper();
  319. var quoteNode = goog.editor.node.findTopMostEditableAncestor(
  320. splitNode.parentNode, goog.bind(this.isSplittableBlockquote, this));
  321. if (!quoteNode) {
  322. return false;
  323. }
  324. var clone = splitNode.cloneNode(false);
  325. // Whenever the cursor is just before a BR element (one|<BR>) and the user
  326. // presses enter, the second quoted block starts with a BR which appears
  327. // to the user as an extra newline. This stems from the fact that the
  328. // dummy span that we create (splitNode) occurs before the BR and we split
  329. // on that.
  330. if (splitNode.nextSibling &&
  331. /** @type {!Element} */ (splitNode.nextSibling).tagName ==
  332. goog.dom.TagName.BR) {
  333. splitNode = splitNode.nextSibling;
  334. }
  335. var secondHalf = goog.editor.node.splitDomTreeAt(splitNode, clone, quoteNode);
  336. goog.dom.insertSiblingAfter(secondHalf, quoteNode);
  337. // Set insertion point.
  338. var tagToInsert = this.getFieldObject().queryCommandValue(
  339. goog.editor.Command.DEFAULT_TAG) ||
  340. goog.dom.TagName.DIV;
  341. var div = dh.createElement(/** @type {string} */ (tagToInsert));
  342. quoteNode.parentNode.insertBefore(div, secondHalf);
  343. // The div needs non-whitespace contents in order for the insertion point
  344. // to get correctly inserted.
  345. div.innerHTML = '&nbsp;';
  346. // Moving the range 1 char isn't enough when you have markup.
  347. // This moves the range to the end of the nbsp.
  348. var range = dh.getDocument().selection.createRange();
  349. range.moveToElementText(splitNode);
  350. range.move('character', 2);
  351. range.select();
  352. // Remove the no-longer-necessary nbsp.
  353. goog.dom.removeChildren(div);
  354. // Clear the original selection.
  355. range.pasteHTML('');
  356. // We need to remove clone from the DOM but just removing clone alone will
  357. // not suffice. Let's assume we have the following DOM structure and the
  358. // cursor is placed after the first numbered list item "one".
  359. //
  360. // <blockquote class="gmail-quote">
  361. // <div><div>a</div><ol><li>one|</li></ol></div>
  362. // <div>b</div>
  363. // </blockquote>
  364. //
  365. // After pressing enter, we have the following structure.
  366. //
  367. // <blockquote class="gmail-quote">
  368. // <div><div>a</div><ol><li>one|</li></ol></div>
  369. // </blockquote>
  370. // <div>&nbsp;</div>
  371. // <blockquote class="gmail-quote">
  372. // <div><ol><li><span id=""></span></li></ol></div>
  373. // <div>b</div>
  374. // </blockquote>
  375. //
  376. // The clone is contained in a subtree which should be removed. This stems
  377. // from the fact that we invoke splitDomTreeAt with the dummy span
  378. // as the starting splitting point and this results in the empty subtree
  379. // <div><ol><li><span id=""></span></li></ol></div>.
  380. //
  381. // We resolve this by walking up the tree till we either reach the
  382. // blockquote or till we hit a node with more than one child. The resulting
  383. // node is then removed from the DOM.
  384. goog.editor.plugins.Blockquote.findAndRemoveSingleChildAncestor_(
  385. clone, secondHalf);
  386. goog.editor.plugins.Blockquote.removeAllWhiteSpaceNodes_(
  387. [quoteNode, secondHalf]);
  388. return true;
  389. };