// 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 Plugin to handle enter keys. * */ goog.provide('goog.editor.plugins.EnterHandler'); goog.require('goog.dom'); goog.require('goog.dom.AbstractRange'); goog.require('goog.dom.NodeOffset'); goog.require('goog.dom.NodeType'); goog.require('goog.dom.TagName'); goog.require('goog.editor.BrowserFeature'); goog.require('goog.editor.Plugin'); goog.require('goog.editor.node'); goog.require('goog.editor.plugins.Blockquote'); goog.require('goog.editor.range'); goog.require('goog.editor.style'); goog.require('goog.events.KeyCodes'); goog.require('goog.string'); goog.require('goog.userAgent'); /** * Plugin to handle enter keys. This does all the crazy to normalize (as much as * is reasonable) what happens when you hit enter. This also handles the * special casing of hitting enter in a blockquote. * * In IE, Webkit, and Opera, the resulting HTML uses one DIV tag per line. In * Firefox, the resulting HTML uses BR tags at the end of each line. * * @constructor * @extends {goog.editor.Plugin} */ goog.editor.plugins.EnterHandler = function() { goog.editor.Plugin.call(this); }; goog.inherits(goog.editor.plugins.EnterHandler, goog.editor.Plugin); /** * The type of block level tag to add on enter, for browsers that support * specifying the default block-level tag. Can be overriden by subclasses; must * be either DIV or P. * @type {goog.dom.TagName} * @protected */ goog.editor.plugins.EnterHandler.prototype.tag = goog.dom.TagName.DIV; /** @override */ goog.editor.plugins.EnterHandler.prototype.getTrogClassId = function() { return 'EnterHandler'; }; /** @override */ goog.editor.plugins.EnterHandler.prototype.enable = function(fieldObject) { goog.base(this, 'enable', fieldObject); if (goog.editor.BrowserFeature.SUPPORTS_OPERA_DEFAULTBLOCK_COMMAND && (this.tag == goog.dom.TagName.P || this.tag == goog.dom.TagName.DIV)) { var doc = this.getFieldDomHelper().getDocument(); doc.execCommand('opera-defaultBlock', false, this.tag); } }; /** * If the contents are empty, return the 'default' html for the field. * The 'default' contents depend on the enter handling mode, so it * makes the most sense in this plugin. * @param {string} html The html to prepare. * @return {string} The original HTML, or default contents if that * html is empty. * @override */ goog.editor.plugins.EnterHandler.prototype.prepareContentsHtml = function( html) { if (!html || goog.string.isBreakingWhitespace(html)) { return goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES ? this.getNonCollapsingBlankHtml() : ''; } return html; }; /** * Gets HTML with no contents that won't collapse, for browsers that * collapse the empty string. * @return {string} Blank html. * @protected */ goog.editor.plugins.EnterHandler.prototype.getNonCollapsingBlankHtml = goog.functions.constant('
'); /** * Internal backspace handler. * @param {goog.events.Event} e The keypress event. * @param {goog.dom.AbstractRange} range The closure range object. * @protected */ goog.editor.plugins.EnterHandler.prototype.handleBackspaceInternal = function(e, range) { var field = this.getFieldObject().getElement(); var container = range && range.getStartNode(); if (field.firstChild == container && goog.editor.node.isEmpty(container)) { e.preventDefault(); // TODO(user): I think we probably don't need to stopPropagation here e.stopPropagation(); } }; /** * Fix paragraphs to be the correct type of node. * @param {goog.events.Event} e The key event. * @param {boolean} split Whether we already split up a blockquote by * manually inserting elements. * @protected */ goog.editor.plugins.EnterHandler.prototype.processParagraphTagsInternal = function(e, split) { // Force IE to turn the node we are leaving into a DIV. If we do turn // it into a DIV, the node IE creates in response to ENTER will also be // a DIV. If we don't, it will be a P. We handle that case // in handleKeyUpIE_ if (goog.userAgent.IE || goog.userAgent.OPERA) { this.ensureBlockIeOpera(goog.dom.TagName.DIV); } else if (!split && goog.userAgent.WEBKIT) { // WebKit duplicates a blockquote when the user hits enter. Let's cancel // this and insert a BR instead, to make it more consistent with the other // browsers. var range = this.getFieldObject().getRange(); if (!range || !goog.editor.plugins.EnterHandler.isDirectlyInBlockquote( range.getContainerElement())) { return; } var dh = this.getFieldDomHelper(); var br = dh.createElement(goog.dom.TagName.BR); range.insertNode(br, true); // If the BR is at the end of a block element, Safari still thinks there is // only one line instead of two, so we need to add another BR in that case. if (goog.editor.node.isBlockTag(br.parentNode) && !goog.editor.node.skipEmptyTextNodes(br.nextSibling)) { goog.dom.insertSiblingBefore( dh.createElement(goog.dom.TagName.BR), br); } goog.editor.range.placeCursorNextTo(br, false); e.preventDefault(); } }; /** * Determines whether the lowest containing block node is a blockquote. * @param {Node} n The node. * @return {boolean} Whether the deepest block ancestor of n is a blockquote. */ goog.editor.plugins.EnterHandler.isDirectlyInBlockquote = function(n) { for (var current = n; current; current = current.parentNode) { if (goog.editor.node.isBlockTag(current)) { return current.tagName == goog.dom.TagName.BLOCKQUOTE; } } return false; }; /** * Internal delete key handler. * @param {goog.events.Event} e The keypress event. * @protected */ goog.editor.plugins.EnterHandler.prototype.handleDeleteGecko = function(e) { this.deleteBrGecko(e); }; /** * Deletes the element at the cursor if it is a BR node, and if it does, calls * e.preventDefault to stop the browser from deleting. Only necessary in Gecko * as a workaround for mozilla bug 205350 where deleting a BR that is followed * by a block element doesn't work (the BR gets immediately replaced). We also * need to account for an ill-formed cursor which occurs from us trying to * stop the browser from deleting. * * @param {goog.events.Event} e The DELETE keypress event. * @protected */ goog.editor.plugins.EnterHandler.prototype.deleteBrGecko = function(e) { var range = this.getFieldObject().getRange(); if (range.isCollapsed()) { var container = range.getEndNode(); if (container.nodeType == goog.dom.NodeType.ELEMENT) { var nextNode = container.childNodes[range.getEndOffset()]; if (nextNode && nextNode.tagName == goog.dom.TagName.BR) { // We want to retrieve the first non-whitespace previous sibling // as we could have added an empty text node below and want to // properly handle deleting a sequence of BR's. var previousSibling = goog.editor.node.getPreviousSibling(nextNode); var nextSibling = nextNode.nextSibling; container.removeChild(nextNode); e.preventDefault(); // When we delete a BR followed by a block level element, the cursor // has a line-height which spans the height of the block level element. // e.g. If we delete a BR followed by a UL, the resulting HTML will // appear to the end user like:- // // | * one // | * two // | * three // // There are a couple of cases that we have to account for in order to // properly conform to what the user expects when DELETE is pressed. // // 1. If the BR has a previous sibling and the previous sibling is // not a block level element or a BR, we place the cursor at the // end of that. // 2. If the BR doesn't have a previous sibling or the previous sibling // is a block level element or a BR, we place the cursor at the // beginning of the leftmost leaf of its next sibling. if (nextSibling && goog.editor.node.isBlockTag(nextSibling)) { if (previousSibling && !(previousSibling.tagName == goog.dom.TagName.BR || goog.editor.node.isBlockTag(previousSibling))) { goog.dom.Range.createCaret( previousSibling, goog.editor.node.getLength(previousSibling)).select(); } else { var leftMostLeaf = goog.editor.node.getLeftMostLeaf(nextSibling); goog.dom.Range.createCaret(leftMostLeaf, 0).select(); } } } } } }; /** @override */ goog.editor.plugins.EnterHandler.prototype.handleKeyPress = function(e) { // If a dialog doesn't have selectable field, Gecko grabs the event and // performs actions in editor window. This solves that problem and allows // the event to be passed on to proper handlers. if (goog.userAgent.GECKO && this.getFieldObject().inModalMode()) { return false; } // Firefox will allow the first node in an iframe to be deleted // on a backspace. Disallow it if the node is empty. if (e.keyCode == goog.events.KeyCodes.BACKSPACE) { this.handleBackspaceInternal(e, this.getFieldObject().getRange()); } else if (e.keyCode == goog.events.KeyCodes.ENTER) { if (goog.userAgent.GECKO) { if (!e.shiftKey) { // Behave similarly to IE's content editable return carriage: // If the shift key is down or specified by the application, insert a // BR, otherwise split paragraphs this.handleEnterGecko_(e); } } else { // In Gecko-based browsers, this is handled in the handleEnterGecko_ // method. this.getFieldObject().dispatchBeforeChange(); var cursorPosition = this.deleteCursorSelection_(); var split = !!this.getFieldObject().execCommand( goog.editor.plugins.Blockquote.SPLIT_COMMAND, cursorPosition); if (split) { // TODO(user): I think we probably don't need to stopPropagation here e.preventDefault(); e.stopPropagation(); } this.releasePositionObject_(cursorPosition); if (goog.userAgent.WEBKIT) { this.handleEnterWebkitInternal(e); } this.processParagraphTagsInternal(e, split); this.getFieldObject().dispatchChange(); } } else if (goog.userAgent.GECKO && e.keyCode == goog.events.KeyCodes.DELETE) { this.handleDeleteGecko(e); } return false; }; /** @override */ goog.editor.plugins.EnterHandler.prototype.handleKeyUp = function(e) { // If a dialog doesn't have selectable field, Gecko grabs the event and // performs actions in editor window. This solves that problem and allows // the event to be passed on to proper handlers. if (goog.userAgent.GECKO && this.getFieldObject().inModalMode()) { return false; } this.handleKeyUpInternal(e); return false; }; /** * Internal handler for keyup events. * @param {goog.events.Event} e The key event. * @protected */ goog.editor.plugins.EnterHandler.prototype.handleKeyUpInternal = function(e) { if ((goog.userAgent.IE || goog.userAgent.OPERA) && e.keyCode == goog.events.KeyCodes.ENTER) { this.ensureBlockIeOpera(goog.dom.TagName.DIV, true); } }; /** * Handles an enter keypress event on fields in Gecko. * @param {goog.events.BrowserEvent} e The key event. * @private */ goog.editor.plugins.EnterHandler.prototype.handleEnterGecko_ = function(e) { // Retrieve whether the selection is collapsed before we delete it. var range = this.getFieldObject().getRange(); var wasCollapsed = !range || range.isCollapsed(); var cursorPosition = this.deleteCursorSelection_(); var handled = this.getFieldObject().execCommand( goog.editor.plugins.Blockquote.SPLIT_COMMAND, cursorPosition); if (handled) { // TODO(user): I think we probably don't need to stopPropagation here e.preventDefault(); e.stopPropagation(); } this.releasePositionObject_(cursorPosition); if (!handled) { this.handleEnterAtCursorGeckoInternal(e, wasCollapsed, range); } }; /** * Handle an enter key press in WebKit. * @param {goog.events.BrowserEvent} e The key press event. * @protected */ goog.editor.plugins.EnterHandler.prototype.handleEnterWebkitInternal = goog.nullFunction; /** * Handle an enter key press on collapsed selection. handleEnterGecko_ ensures * the selection is collapsed by deleting its contents if it is not. The * default implementation does nothing. * @param {goog.events.BrowserEvent} e The key press event. * @param {boolean} wasCollapsed Whether the selection was collapsed before * the key press. If it was not, code before this function has already * cleared the contents of the selection. * @param {goog.dom.AbstractRange} range Object representing the selection. * @protected */ goog.editor.plugins.EnterHandler.prototype.handleEnterAtCursorGeckoInternal = goog.nullFunction; /** * Names of all the nodes that we don't want to turn into block nodes in IE when * the user hits enter. * @type {Object} * @private */ goog.editor.plugins.EnterHandler.DO_NOT_ENSURE_BLOCK_NODES_ = goog.object.createSet( goog.dom.TagName.LI, goog.dom.TagName.DIV, goog.dom.TagName.H1, goog.dom.TagName.H2, goog.dom.TagName.H3, goog.dom.TagName.H4, goog.dom.TagName.H5, goog.dom.TagName.H6); /** * Whether this is a node that contains a single BR tag and non-nbsp * whitespace. * @param {Node} node Node to check. * @return {boolean} Whether this is an element that only contains a BR. * @protected */ goog.editor.plugins.EnterHandler.isBrElem = function(node) { return goog.editor.node.isEmpty(node) && node.getElementsByTagName(goog.dom.TagName.BR).length == 1; }; /** * Ensures all text in IE and Opera to be in the given tag in order to control * Enter spacing. Call this when Enter is pressed if desired. * * We want to make sure the user is always inside of a block (or other nodes * listed in goog.editor.plugins.EnterHandler.IGNORE_ENSURE_BLOCK_NODES_). We * listen to keypress to force nodes that the user is leaving to turn into * blocks, but we also need to listen to keyup to force nodes that the user is * entering to turn into blocks. * Example: html is: "

foo[cursor]

", and the user hits enter. We * don't want to format the h2, but we do want to format the P that is * created on enter. The P node is not available until keyup. * @param {goog.dom.TagName} tag The tag name to convert to. * @param {boolean=} opt_keyUp Whether the function is being called on key up. * When called on key up, the cursor is in the newly created node, so the * semantics for when to change it to a block are different. Specifically, * if the resulting node contains only a BR, it is converted to . * @protected */ goog.editor.plugins.EnterHandler.prototype.ensureBlockIeOpera = function(tag, opt_keyUp) { var range = this.getFieldObject().getRange(); var container = range.getContainer(); var field = this.getFieldObject().getElement(); var paragraph; while (container && container != field) { // We don't need to ensure a block if we are already in the same block, or // in another block level node that we don't want to change the format of // (unless we're handling keyUp and that block node just contains a BR). var nodeName = container.nodeName; // Due to @bug 2455389, the call to isBrElem needs to be inlined in the if // instead of done before and saved in a variable, so that it can be // short-circuited and avoid a weird IE edge case. if (nodeName == tag || (goog.editor.plugins.EnterHandler. DO_NOT_ENSURE_BLOCK_NODES_[nodeName] && !(opt_keyUp && goog.editor.plugins.EnterHandler.isBrElem(container)))) { // Opera can create a

inside of a

in some situations, // such as when breaking out of a list that is contained in a
. if (goog.userAgent.OPERA && paragraph) { if (nodeName == tag && paragraph == container.lastChild && goog.editor.node.isEmpty(paragraph)) { goog.dom.insertSiblingAfter(paragraph, container); goog.dom.Range.createFromNodeContents(paragraph).select(); } break; } return; } if (goog.userAgent.OPERA && opt_keyUp && nodeName == goog.dom.TagName.P && nodeName != tag) { paragraph = container; } container = container.parentNode; } if (goog.userAgent.IE && !goog.userAgent.isVersion(9)) { // IE (before IE9) has a bug where if the cursor is directly before a block // node (e.g., the content is "foo[cursor]
bar
"), // the FormatBlock command actually formats the "bar" instead of the "foo". // This is just wrong. To work-around this, we want to move the // selection back one character, and then restore it to its prior position. // NOTE: We use the following "range math" to detect this situation because // using Closure ranges here triggers a bug in IE that causes a crash. // parent2 != parent3 ensures moving the cursor forward one character // crosses at least 1 element boundary, and therefore tests if the cursor is // at such a boundary. The second check, parent3 != range.parentElement() // weeds out some cases where the elements are siblings instead of cousins. var needsHelp = false; range = range.getBrowserRangeObject(); var range2 = range.duplicate(); range2.moveEnd('character', 1); // In whitebox mode, when the cursor is at the end of the field, trying to // move the end of the range will do nothing, and hence the range's text // will be empty. In this case, the cursor clearly isn't sitting just // before a block node, since it isn't before anything. if (range2.text.length) { var parent2 = range2.parentElement(); var range3 = range2.duplicate(); range3.collapse(false); var parent3 = range3.parentElement(); if ((needsHelp = parent2 != parent3 && parent3 != range.parentElement())) { range.move('character', -1); range.select(); } } } this.getFieldObject().getEditableDomHelper().getDocument().execCommand( 'FormatBlock', false, '<' + tag + '>'); if (needsHelp) { range.move('character', 1); range.select(); } }; /** * Deletes the content at the current cursor position. * @return {Node|Object} Something representing the current cursor position. * See deleteCursorSelectionIE_ and deleteCursorSelectionW3C_ for details. * Should be passed to releasePositionObject_ when no longer in use. * @private */ goog.editor.plugins.EnterHandler.prototype.deleteCursorSelection_ = function() { return goog.editor.BrowserFeature.HAS_W3C_RANGES ? this.deleteCursorSelectionW3C_() : this.deleteCursorSelectionIE_(); }; /** * Releases the object returned by deleteCursorSelection_. * @param {Node|Object} position The object returned by deleteCursorSelection_. * @private */ goog.editor.plugins.EnterHandler.prototype.releasePositionObject_ = function(position) { if (!goog.editor.BrowserFeature.HAS_W3C_RANGES) { (/** @type {Node} */ (position)).removeNode(true); } }; /** * Delete the selection at the current cursor position, then returns a temporary * node at the current position. * @return {Node} A temporary node marking the current cursor position. This * node should eventually be removed from the DOM. * @private */ goog.editor.plugins.EnterHandler.prototype.deleteCursorSelectionIE_ = function() { var doc = this.getFieldDomHelper().getDocument(); var range = doc.selection.createRange(); var id = goog.string.createUniqueString(); range.pasteHTML(''); var splitNode = doc.getElementById(id); splitNode.id = ''; return splitNode; }; /** * Delete the selection at the current cursor position, then returns the node * at the current position. * @return {goog.editor.range.Point} The current cursor position. Note that * unlike simulateEnterIE_, this should not be removed from the DOM. * @private */ goog.editor.plugins.EnterHandler.prototype.deleteCursorSelectionW3C_ = function() { var range = this.getFieldObject().getRange(); // Delete the current selection if it's is non-collapsed. // Although this is redundant in FF, it's necessary for Safari if (!range.isCollapsed()) { var shouldDelete = true; // Opera selects the
in an empty block if there is no text node // preceding it. To preserve inline formatting when pressing [enter] inside // an empty block, don't delete the selection if it only selects a
at // the end of the block. // TODO(user): Move this into goog.dom.Range. It should detect this state // when creating a range from the window selection and fix it in the created // range. if (goog.userAgent.OPERA) { var startNode = range.getStartNode(); var startOffset = range.getStartOffset(); if (startNode == range.getEndNode() && // This weeds out cases where startNode is a text node. startNode.lastChild && startNode.lastChild.tagName == goog.dom.TagName.BR && // If this check is true, then endOffset is implied to be // startOffset + 1, because the selection is not collapsed and // it starts and ends within the same element. startOffset == startNode.childNodes.length - 1) { shouldDelete = false; } } if (shouldDelete) { goog.editor.plugins.EnterHandler.deleteW3cRange_(range); } } return goog.editor.range.getDeepEndPoint(range, true); }; /** * Deletes the contents of the selection from the DOM. * @param {goog.dom.AbstractRange} range The range to remove contents from. * @return {goog.dom.AbstractRange} The resulting range. Used for testing. * @private */ goog.editor.plugins.EnterHandler.deleteW3cRange_ = function(range) { if (range && !range.isCollapsed()) { var reselect = true; var baseNode = range.getContainerElement(); var nodeOffset = new goog.dom.NodeOffset(range.getStartNode(), baseNode); var rangeOffset = range.getStartOffset(); // Whether the selection crosses no container boundaries. var isInOneContainer = goog.editor.plugins.EnterHandler.isInOneContainerW3c_(range); // Whether the selection ends in a container it doesn't fully select. var isPartialEnd = !isInOneContainer && goog.editor.plugins.EnterHandler.isPartialEndW3c_(range); // Remove The range contents, and ensure the correct content stays selected. range.removeContents(); var node = nodeOffset.findTargetNode(baseNode); if (node) { range = goog.dom.Range.createCaret(node, rangeOffset); } else { // This occurs when the node that would have been referenced has now been // deleted and there are no other nodes in the baseNode. Thus need to // set the caret to the end of the base node. range = goog.dom.Range.createCaret(baseNode, baseNode.childNodes.length); reselect = false; } range.select(); // If we just deleted everything from the container, add an nbsp // to the container, and leave the cursor inside of it if (isInOneContainer) { var container = goog.editor.style.getContainer(range.getStartNode()); if (goog.editor.node.isEmpty(container, true)) { var html = ' '; if (goog.userAgent.OPERA && container.tagName == goog.dom.TagName.LI) { // Don't break Opera's native break-out-of-lists behavior. html = '
'; } container.innerHTML = html; goog.editor.range.selectNodeStart(container.firstChild); reselect = false; } } if (isPartialEnd) { /* This code handles the following, where | is the cursor:
a|b
c|d
After removeContents, the remaining HTML is
a
d
which means the line break between the two divs remains. This block moves children of the second div in to the first div to get the correct result:
ad
TODO(robbyw): Should we wrap the second div's contents in a span if they have inline style? */ var rangeStart = goog.editor.style.getContainer(range.getStartNode()); var redundantContainer = goog.editor.node.getNextSibling(rangeStart); if (rangeStart && redundantContainer) { goog.dom.append(rangeStart, redundantContainer.childNodes); goog.dom.removeNode(redundantContainer); } } if (reselect) { // The contents of the original range are gone, so restore the cursor // position at the start of where the range once was. range = goog.dom.Range.createCaret(nodeOffset.findTargetNode(baseNode), rangeOffset); range.select(); } } return range; }; /** * Checks whether the whole range is in a single block-level element. * @param {goog.dom.AbstractRange} range The range to check. * @return {boolean} Whether the whole range is in a single block-level element. * @private */ goog.editor.plugins.EnterHandler.isInOneContainerW3c_ = function(range) { // Find the block element containing the start of the selection. var startContainer = range.getStartNode(); if (goog.editor.style.isContainer(startContainer)) { startContainer = startContainer.childNodes[range.getStartOffset()] || startContainer; } startContainer = goog.editor.style.getContainer(startContainer); // Find the block element containing the end of the selection. var endContainer = range.getEndNode(); if (goog.editor.style.isContainer(endContainer)) { endContainer = endContainer.childNodes[range.getEndOffset()] || endContainer; } endContainer = goog.editor.style.getContainer(endContainer); // Compare the two. return startContainer == endContainer; }; /** * Checks whether the end of the range is not at the end of a block-level * element. * @param {goog.dom.AbstractRange} range The range to check. * @return {boolean} Whether the end of the range is not at the end of a * block-level element. * @private */ goog.editor.plugins.EnterHandler.isPartialEndW3c_ = function(range) { var endContainer = range.getEndNode(); var endOffset = range.getEndOffset(); var node = endContainer; if (goog.editor.style.isContainer(node)) { var child = node.childNodes[endOffset]; // Child is null when end offset is >= length, which indicates the entire // container is selected. Otherwise, we also know the entire container // is selected if the selection ends at a new container. if (!child || child.nodeType == goog.dom.NodeType.ELEMENT && goog.editor.style.isContainer(child)) { return false; } } var container = goog.editor.style.getContainer(node); while (container != node) { if (goog.editor.node.getNextSibling(node)) { return true; } node = node.parentNode; } return endOffset != goog.editor.node.getLength(endContainer); };