Adding mapbox-gl branch

This commit is contained in:
Andreas Hocevar
2015-03-16 18:50:27 +01:00
parent 7985f030fa
commit 57ee7f52fd
3109 changed files with 943365 additions and 0 deletions
@@ -0,0 +1,712 @@
// Copyright 2005 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 Base class for bubble plugins.
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.AbstractBubblePlugin');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.dom.classlist');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.style');
goog.require('goog.events');
goog.require('goog.events.EventHandler');
goog.require('goog.events.EventType');
goog.require('goog.events.KeyCodes');
goog.require('goog.events.actionEventWrapper');
goog.require('goog.functions');
goog.require('goog.string.Unicode');
goog.require('goog.ui.Component');
goog.require('goog.ui.editor.Bubble');
goog.require('goog.userAgent');
/**
* Base class for bubble plugins. This is used for to connect user behavior
* in the editor to a goog.ui.editor.Bubble UI element that allows
* the user to modify the properties of an element on their page (e.g. the alt
* text of an image tag).
*
* Subclasses should override the abstract method getBubbleTargetFromSelection()
* with code to determine if the current selection should activate the bubble
* type. The other abstract method createBubbleContents() should be overriden
* with code to create the inside markup of the bubble. The base class creates
* the rest of the bubble.
*
* @constructor
* @extends {goog.editor.Plugin}
*/
goog.editor.plugins.AbstractBubblePlugin = function() {
goog.editor.plugins.AbstractBubblePlugin.base(this, 'constructor');
/**
* Place to register events the plugin listens to.
* @type {goog.events.EventHandler<
* !goog.editor.plugins.AbstractBubblePlugin>}
* @protected
*/
this.eventRegister = new goog.events.EventHandler(this);
/**
* Instance factory function that creates a bubble UI component. If set to a
* non-null value, this function will be used to create a bubble instead of
* the global factory function. It takes as parameters the bubble parent
* element and the z index to draw the bubble at.
* @type {?function(!Element, number): !goog.ui.editor.Bubble}
* @private
*/
this.bubbleFactory_ = null;
};
goog.inherits(goog.editor.plugins.AbstractBubblePlugin, goog.editor.Plugin);
/**
* The css class name of option link elements.
* @type {string}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.OPTION_LINK_CLASSNAME_ =
goog.getCssName('tr_option-link');
/**
* The css class name of link elements.
* @type {string}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.LINK_CLASSNAME_ =
goog.getCssName('tr_bubble_link');
/**
* A class name to mark elements that should be reachable by keyboard tabbing.
* @type {string}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_ =
goog.getCssName('tr_bubble_tabbable');
/**
* The constant string used to separate option links.
* @type {string}
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.DASH_NBSP_STRING =
goog.string.Unicode.NBSP + '-' + goog.string.Unicode.NBSP;
/**
* Default factory function for creating a bubble UI component.
* @param {!Element} parent The parent element for the bubble.
* @param {number} zIndex The z index to draw the bubble at.
* @return {!goog.ui.editor.Bubble} The new bubble component.
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.defaultBubbleFactory_ = function(
parent, zIndex) {
return new goog.ui.editor.Bubble(parent, zIndex);
};
/**
* Global factory function that creates a bubble UI component. It takes as
* parameters the bubble parent element and the z index to draw the bubble at.
* @type {function(!Element, number): !goog.ui.editor.Bubble}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_ =
goog.editor.plugins.AbstractBubblePlugin.defaultBubbleFactory_;
/**
* Sets the global bubble factory function.
* @param {function(!Element, number): !goog.ui.editor.Bubble}
* bubbleFactory Function that creates a bubble for the given bubble parent
* element and z index.
*/
goog.editor.plugins.AbstractBubblePlugin.setBubbleFactory = function(
bubbleFactory) {
goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_ = bubbleFactory;
};
/**
* Map from field id to shared bubble object.
* @type {!Object<goog.ui.editor.Bubble>}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.bubbleMap_ = {};
/**
* The optional parent of the bubble. If null or not set, we will use the
* application document. This is useful when you have an editor embedded in
* a scrolling DIV.
* @type {Element|undefined}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.bubbleParent_;
/**
* The id of the panel this plugin added to the shared bubble. Null when
* this plugin doesn't currently have a panel in a bubble.
* @type {string?}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.panelId_ = null;
/**
* Whether this bubble should support tabbing through elements. False
* by default.
* @type {boolean}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.keyboardNavigationEnabled_ =
false;
/**
* Sets the instance bubble factory function. If set to a non-null value, this
* function will be used to create a bubble instead of the global factory
* function.
* @param {?function(!Element, number): !goog.ui.editor.Bubble} bubbleFactory
* Function that creates a bubble for the given bubble parent element and z
* index. Null to reset the factory function.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.setBubbleFactory = function(
bubbleFactory) {
this.bubbleFactory_ = bubbleFactory;
};
/**
* Sets whether the bubble should support tabbing through elements.
* @param {boolean} keyboardNavigationEnabled
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.enableKeyboardNavigation =
function(keyboardNavigationEnabled) {
this.keyboardNavigationEnabled_ = keyboardNavigationEnabled;
};
/**
* Sets the bubble parent.
* @param {Element} bubbleParent An element where the bubble will be
* anchored. If null, we will use the application document. This
* is useful when you have an editor embedded in a scrolling div.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.setBubbleParent = function(
bubbleParent) {
this.bubbleParent_ = bubbleParent;
};
/**
* Returns the bubble map. Subclasses may override to use a separate map.
* @return {!Object<goog.ui.editor.Bubble>}
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleMap = function() {
return goog.editor.plugins.AbstractBubblePlugin.bubbleMap_;
};
/**
* @return {goog.dom.DomHelper} The dom helper for the bubble window.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleDom = function() {
return this.dom_;
};
/** @override */
goog.editor.plugins.AbstractBubblePlugin.prototype.getTrogClassId =
goog.functions.constant('AbstractBubblePlugin');
/**
* Returns the element whose properties the bubble manipulates.
* @return {Element} The target element.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getTargetElement =
function() {
return this.targetElement_;
};
/** @override */
goog.editor.plugins.AbstractBubblePlugin.prototype.handleKeyUp = function(e) {
// For example, when an image is selected, pressing any key overwrites
// the image and the panel should be hidden.
// Therefore we need to track key presses when the bubble is showing.
if (this.isVisible()) {
this.handleSelectionChange();
}
return false;
};
/**
* Pops up a property bubble for the given selection if appropriate and closes
* open property bubbles if no longer needed. This should not be overridden.
* @override
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.handleSelectionChange =
function(opt_e, opt_target) {
var selectedElement;
if (opt_e) {
selectedElement = /** @type {Element} */ (opt_e.target);
} else if (opt_target) {
selectedElement = /** @type {Element} */ (opt_target);
} else {
var range = this.getFieldObject().getRange();
if (range) {
var startNode = range.getStartNode();
var endNode = range.getEndNode();
var startOffset = range.getStartOffset();
var endOffset = range.getEndOffset();
// Sometimes in IE, the range will be collapsed, but think the end node
// and start node are different (although in the same visible position).
// In this case, favor the position IE thinks is the start node.
if (goog.userAgent.IE && range.isCollapsed() && startNode != endNode) {
range = goog.dom.Range.createCaret(startNode, startOffset);
}
if (startNode.nodeType == goog.dom.NodeType.ELEMENT &&
startNode == endNode && startOffset == endOffset - 1) {
var element = startNode.childNodes[startOffset];
if (element.nodeType == goog.dom.NodeType.ELEMENT) {
selectedElement = element;
}
}
}
selectedElement = selectedElement || range && range.getContainerElement();
}
return this.handleSelectionChangeInternal(selectedElement);
};
/**
* Pops up a property bubble for the given selection if appropriate and closes
* open property bubbles if no longer needed.
* @param {Element?} selectedElement The selected element.
* @return {boolean} Always false, allowing every bubble plugin to handle the
* event.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.
handleSelectionChangeInternal = function(selectedElement) {
if (selectedElement) {
var bubbleTarget = this.getBubbleTargetFromSelection(selectedElement);
if (bubbleTarget) {
if (bubbleTarget != this.targetElement_ || !this.panelId_) {
// Make sure any existing panel of the same type is closed before
// creating a new one.
if (this.panelId_) {
this.closeBubble();
}
this.createBubble(bubbleTarget);
}
return false;
}
}
if (this.panelId_) {
this.closeBubble();
}
return false;
};
/**
* Should be overriden by subclasses to return the bubble target element or
* null if an element of their required type isn't found.
* @param {Element} selectedElement The target of the selection change event or
* the parent container of the current entire selection.
* @return {Element?} The HTML bubble target element or null if no element of
* the required type is not found.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.
getBubbleTargetFromSelection = goog.abstractMethod;
/** @override */
goog.editor.plugins.AbstractBubblePlugin.prototype.disable = function(field) {
// When the field is made uneditable, dispose of the bubble. We do this
// because the next time the field is made editable again it may be in
// a different document / iframe.
if (field.isUneditable()) {
var bubbleMap = this.getBubbleMap();
var bubble = bubbleMap[field.id];
if (bubble) {
if (field == this.getFieldObject()) {
this.closeBubble();
}
bubble.dispose();
delete bubbleMap[field.id];
}
}
};
/**
* @return {!goog.ui.editor.Bubble} The shared bubble object for the field this
* plugin is registered on. Creates it if necessary.
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getSharedBubble_ =
function() {
var bubbleParent = /** @type {!Element} */ (this.bubbleParent_ ||
this.getFieldObject().getAppWindow().document.body);
this.dom_ = goog.dom.getDomHelper(bubbleParent);
var bubbleMap = this.getBubbleMap();
var bubble = bubbleMap[this.getFieldObject().id];
if (!bubble) {
var factory = this.bubbleFactory_ ||
goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_;
bubble = factory.call(null, bubbleParent,
this.getFieldObject().getBaseZindex());
bubbleMap[this.getFieldObject().id] = bubble;
}
return bubble;
};
/**
* Creates and shows the property bubble.
* @param {Element} targetElement The target element of the bubble.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.createBubble = function(
targetElement) {
var bubble = this.getSharedBubble_();
if (!bubble.hasPanelOfType(this.getBubbleType())) {
this.targetElement_ = targetElement;
this.panelId_ = bubble.addPanel(this.getBubbleType(), this.getBubbleTitle(),
targetElement,
goog.bind(this.createBubbleContents, this),
this.shouldPreferBubbleAboveElement());
this.eventRegister.listen(bubble, goog.ui.Component.EventType.HIDE,
this.handlePanelClosed_);
this.onShow();
if (this.keyboardNavigationEnabled_) {
this.eventRegister.listen(bubble.getContentElement(),
goog.events.EventType.KEYDOWN, this.onBubbleKey_);
}
}
};
/**
* @return {string} The type of bubble shown by this plugin. Usually the tag
* name of the element this bubble targets.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleType = function() {
return '';
};
/**
* @return {string} The title for bubble shown by this plugin. Defaults to no
* title. Should be overridden by subclasses.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleTitle = function() {
return '';
};
/**
* @return {boolean} Whether the bubble should prefer placement above the
* target element.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.
shouldPreferBubbleAboveElement = goog.functions.FALSE;
/**
* Should be overriden by subclasses to add the type specific contents to the
* bubble.
* @param {Element} bubbleContainer The container element of the bubble to
* which the contents should be added.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.createBubbleContents =
goog.abstractMethod;
/**
* Register the handler for the target's CLICK event.
* @param {Element} target The event source element.
* @param {Function} handler The event handler.
* @protected
* @deprecated Use goog.editor.plugins.AbstractBubblePlugin.
* registerActionHandler to register click and enter events.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.registerClickHandler =
function(target, handler) {
this.registerActionHandler(target, handler);
};
/**
* Register the handler for the target's CLICK and ENTER key events.
* @param {Element} target The event source element.
* @param {Function} handler The event handler.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.registerActionHandler =
function(target, handler) {
this.eventRegister.listenWithWrapper(target, goog.events.actionEventWrapper,
handler);
};
/**
* Closes the bubble.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.closeBubble = function() {
if (this.panelId_) {
this.getSharedBubble_().removePanel(this.panelId_);
this.handlePanelClosed_();
}
};
/**
* Called after the bubble is shown. The default implementation does nothing.
* Override it to provide your own one.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.onShow = goog.nullFunction;
/**
* Called when the bubble is closed or hidden. The default implementation does
* nothing.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.cleanOnBubbleClose =
goog.nullFunction;
/**
* Handles when the bubble panel is closed. Invoked when the entire bubble is
* hidden and also directly when the panel is closed manually.
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.handlePanelClosed_ =
function() {
this.targetElement_ = null;
this.panelId_ = null;
this.eventRegister.removeAll();
this.cleanOnBubbleClose();
};
/**
* In case the keyboard navigation is enabled, this will set focus on the first
* tabbable element in the bubble when TAB is clicked.
* @override
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.handleKeyDown = function(e) {
if (this.keyboardNavigationEnabled_ &&
this.isVisible() &&
e.keyCode == goog.events.KeyCodes.TAB && !e.shiftKey) {
var bubbleEl = this.getSharedBubble_().getContentElement();
var tabbable = goog.dom.getElementByClass(
goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_, bubbleEl);
if (tabbable) {
tabbable.focus();
e.preventDefault();
return true;
}
}
return false;
};
/**
* Handles a key event on the bubble. This ensures that the focus loops through
* the tabbable elements found in the bubble and then the focus is got by the
* field element.
* @param {goog.events.BrowserEvent} e The event.
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.onBubbleKey_ = function(e) {
if (this.isVisible() &&
e.keyCode == goog.events.KeyCodes.TAB) {
var bubbleEl = this.getSharedBubble_().getContentElement();
var tabbables = goog.dom.getElementsByClass(
goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_, bubbleEl);
var tabbable = e.shiftKey ? tabbables[0] : goog.array.peek(tabbables);
var tabbingOutOfBubble = tabbable == e.target;
if (tabbingOutOfBubble) {
this.getFieldObject().focus();
e.preventDefault();
}
}
};
/**
* @return {boolean} Whether the bubble is visible.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.isVisible = function() {
return !!this.panelId_;
};
/**
* Reposition the property bubble.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.reposition = function() {
var bubble = this.getSharedBubble_();
if (bubble) {
bubble.reposition();
}
};
/**
* Helper method that creates option links (such as edit, test, remove)
* @param {string} id String id for the span id.
* @return {Element} The option link element.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.createLinkOption = function(
id) {
// Dash plus link are together in a span so we can hide/show them easily
return this.dom_.createDom(goog.dom.TagName.SPAN,
{
id: id,
className:
goog.editor.plugins.AbstractBubblePlugin.OPTION_LINK_CLASSNAME_
},
this.dom_.createTextNode(
goog.editor.plugins.AbstractBubblePlugin.DASH_NBSP_STRING));
};
/**
* Helper method that creates a link with text set to linkText and optionally
* wires up a listener for the CLICK event or the link. The link is navigable by
* tabs if {@code enableKeyboardNavigation(true)} was called.
* @param {string} linkId The id of the link.
* @param {string} linkText Text of the link.
* @param {Function=} opt_onClick Optional function to call when the link is
* clicked.
* @param {Element=} opt_container If specified, location to insert link. If no
* container is specified, the old link is removed and replaced.
* @return {Element} The link element.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.createLink = function(
linkId, linkText, opt_onClick, opt_container) {
var link = this.createLinkHelper(linkId, linkText, false, opt_container);
if (opt_onClick) {
this.registerActionHandler(link, opt_onClick);
}
return link;
};
/**
* Helper method to create a link to insert into the bubble. The link is
* navigable by tabs if {@code enableKeyboardNavigation(true)} was called.
* @param {string} linkId The id of the link.
* @param {string} linkText Text of the link.
* @param {boolean} isAnchor Set to true to create an actual anchor tag
* instead of a span. Actual links are right clickable (e.g. to open in
* a new window) and also update window status on hover.
* @param {Element=} opt_container If specified, location to insert link. If no
* container is specified, the old link is removed and replaced.
* @return {Element} The link element.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.createLinkHelper = function(
linkId, linkText, isAnchor, opt_container) {
var link = this.dom_.createDom(
isAnchor ? goog.dom.TagName.A : goog.dom.TagName.SPAN,
{className: goog.editor.plugins.AbstractBubblePlugin.LINK_CLASSNAME_},
linkText);
if (this.keyboardNavigationEnabled_) {
this.setTabbable(link);
}
link.setAttribute('role', 'link');
this.setupLink(link, linkId, opt_container);
goog.editor.style.makeUnselectable(link, this.eventRegister);
return link;
};
/**
* Makes the given element tabbable.
*
* <p>Elements created by createLink[Helper] are tabbable even without
* calling this method. Call it for other elements if needed.
*
* <p>If tabindex is not already set in the element, this function sets it to 0.
* You'll usually want to also call {@code enableKeyboardNavigation(true)}.
*
* @param {!Element} element
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.setTabbable =
function(element) {
if (!element.hasAttribute('tabindex')) {
element.setAttribute('tabindex', 0);
}
goog.dom.classlist.add(element,
goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_);
};
/**
* Inserts a link in the given container if it is specified or removes
* the old link with this id and replaces it with the new link
* @param {Element} link Html element to insert.
* @param {string} linkId Id of the link.
* @param {Element=} opt_container If specified, location to insert link.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.setupLink = function(
link, linkId, opt_container) {
if (opt_container) {
opt_container.appendChild(link);
} else {
var oldLink = this.dom_.getElement(linkId);
if (oldLink) {
goog.dom.replaceNode(link, oldLink);
}
}
link.id = linkId;
};
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
@author tildahl@google.com (Michael Tildahl)
@author robbyw@google.com (Robby Walker)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.AbstractBubblePlugin
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.AbstractBubblePluginTest');
</script>
<link rel="stylesheet" type="text/css" href="../../css/bubble.css" />
</head>
<body>
<div id="field">
</div>
</body>
</html>
@@ -0,0 +1,436 @@
// 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.
goog.provide('goog.editor.plugins.AbstractBubblePluginTest');
goog.setTestOnly('goog.editor.plugins.AbstractBubblePluginTest');
goog.require('goog.dom');
goog.require('goog.editor.plugins.AbstractBubblePlugin');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.EventType');
goog.require('goog.events.KeyCodes');
goog.require('goog.functions');
goog.require('goog.style');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.events.Event');
goog.require('goog.testing.jsunit');
goog.require('goog.ui.editor.Bubble');
goog.require('goog.userAgent');
var testHelper;
var fieldDiv;
var COMMAND = 'base';
var fieldMock;
var bubblePlugin;
var link;
var link2;
function setUpPage() {
fieldDiv = goog.dom.getElement('field');
var viewportSize = goog.dom.getViewportSize();
// Some tests depends on enough size of viewport.
if (viewportSize.width < 600 || viewportSize.height < 440) {
window.moveTo(0, 0);
window.resizeTo(640, 480);
}
}
function setUp() {
testHelper = new goog.testing.editor.TestHelper(fieldDiv);
testHelper.setUpEditableElement();
fieldMock = new goog.testing.editor.FieldMock();
bubblePlugin = new goog.editor.plugins.AbstractBubblePlugin(COMMAND);
bubblePlugin.fieldObject = fieldMock;
fieldDiv.innerHTML = '<a href="http://www.google.com">Google</a>' +
'<a href="http://www.google.com">Google2</a>';
link = fieldDiv.firstChild;
link2 = fieldDiv.lastChild;
window.scrollTo(0, 0);
goog.style.setStyle(document.body, 'direction', 'ltr');
goog.style.setStyle(document.getElementById('field'), 'position', 'static');
}
function tearDown() {
bubblePlugin.closeBubble();
testHelper.tearDownEditableElement();
}
/**
* This is a helper function for setting up the targetElement with a
* given direction.
*
* @param {string} dir The direction of the targetElement, 'ltr' or 'rtl'.
*/
function prepareTargetWithGivenDirection(dir) {
goog.style.setStyle(document.body, 'direction', dir);
fieldDiv.style.direction = dir;
fieldDiv.innerHTML = '<a href="http://www.google.com">Google</a>';
link = fieldDiv.firstChild;
fieldMock.$replay();
bubblePlugin.createBubbleContents = function(bubbleContainer) {
bubbleContainer.innerHTML = '<div style="border:1px solid blue;">B</div>';
goog.style.setStyle(bubbleContainer, 'border', '1px solid white');
};
bubblePlugin.registerFieldObject(fieldMock);
bubblePlugin.enable(fieldMock);
bubblePlugin.createBubble(link);
}
/**
* Similar in intent to mock reset, but implemented by recreating the mock
* variable. $reset() can't work because it will reset general any-time
* expectations done in the fieldMock constructor.
*/
function resetFieldMock() {
fieldMock = new goog.testing.editor.FieldMock();
bubblePlugin.fieldObject = fieldMock;
}
function helpTestCreateBubble(opt_fn) {
fieldMock.$replay();
var numCalled = 0;
bubblePlugin.createBubbleContents = function(bubbleContainer) {
numCalled++;
assertNotNull('bubbleContainer should not be null', bubbleContainer);
};
if (opt_fn) {
opt_fn();
}
bubblePlugin.createBubble(link);
assertEquals('createBubbleContents should be called', 1, numCalled);
fieldMock.$verify();
}
function testCreateBubble(opt_fn) {
helpTestCreateBubble(opt_fn);
assertTrue(bubblePlugin.getSharedBubble_() instanceof goog.ui.editor.Bubble);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
}
function testOpeningBubbleCallsOnShow() {
var numCalled = 0;
testCreateBubble(function() {
bubblePlugin.onShow = function() {
numCalled++;
};
});
assertEquals('onShow should be called', 1, numCalled);
fieldMock.$verify();
}
function testCloseBubble() {
testCreateBubble();
bubblePlugin.closeBubble();
assertFalse('Bubble should not be visible', bubblePlugin.isVisible());
fieldMock.$verify();
}
function testZindexBehavior() {
// Don't use the default return values.
fieldMock.$reset();
fieldMock.getAppWindow().$anyTimes().$returns(window);
fieldMock.getEditableDomHelper().$anyTimes()
.$returns(goog.dom.getDomHelper(document));
fieldMock.getBaseZindex().$returns(2);
bubblePlugin.createBubbleContents = goog.nullFunction;
fieldMock.$replay();
bubblePlugin.createBubble(link);
assertEquals('2',
'' + bubblePlugin.getSharedBubble_().bubbleContainer_.style.zIndex);
fieldMock.$verify();
}
function testNoTwoBubblesOpenAtSameTime() {
fieldMock.$replay();
var origClose = goog.bind(bubblePlugin.closeBubble, bubblePlugin);
var numTimesCloseCalled = 0;
bubblePlugin.closeBubble = function() {
numTimesCloseCalled++;
origClose();
};
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
bubblePlugin.createBubbleContents = goog.nullFunction;
bubblePlugin.handleSelectionChangeInternal(link);
assertEquals(0, numTimesCloseCalled);
assertEquals(link, bubblePlugin.targetElement_);
fieldMock.$verify();
bubblePlugin.handleSelectionChangeInternal(link2);
assertEquals(1, numTimesCloseCalled);
assertEquals(link2, bubblePlugin.targetElement_);
fieldMock.$verify();
}
function testHandleSelectionChangeWithEvent() {
fieldMock.$replay();
var fakeEvent =
new goog.events.BrowserEvent({type: 'mouseup', target: link});
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
bubblePlugin.createBubbleContents = goog.nullFunction;
bubblePlugin.handleSelectionChange(fakeEvent);
assertTrue('Bubble should have been opened', bubblePlugin.isVisible());
assertEquals('Bubble target should be provided event\'s target',
link, bubblePlugin.targetElement_);
}
function testHandleSelectionChangeWithTarget() {
fieldMock.$replay();
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
bubblePlugin.createBubbleContents = goog.nullFunction;
bubblePlugin.handleSelectionChange(undefined, link2);
assertTrue('Bubble should have been opened', bubblePlugin.isVisible());
assertEquals('Bubble target should be provided target',
link2, bubblePlugin.targetElement_);
}
/**
* Regression test for @bug 2945341
*/
function testSelectOneTextCharacterNoError() {
fieldMock.$replay();
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
bubblePlugin.createBubbleContents = goog.nullFunction;
// Select first char of first link's text node.
testHelper.select(link.firstChild, 0, link.firstChild, 1);
// This should execute without js errors.
bubblePlugin.handleSelectionChange();
assertTrue('Bubble should have been opened', bubblePlugin.isVisible());
fieldMock.$verify();
}
function testTabKeyEvents() {
fieldMock.$replay();
bubblePlugin.enableKeyboardNavigation(true);
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
var nonTabbable1, tabbable1, tabbable2, nonTabbable2;
bubblePlugin.createBubbleContents = function(container) {
nonTabbable1 = goog.dom.createDom('div');
tabbable1 = goog.dom.createDom('div');
tabbable2 = goog.dom.createDom('div');
nonTabbable2 = goog.dom.createDom('div');
goog.dom.append(
container, nonTabbable1, tabbable1, tabbable2, nonTabbable2);
bubblePlugin.setTabbable(tabbable1);
bubblePlugin.setTabbable(tabbable2);
};
bubblePlugin.handleSelectionChangeInternal(link);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
var tabHandledByBubble = simulateTabKeyOnBubble();
assertTrue('The action should be handled by the plugin', tabHandledByBubble);
assertFocused(tabbable1);
// Tab on the first tabbable. The test framework doesn't easily let us verify
// the desired behavior - namely, that the second tabbable gets focused - but
// we verify that the field doesn't get the focus.
goog.testing.events.fireKeySequence(tabbable1, goog.events.KeyCodes.TAB);
fieldMock.$verify();
// Tabbing on the last tabbable should trigger focus() of the target field.
resetFieldMock();
fieldMock.focus();
fieldMock.$replay();
goog.testing.events.fireKeySequence(tabbable2, goog.events.KeyCodes.TAB);
fieldMock.$verify();
}
function testTabKeyEventsWithShiftKey() {
fieldMock.$replay();
bubblePlugin.enableKeyboardNavigation(true);
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
var nonTabbable, tabbable1, tabbable2;
bubblePlugin.createBubbleContents = function(container) {
nonTabbable = goog.dom.createDom('div');
tabbable1 = goog.dom.createDom('div');
// The test acts only on one tabbable, but we give another one to make sure
// that the tabbable we act on is not also the last.
tabbable2 = goog.dom.createDom('div');
goog.dom.append(container, nonTabbable, tabbable1, tabbable2);
bubblePlugin.setTabbable(tabbable1);
bubblePlugin.setTabbable(tabbable2);
};
bubblePlugin.handleSelectionChangeInternal(link);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
var tabHandledByBubble = simulateTabKeyOnBubble();
assertTrue('The action should be handled by the plugin', tabHandledByBubble);
assertFocused(tabbable1);
fieldMock.$verify();
// Shift-tabbing on the first tabbable should trigger focus() of the target
// field.
resetFieldMock();
fieldMock.focus();
fieldMock.$replay();
goog.testing.events.fireKeySequence(
tabbable1, goog.events.KeyCodes.TAB, {shiftKey: true});
fieldMock.$verify();
}
function testLinksAreTabbable() {
fieldMock.$replay();
bubblePlugin.enableKeyboardNavigation(true);
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
var nonTabbable1, link1, link2, nonTabbable2;
bubblePlugin.createBubbleContents = function(container) {
nonTabbable1 = goog.dom.createDom('div');
goog.dom.appendChild(container, nonTabbable1);
bubbleLink1 = this.createLink('linkInBubble1', 'Foo', false, container);
bubbleLink2 = this.createLink('linkInBubble2', 'Bar', false, container);
nonTabbable2 = goog.dom.createDom('div');
goog.dom.appendChild(container, nonTabbable2);
};
bubblePlugin.handleSelectionChangeInternal(link);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
var tabHandledByBubble = simulateTabKeyOnBubble();
assertTrue('The action should be handled by the plugin', tabHandledByBubble);
assertFocused(bubbleLink1);
fieldMock.$verify();
// Tabbing on the last link should trigger focus() of the target field.
resetFieldMock();
fieldMock.focus();
fieldMock.$replay();
goog.testing.events.fireKeySequence(bubbleLink2, goog.events.KeyCodes.TAB);
fieldMock.$verify();
}
function testTabKeyNoEffectKeyboardNavDisabled() {
fieldMock.$replay();
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
var bubbleLink;
bubblePlugin.createBubbleContents = function(container) {
bubbleLink = this.createLink('linkInBubble', 'Foo', false, container);
};
bubblePlugin.handleSelectionChangeInternal(link);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
var tabHandledByBubble = simulateTabKeyOnBubble();
assertFalse('The action should not be handled by the plugin',
tabHandledByBubble);
assertNotFocused(bubbleLink);
// Verify that tabbing the link doesn't cause focus of the field.
goog.testing.events.fireKeySequence(bubbleLink, goog.events.KeyCodes.TAB);
fieldMock.$verify();
}
function testOtherKeyEventNoEffectKeyboardNavEnabled() {
fieldMock.$replay();
bubblePlugin.enableKeyboardNavigation(true);
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
var bubbleLink;
bubblePlugin.createBubbleContents = function(container) {
bubbleLink = this.createLink('linkInBubble', 'Foo', false, container);
};
bubblePlugin.handleSelectionChangeInternal(link);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
// Test pressing CTRL + B: this should not have any effect.
var keyHandledByBubble =
simulateKeyDownOnBubble(goog.events.KeyCodes.B, true);
assertFalse('The action should not be handled by the plugin',
keyHandledByBubble);
assertNotFocused(bubbleLink);
fieldMock.$verify();
}
function testSetTabbableSetsTabIndex() {
var element1 = goog.dom.createDom('div');
var element2 = goog.dom.createDom('div');
element1.setAttribute('tabIndex', '1');
bubblePlugin.setTabbable(element1);
bubblePlugin.setTabbable(element2);
assertEquals('1', element1.getAttribute('tabIndex'));
assertEquals('0', element2.getAttribute('tabIndex'));
}
function testDisable() {
testCreateBubble();
fieldMock.setUneditable(true);
bubblePlugin.disable(fieldMock);
bubblePlugin.closeBubble();
}
/**
* Sends a tab key event to the bubble.
* @return {boolean} whether the bubble hanlded the event.
*/
function simulateTabKeyOnBubble() {
return simulateKeyDownOnBubble(goog.events.KeyCodes.TAB, false);
}
/**
* Sends a key event to the bubble.
* @param {number} keyCode
* @param {boolean} isCtrl
* @return {boolean} whether the bubble hanlded the event.
*/
function simulateKeyDownOnBubble(keyCode, isCtrl) {
// In some browsers (e.g. FireFox) the editable field is marked with
// designMode on. In the test setting (and not in production setting), the
// bubble element shares the same window and hence the designMode. In this
// mode, activeElement remains the <body> and isn't changed along with the
// focus as a result of tab key.
bubblePlugin.getSharedBubble_().getContentElement().
ownerDocument.designMode = 'off';
var event =
new goog.testing.events.Event(goog.events.EventType.KEYDOWN, null);
event.keyCode = keyCode;
event.ctrlKey = isCtrl;
return bubblePlugin.handleKeyDown(event);
}
function assertFocused(element) {
// The activeElement assertion below doesn't work in IE7. At this time IE7 is
// no longer supported by any client product, so we don't care.
if (goog.userAgent.IE && !goog.userAgent.isVersionOrHigher(8)) {
return;
}
assertEquals('unexpected focus', element, document.activeElement);
}
function assertNotFocused(element) {
assertNotEquals('unexpected focus', element, document.activeElement);
}
@@ -0,0 +1,333 @@
// 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 An abstract superclass for TrogEdit dialog plugins. Each
* Trogedit dialog has its own plugin.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.plugins.AbstractDialogPlugin');
goog.provide('goog.editor.plugins.AbstractDialogPlugin.EventType');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.editor.Field');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.range');
goog.require('goog.events');
goog.require('goog.ui.editor.AbstractDialog');
// *** Public interface ***************************************************** //
/**
* An abstract superclass for a Trogedit plugin that creates exactly one
* dialog. By default dialogs are not reused -- each time execCommand is called,
* a new instance of the dialog object is created (and the old one disposed of).
* To enable reusing of the dialog object, subclasses should call
* setReuseDialog() after calling the superclass constructor.
* @param {string} command The command that this plugin handles.
* @constructor
* @extends {goog.editor.Plugin}
*/
goog.editor.plugins.AbstractDialogPlugin = function(command) {
goog.editor.Plugin.call(this);
this.command_ = command;
};
goog.inherits(goog.editor.plugins.AbstractDialogPlugin, goog.editor.Plugin);
/** @override */
goog.editor.plugins.AbstractDialogPlugin.prototype.isSupportedCommand =
function(command) {
return command == this.command_;
};
/**
* Handles execCommand. Dialog plugins don't make any changes when they open a
* dialog, just when the dialog closes (because only modal dialogs are
* supported). Hence this method does not dispatch the change events that the
* superclass method does.
* @param {string} command The command to execute.
* @param {...*} var_args Any additional parameters needed to
* execute the command.
* @return {*} The result of the execCommand, if any.
* @override
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.execCommand = function(
command, var_args) {
return this.execCommandInternal.apply(this, arguments);
};
// *** Events *************************************************************** //
/**
* Event type constants for events the dialog plugins fire.
* @enum {string}
*/
goog.editor.plugins.AbstractDialogPlugin.EventType = {
// This event is fired when a dialog has been opened.
OPENED: 'dialogOpened',
// This event is fired when a dialog has been closed.
CLOSED: 'dialogClosed'
};
// *** Protected interface ************************************************** //
/**
* Creates a new instance of this plugin's dialog. Must be overridden by
* subclasses.
* @param {!goog.dom.DomHelper} dialogDomHelper The dom helper to be used to
* create the dialog.
* @param {*=} opt_arg The dialog specific argument. Concrete subclasses should
* declare a specific type.
* @return {goog.ui.editor.AbstractDialog} The newly created dialog.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.createDialog =
goog.abstractMethod;
/**
* Returns the current dialog that was created and opened by this plugin.
* @return {goog.ui.editor.AbstractDialog} The current dialog that was created
* and opened by this plugin.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.getDialog = function() {
return this.dialog_;
};
/**
* Sets whether this plugin should reuse the same instance of the dialog each
* time execCommand is called or create a new one. This is intended for use by
* subclasses only, hence protected.
* @param {boolean} reuse Whether to reuse the dialog.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.setReuseDialog =
function(reuse) {
this.reuseDialog_ = reuse;
};
/**
* Handles execCommand by opening the dialog. Dispatches
* {@link goog.editor.plugins.AbstractDialogPlugin.EventType.OPENED} after the
* dialog is shown.
* @param {string} command The command to execute.
* @param {*=} opt_arg The dialog specific argument. Should be the same as
* {@link createDialog}.
* @return {*} Always returns true, indicating the dialog was shown.
* @protected
* @override
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.execCommandInternal =
function(command, opt_arg) {
// If this plugin should not reuse dialog instances, first dispose of the
// previous dialog.
if (!this.reuseDialog_) {
this.disposeDialog_();
}
// If there is no dialog yet (or we aren't reusing the previous one), create
// one.
if (!this.dialog_) {
this.dialog_ = this.createDialog(
// TODO(user): Add Field.getAppDomHelper. (Note dom helper will
// need to be updated if setAppWindow is called by clients.)
goog.dom.getDomHelper(this.getFieldObject().getAppWindow()),
opt_arg);
}
// Since we're opening a dialog, we need to clear the selection because the
// focus will be going to the dialog, and if we leave an selection in the
// editor while another selection is active in the dialog as the user is
// typing, some browsers will screw up the original selection. But first we
// save it so we can restore it when the dialog closes.
// getRange may return null if there is no selection in the field.
var tempRange = this.getFieldObject().getRange();
// saveUsingDom() did not work as well as saveUsingNormalizedCarets(),
// not sure why.
this.savedRange_ = tempRange && goog.editor.range.saveUsingNormalizedCarets(
tempRange);
goog.dom.Range.clearSelection(
this.getFieldObject().getEditableDomHelper().getWindow());
// Listen for the dialog closing so we can clean up.
goog.events.listenOnce(this.dialog_,
goog.ui.editor.AbstractDialog.EventType.AFTER_HIDE,
this.handleAfterHide,
false,
this);
this.getFieldObject().setModalMode(true);
this.dialog_.show();
this.dispatchEvent(goog.editor.plugins.AbstractDialogPlugin.EventType.OPENED);
// Since the selection has left the document, dispatch a selection
// change event.
this.getFieldObject().dispatchSelectionChangeEvent();
return true;
};
/**
* Cleans up after the dialog has closed, including restoring the selection to
* what it was before the dialog was opened. If a subclass modifies the editable
* field's content such that the original selection is no longer valid (usually
* the case when the user clicks OK, and sometimes also on Cancel), it is that
* subclass' responsibility to place the selection in the desired place during
* the OK or Cancel (or other) handler. In that case, this method will leave the
* selection in place.
* @param {goog.events.Event} e The AFTER_HIDE event object.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.handleAfterHide = function(
e) {
this.getFieldObject().setModalMode(false);
this.restoreOriginalSelection();
if (!this.reuseDialog_) {
this.disposeDialog_();
}
this.dispatchEvent(goog.editor.plugins.AbstractDialogPlugin.EventType.CLOSED);
// Since the selection has returned to the document, dispatch a selection
// change event.
this.getFieldObject().dispatchSelectionChangeEvent();
// When the dialog closes due to pressing enter or escape, that happens on the
// keydown event. But the browser will still fire a keyup event after that,
// which is caught by the editable field and causes it to try to fire a
// selection change event. To avoid that, we "debounce" the selection change
// event, meaning the editable field will not fire that event if the keyup
// that caused it immediately after this dialog was hidden ("immediately"
// means a small number of milliseconds defined by the editable field).
this.getFieldObject().debounceEvent(
goog.editor.Field.EventType.SELECTIONCHANGE);
};
/**
* Restores the selection in the editable field to what it was before the dialog
* was opened. This is not guaranteed to work if the contents of the field
* have changed.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.restoreOriginalSelection =
function() {
this.getFieldObject().restoreSavedRange(this.savedRange_);
this.savedRange_ = null;
};
/**
* Cleans up the structure used to save the original selection before the dialog
* was opened. Should be used by subclasses that don't restore the original
* selection via restoreOriginalSelection.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.disposeOriginalSelection =
function() {
if (this.savedRange_) {
this.savedRange_.dispose();
this.savedRange_ = null;
}
};
/** @override */
goog.editor.plugins.AbstractDialogPlugin.prototype.disposeInternal =
function() {
this.disposeDialog_();
goog.editor.plugins.AbstractDialogPlugin.base(this, 'disposeInternal');
};
// *** Private implementation *********************************************** //
/**
* The command that this plugin handles.
* @type {string}
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.command_;
/**
* The current dialog that was created and opened by this plugin.
* @type {goog.ui.editor.AbstractDialog}
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.dialog_;
/**
* Whether this plugin should reuse the same instance of the dialog each time
* execCommand is called or create a new one.
* @type {boolean}
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.reuseDialog_ = false;
/**
* Mutex to prevent recursive calls to disposeDialog_.
* @type {boolean}
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.isDisposingDialog_ = false;
/**
* SavedRange representing the selection before the dialog was opened.
* @type {goog.dom.SavedRange}
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.savedRange_;
/**
* Disposes of the dialog if needed. It is this abstract class' responsibility
* to dispose of the dialog. The "if needed" refers to the fact this method
* might be called twice (nested calls, not sequential) in the dispose flow, so
* if the dialog was already disposed once it should not be disposed again.
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.disposeDialog_ = function() {
// Wrap disposing the dialog in a mutex. Otherwise disposing it would cause it
// to get hidden (if it is still open) and fire AFTER_HIDE, which in
// turn would cause the dialog to be disposed again (closure only flags an
// object as disposed after the dispose call chain completes, so it doesn't
// prevent recursive dispose calls).
if (this.dialog_ && !this.isDisposingDialog_) {
this.isDisposingDialog_ = true;
this.dialog_.dispose();
this.dialog_ = null;
this.isDisposingDialog_ = false;
}
};
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<!--
@author marcosalmeida@google.com (Marcos Almeida)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.AbstractDialogPlugin
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.AbstractDialogPluginTest');
</script>
</head>
<body>
</body>
</html>
@@ -0,0 +1,403 @@
// 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.
goog.provide('goog.editor.plugins.AbstractDialogPluginTest');
goog.setTestOnly('goog.editor.plugins.AbstractDialogPluginTest');
goog.require('goog.dom.SavedRange');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.AbstractDialogPlugin');
goog.require('goog.events.Event');
goog.require('goog.events.EventHandler');
goog.require('goog.functions');
goog.require('goog.testing.MockClock');
goog.require('goog.testing.MockControl');
goog.require('goog.testing.PropertyReplacer');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.testing.mockmatchers.ArgumentMatcher');
goog.require('goog.ui.editor.AbstractDialog');
goog.require('goog.userAgent');
var plugin;
var mockCtrl;
var mockField;
var mockSavedRange;
var mockOpenedHandler;
var mockClosedHandler;
var COMMAND = 'myCommand';
var stubs = new goog.testing.PropertyReplacer();
var mockClock;
var fieldObj;
var fieldElem;
var mockHandler;
function setUp() {
mockCtrl = new goog.testing.MockControl();
mockOpenedHandler = mockCtrl.createLooseMock(goog.events.EventHandler);
mockClosedHandler = mockCtrl.createLooseMock(goog.events.EventHandler);
mockField = new goog.testing.editor.FieldMock(undefined, undefined, {});
mockCtrl.addMock(mockField);
mockField.focus();
plugin = createDialogPlugin();
}
function setUpMockRange() {
mockSavedRange = mockCtrl.createLooseMock(goog.dom.SavedRange);
mockSavedRange.restore();
stubs.setPath('goog.editor.range.saveUsingNormalizedCarets',
goog.functions.constant(mockSavedRange));
}
function tearDown() {
stubs.reset();
tearDownRealEditableField();
if (mockClock) {
// Crucial to letting time operations work normally in the rest of tests.
mockClock.dispose();
}
if (plugin) {
mockField.$setIgnoreUnexpectedCalls(true);
plugin.dispose();
}
}
/**
* Creates a concrete instance of goog.ui.editor.AbstractDialog by adding
* a plain implementation of createDialogControl().
* @param {goog.dom.DomHelper} dialogDomHelper The dom helper to be used to
* create the dialog.
* @return {goog.ui.editor.AbstractDialog} The created dialog.
*/
function createDialog(domHelper) {
var dialog = new goog.ui.editor.AbstractDialog(domHelper);
dialog.createDialogControl = function() {
return new goog.ui.editor.AbstractDialog.Builder(dialog).build();
};
return dialog;
}
/**
* Creates a concrete instance of the abstract class
* goog.editor.plugins.AbstractDialogPlugin
* and registers it with the mock editable field being used.
* @return {goog.editor.plugins.AbstractDialogPlugin} The created plugin.
*/
function createDialogPlugin() {
var plugin = new goog.editor.plugins.AbstractDialogPlugin(COMMAND);
plugin.createDialog = createDialog;
plugin.returnControlToEditableField = plugin.restoreOriginalSelection;
plugin.registerFieldObject(mockField);
plugin.addEventListener(
goog.editor.plugins.AbstractDialogPlugin.EventType.OPENED,
mockOpenedHandler);
plugin.addEventListener(
goog.editor.plugins.AbstractDialogPlugin.EventType.CLOSED,
mockClosedHandler);
return plugin;
}
/**
* Sets up the mock event handler to expect an OPENED event.
*/
function expectOpened(opt_times) {
mockOpenedHandler.handleEvent(new goog.testing.mockmatchers.ArgumentMatcher(
function(arg) {
return arg.type ==
goog.editor.plugins.AbstractDialogPlugin.EventType.OPENED;
}));
mockField.dispatchSelectionChangeEvent();
if (opt_times) {
mockOpenedHandler.$times(opt_times);
mockField.$times(opt_times);
}
}
/**
* Sets up the mock event handler to expect a CLOSED event.
*/
function expectClosed(opt_times) {
mockClosedHandler.handleEvent(new goog.testing.mockmatchers.ArgumentMatcher(
function(arg) {
return arg.type ==
goog.editor.plugins.AbstractDialogPlugin.EventType.CLOSED;
}));
mockField.dispatchSelectionChangeEvent();
if (opt_times) {
mockClosedHandler.$times(opt_times);
mockField.$times(opt_times);
}
}
/**
* Tests the simple flow of calling execCommand (which opens the
* dialog) and immediately disposing of the plugin (which closes the dialog).
* @param {boolean=} opt_reuse Whether to set the plugin to reuse its dialog.
*/
function testExecAndDispose(opt_reuse) {
setUpMockRange();
expectOpened();
expectClosed();
mockField.debounceEvent(goog.editor.Field.EventType.SELECTIONCHANGE);
mockCtrl.$replayAll();
if (opt_reuse) {
plugin.setReuseDialog(true);
}
assertFalse('Dialog should not be open yet',
!!plugin.getDialog() && plugin.getDialog().isOpen());
plugin.execCommand(COMMAND);
assertTrue('Dialog should be open now',
!!plugin.getDialog() && plugin.getDialog().isOpen());
var tempDialog = plugin.getDialog();
plugin.dispose();
assertFalse('Dialog should not still be open after disposal',
tempDialog.isOpen());
mockCtrl.$verifyAll();
}
/**
* Tests execCommand and dispose while reusing the dialog.
*/
function testExecAndDisposeReuse() {
testExecAndDispose(true);
}
/**
* Tests the flow of calling execCommand (which opens the dialog) and
* then hiding it (simulating that a user did somthing to cause the dialog to
* close).
* @param {boolean} reuse Whether to set the plugin to reuse its dialog.
*/
function testExecAndHide(opt_reuse) {
setUpMockRange();
expectOpened();
expectClosed();
mockField.debounceEvent(goog.editor.Field.EventType.SELECTIONCHANGE);
mockCtrl.$replayAll();
if (opt_reuse) {
plugin.setReuseDialog(true);
}
assertFalse('Dialog should not be open yet',
!!plugin.getDialog() && plugin.getDialog().isOpen());
plugin.execCommand(COMMAND);
assertTrue('Dialog should be open now',
!!plugin.getDialog() && plugin.getDialog().isOpen());
var tempDialog = plugin.getDialog();
plugin.getDialog().hide();
assertFalse('Dialog should not still be open after hiding',
tempDialog.isOpen());
if (opt_reuse) {
assertFalse('Dialog should not be disposed after hiding (will be reused)',
tempDialog.isDisposed());
} else {
assertTrue('Dialog should be disposed after hiding',
tempDialog.isDisposed());
}
plugin.dispose();
mockCtrl.$verifyAll();
}
/**
* Tests execCommand and hide while reusing the dialog.
*/
function testExecAndHideReuse() {
testExecAndHide(true);
}
/**
* Tests the flow of calling execCommand (which opens a dialog) and
* then calling it again before the first dialog is closed. This is not
* something anyone should be doing since dialogs are (usually?) modal so the
* user can't do another execCommand before closing the first dialog. But
* since the API makes it possible, I thought it would be good to guard
* against and unit test.
* @param {boolean} reuse Whether to set the plugin to reuse its dialog.
*/
function testExecTwice(opt_reuse) {
setUpMockRange();
if (opt_reuse) {
expectOpened(2); // The second exec should cause a second OPENED event.
// But the dialog was not closed between exec calls, so only one CLOSED is
// expected.
expectClosed();
plugin.setReuseDialog(true);
mockField.debounceEvent(goog.editor.Field.EventType.SELECTIONCHANGE);
} else {
expectOpened(2); // The second exec should cause a second OPENED event.
// The first dialog will be disposed so there should be two CLOSED events.
expectClosed(2);
mockSavedRange.restore(); // Expected 2x, once already recorded in setup.
mockField.focus(); // Expected 2x, once already recorded in setup.
mockField.debounceEvent(goog.editor.Field.EventType.SELECTIONCHANGE);
mockField.$times(2);
}
mockCtrl.$replayAll();
assertFalse('Dialog should not be open yet',
!!plugin.getDialog() && plugin.getDialog().isOpen());
plugin.execCommand(COMMAND);
assertTrue('Dialog should be open now',
!!plugin.getDialog() && plugin.getDialog().isOpen());
var tempDialog = plugin.getDialog();
plugin.execCommand(COMMAND);
if (opt_reuse) {
assertTrue('Reused dialog should still be open after second exec',
tempDialog.isOpen());
assertFalse('Reused dialog should not be disposed after second exec',
tempDialog.isDisposed());
} else {
assertFalse('First dialog should not still be open after opening second',
tempDialog.isOpen());
assertTrue('First dialog should be disposed after opening second',
tempDialog.isDisposed());
}
plugin.dispose();
mockCtrl.$verifyAll();
}
/**
* Tests execCommand twice while reusing the dialog.
*/
function testExecTwiceReuse() {
// Test is failing with an out-of-memory error in IE7.
if (goog.userAgent.IE && !goog.userAgent.isVersionOrHigher('8')) {
return;
}
testExecTwice(true);
}
/**
* Tests that the selection is cleared when the dialog opens and is
* correctly restored after it closes.
*/
function testRestoreSelection() {
setUpRealEditableField();
fieldObj.setHtml(false, '12345');
var elem = fieldObj.getElement();
var helper = new goog.testing.editor.TestHelper(elem);
helper.select('12345', 1, '12345', 4); // Selects '234'.
assertEquals('Incorrect text selected before dialog is opened',
'234',
fieldObj.getRange().getText());
plugin.execCommand(COMMAND);
if (!goog.userAgent.IE && !goog.userAgent.OPERA) {
// IE returns some bogus range when field doesn't have selection.
// Opera can't remove the selection from a whitebox field.
assertNull('There should be no selection while dialog is open',
fieldObj.getRange());
}
plugin.getDialog().hide();
assertEquals('Incorrect text selected after dialog is closed',
'234',
fieldObj.getRange().getText());
}
/**
* Setup a real editable field (instead of a mock) and register the plugin to
* it.
*/
function setUpRealEditableField() {
fieldElem = document.createElement('div');
fieldElem.id = 'myField';
document.body.appendChild(fieldElem);
fieldObj = new goog.editor.Field('myField', document);
fieldObj.makeEditable();
// Register the plugin to that field.
plugin.getTrogClassId = goog.functions.constant('myClassId');
fieldObj.registerPlugin(plugin);
}
/**
* Tear down the real editable field.
*/
function tearDownRealEditableField() {
if (fieldObj) {
fieldObj.makeUneditable();
fieldObj.dispose();
fieldObj = null;
}
if (fieldElem && fieldElem.parentNode == document.body) {
document.body.removeChild(fieldElem);
}
}
/**
* Tests that after the dialog is hidden via a keystroke, the editable field
* doesn't fire an extra SELECTIONCHANGE event due to the keyup from that
* keystroke.
* There is also a robot test in dialog_robot.html to test debouncing the
* SELECTIONCHANGE event when the dialog closes.
*/
function testDebounceSelectionChange() {
mockClock = new goog.testing.MockClock(true);
// Initial time is 0 which evaluates to false in debouncing implementation.
mockClock.tick(1);
setUpRealEditableField();
// Set up a mock event handler to make sure selection change isn't fired
// more than once on close and a second time on close.
var count = 0;
fieldObj.addEventListener(goog.editor.Field.EventType.SELECTIONCHANGE,
function(e) {
count++;
});
assertEquals(0, count);
plugin.execCommand(COMMAND);
assertEquals(1, count);
plugin.getDialog().hide();
assertEquals(2, count);
// Fake the keyup event firing on the field after the dialog closes.
var e = new goog.events.Event('keyup', plugin.fieldObject.getElement());
e.keyCode = 13;
goog.testing.events.fireBrowserEvent(e);
// Tick the mock clock so that selection change tries to fire.
mockClock.tick(goog.editor.Field.SELECTION_CHANGE_FREQUENCY_ + 1);
// Ensure the handler did not fire again.
assertEquals(2, count);
}
@@ -0,0 +1,78 @@
// 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 Abstract Editor plugin class to handle tab keys. Has one
* abstract method which should be overriden to handle a tab key press.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.AbstractTabHandler');
goog.require('goog.editor.Plugin');
goog.require('goog.events.KeyCodes');
goog.require('goog.userAgent');
/**
* Plugin to handle tab keys. Specific tab behavior defined by subclasses.
*
* @constructor
* @extends {goog.editor.Plugin}
*/
goog.editor.plugins.AbstractTabHandler = function() {
goog.editor.Plugin.call(this);
};
goog.inherits(goog.editor.plugins.AbstractTabHandler, goog.editor.Plugin);
/** @override */
goog.editor.plugins.AbstractTabHandler.prototype.getTrogClassId =
goog.abstractMethod;
/** @override */
goog.editor.plugins.AbstractTabHandler.prototype.handleKeyboardShortcut =
function(e, key, isModifierPressed) {
// If a dialog doesn't have selectable field, Moz 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;
}
// Don't handle Ctrl+Tab since the user is most likely trying to switch
// browser tabs. See bug 1305086.
// FF3 on Mac sends Ctrl-Tab to trogedit and we end up inserting a tab, but
// then it also switches the tabs. See bug 1511681. Note that we don't use
// isModifierPressed here since isModifierPressed is true only if metaKey
// is true on Mac.
if (e.keyCode == goog.events.KeyCodes.TAB && !e.metaKey && !e.ctrlKey) {
return this.handleTabKey(e);
}
return false;
};
/**
* Handle a tab key press.
* @param {goog.events.Event} e The key event.
* @return {boolean} Whether this event was handled by this plugin.
* @protected
*/
goog.editor.plugins.AbstractTabHandler.prototype.handleTabKey =
goog.abstractMethod;
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<!--
@author ajp@google.com (Andy Perelson)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.AbstractTabHandler
</title>
<script src="../../base.js">
</script>
<script src="../../deps.js">
</script>
<script>
goog.require('goog.editor.plugins.AbstractTabHandlerTest');
</script>
</head>
<body>
</body>
</html>
@@ -0,0 +1,81 @@
// 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.
goog.provide('goog.editor.plugins.AbstractTabHandlerTest');
goog.setTestOnly('goog.editor.plugins.AbstractTabHandlerTest');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.AbstractTabHandler');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.KeyCodes');
goog.require('goog.testing.StrictMock');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var tabHandler;
var editableField;
var handleTabKeyCalled = false;
function setUp() {
editableField = new goog.testing.editor.FieldMock();
editableField.inModalMode = goog.editor.Field.prototype.inModalMode;
editableField.setModalMode = goog.editor.Field.prototype.setModalMode;
tabHandler = new goog.editor.plugins.AbstractTabHandler();
tabHandler.registerFieldObject(editableField);
tabHandler.handleTabKey = function(e) {
handleTabKeyCalled = true;
return true;
};
}
function tearDown() {
tabHandler.dispose();
}
function testHandleKey() {
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.ctrlKey = false;
event.metaKey = false;
assertTrue('Event must be handled when no modifier keys are pressed.',
tabHandler.handleKeyboardShortcut(event, '', false));
assertTrue(handleTabKeyCalled);
handleTabKeyCalled = false;
editableField.setModalMode(true);
if (goog.userAgent.GECKO) {
assertFalse('Event must not be handled when in modal mode',
tabHandler.handleKeyboardShortcut(event, '', false));
assertFalse(handleTabKeyCalled);
} else {
assertTrue('Event must be handled when in modal mode',
tabHandler.handleKeyboardShortcut(event, '', false));
assertTrue(handleTabKeyCalled);
handleTabKeyCalled = false;
}
event.ctrlKey = true;
assertFalse('Plugin must never handle tab key press when ctrlKey is pressed.',
tabHandler.handleKeyboardShortcut(event, '', false));
assertFalse(handleTabKeyCalled);
event.ctrlKey = false;
event.metaKey = true;
assertFalse('Plugin must never handle tab key press when metaKey is pressed.',
tabHandler.handleKeyboardShortcut(event, '', false));
assertFalse(handleTabKeyCalled);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.BasicTextFormatter Tests
</title>
<script type="text/javascript" src="../../base.js">
</script>
<script type="text/javascript">
goog.require('goog.editor.plugins.BasicTextFormatterTest');
</script>
</head>
<body style="font-size:16px">
<!-- This has to be created here so it is loaded when we need it -->
<iframe id="iframe"></iframe>
<div id="html">
<ul id="outerUL" type="1">
<li>foo</li>
<ul id="innerUL">
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>
<li>bar</li>
<li>baz</li>
</ul>
...
<ul id="outerUL2">
<li>foo</li>
<ul>
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>
<ul>
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>
<li>bar</li>
<li>baz</li>
</ul>
<ol id="ol">
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ol>
<div>
<ol id="switchListType">
<li>switch</li>
<li>list</li>
<li>type</li>
</ol>
</div>
<p>before <span id="toQuote">Foo. Bar, baz.</span> after</p>
<p>before <div id="toQuote2">Foo.<p/>Bar,<p/>baz.</div> after</p>
<div id="divQuote"><div>lorem</div><div>ipsum</div><div>dolor</div></div>
<p id="geckolist"><font face="courier">test</font></p>
<table>
<tr> <th> head1</th>
<th id= "outerTh"><span id="emptyTh">head2</span></th> </tr>
<tr> <td> one </td> <td>two </td> </tr>
<tr>
<td> three</td>
<td id="outerTd"> <span id="emptyTd"><strong>four</strong></span></td>
</tr>
<tr id="outerTr"> <td><span id="emptyTr"> five </span></td></tr>
</table>
<div id="linkwrapper">Foo<span id="link">Pre<a href="http://www.google.com">Outside Span<span style="font-size:15pt">Inside Span</span></a></span></div>
</div>
<div id="root"></div>
<div id="real-field"></div>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,451 @@
// 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.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.Blockquote');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.TagName');
goog.require('goog.dom.classlist');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Command');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.node');
goog.require('goog.functions');
goog.require('goog.log');
/**
* 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}
* @final
*/
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.log.Logger}
* @protected
* @override
*/
goog.editor.plugins.Blockquote.prototype.logger =
goog.log.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.
* @override
*/
goog.editor.plugins.Blockquote.prototype.isSilentCommand = goog.functions.TRUE;
/**
* Checks if a node is a blockquote which can be split. A splittable blockquote
* meets the following criteria:
* <ol>
* <li>Node is a blockquote element</li>
* <li>Node has the blockquote classname if the classname is required to
* split</li>
* </ol>
*
* @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.classlist.contains(/** @type {!Element} */ (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.classlist.contains(/** @type {!Element} */ (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<Node>} 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. If BrowserFeature.HAS_W3C_RANGES it is an object with a
* {@code node} key and an {@code offset} key. In other cases (legacy IE)
* it is a single node.
* @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<BR>|) or just before a BR element
// (one|<BR>) 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|<BR>. 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.getFieldObject().queryCommandValue(
goog.editor.Command.DEFAULT_TAG) ||
goog.dom.TagName.DIV;
var container = dh.createElement(/** @type {string} */ (tagToInsert));
container.innerHTML = '&nbsp;'; // 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.getFieldObject().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|<BR>) 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.getFieldObject().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 = '&nbsp;';
// 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".
//
// <blockquote class="gmail-quote">
// <div><div>a</div><ol><li>one|</li></ol></div>
// <div>b</div>
// </blockquote>
//
// After pressing enter, we have the following structure.
//
// <blockquote class="gmail-quote">
// <div><div>a</div><ol><li>one|</li></ol></div>
// </blockquote>
// <div>&nbsp;</div>
// <blockquote class="gmail-quote">
// <div><ol><li><span id=""></span></li></ol></div>
// <div>b</div>
// </blockquote>
//
// 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
// <div><ol><li><span id=""></span></li></ol></div>.
//
// 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;
};
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
Tests for goog.editor.plugins.Blockquote
@author robbyw@google.com (Robby Walker)
-->
<html>
<!--
Copyright 2009 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.Blockquote
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.BlockquoteTest');
</script>
</head>
<body>
<div id="root">
</div>
</body>
</html>
@@ -0,0 +1,209 @@
// Copyright 2009 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.
goog.provide('goog.editor.plugins.BlockquoteTest');
goog.setTestOnly('goog.editor.plugins.BlockquoteTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.plugins.Blockquote');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
var SPLIT = '<span id="split-point"></span>';
var root, helper, field, plugin;
function setUp() {
root = goog.dom.getElement('root');
helper = new goog.testing.editor.TestHelper(root);
field = new goog.testing.editor.FieldMock();
helper.setUpEditableElement();
}
function tearDown() {
field.$verify();
helper.tearDownEditableElement();
}
function createPlugin(requireClassname, opt_paragraphMode) {
field.queryCommandValue('+defaultTag').$anyTimes().$returns(
opt_paragraphMode ? goog.dom.TagName.P : undefined);
plugin = new goog.editor.plugins.Blockquote(requireClassname);
plugin.registerFieldObject(field);
plugin.enable(field);
}
function execCommand() {
field.$replay();
// With splitPoint we try to mimic the behavior of EnterHandler's
// deleteCursorSelection_.
var splitPoint = goog.dom.getElement('split-point');
var position = goog.editor.BrowserFeature.HAS_W3C_RANGES ?
{node: splitPoint.nextSibling, offset: 0} : splitPoint;
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
goog.dom.removeNode(splitPoint);
goog.dom.Range.createCaret(position.node, 0).select();
} else {
goog.dom.Range.createCaret(position, 0).select();
}
var result = plugin.execCommand(goog.editor.plugins.Blockquote.SPLIT_COMMAND,
position);
if (!goog.editor.BrowserFeature.HAS_W3C_RANGES) {
goog.dom.removeNode(splitPoint);
}
return result;
}
function testSplitBlockquoteDoesNothingWhenNotInBlockquote() {
root.innerHTML = '<div>Test' + SPLIT + 'ing</div>';
createPlugin(false);
assertFalse(execCommand());
helper.assertHtmlMatches('<div>Testing</div>');
}
function testSplitBlockquoteDoesNothingWhenNotInBlockquoteWithClass() {
root.innerHTML = '<blockquote>Test' + SPLIT + 'ing</blockquote>';
createPlugin(true);
assertFalse(execCommand());
helper.assertHtmlMatches('<blockquote>Testing</blockquote>');
}
function testSplitBlockquoteInBlockquoteWithoutClass() {
root.innerHTML = '<blockquote>Test' + SPLIT + 'ing</blockquote>';
createPlugin(false);
assertTrue(execCommand());
helper.assertHtmlMatches(
'<blockquote>Test</blockquote>' +
'<div>' +
(goog.editor.BrowserFeature.HAS_W3C_RANGES ? '&nbsp;' : '') +
'</div>' +
'<blockquote>ing</blockquote>');
}
function testSplitBlockquoteInBlockquoteWithoutClassInParagraphMode() {
root.innerHTML = '<blockquote>Test' + SPLIT + 'ing</blockquote>';
createPlugin(false, true);
assertTrue(execCommand());
helper.assertHtmlMatches(
'<blockquote>Test</blockquote>' +
'<p>' +
(goog.editor.BrowserFeature.HAS_W3C_RANGES ? '&nbsp;' : '') +
'</p>' +
'<blockquote>ing</blockquote>');
}
function testSplitBlockquoteInBlockquoteWithClass() {
root.innerHTML =
'<blockquote class="tr_bq">Test' + SPLIT + 'ing</blockquote>';
createPlugin(true);
assertTrue(execCommand());
helper.assertHtmlMatches(
'<blockquote class="tr_bq">Test</blockquote>' +
'<div>' +
(goog.editor.BrowserFeature.HAS_W3C_RANGES ? '&nbsp;' : '') +
'</div>' +
'<blockquote class="tr_bq">ing</blockquote>');
}
function testSplitBlockquoteInBlockquoteWithClassInParagraphMode() {
root.innerHTML =
'<blockquote class="tr_bq">Test' + SPLIT + 'ing</blockquote>';
createPlugin(true, true);
assertTrue(execCommand());
helper.assertHtmlMatches(
'<blockquote class="tr_bq">Test</blockquote>' +
'<p>' +
(goog.editor.BrowserFeature.HAS_W3C_RANGES ? '&nbsp;' : '') +
'</p>' +
'<blockquote class="tr_bq">ing</blockquote>');
}
function testIsSplittableBlockquoteWhenRequiresClassNameToSplit() {
createPlugin(true);
var blockquoteWithClassName = goog.dom.createDom('blockquote', 'tr_bq');
assertTrue('blockquote should be detected as splittable',
plugin.isSplittableBlockquote(blockquoteWithClassName));
var blockquoteWithoutClassName = goog.dom.createDom('blockquote', 'foo');
assertFalse('blockquote should not be detected as splittable',
plugin.isSplittableBlockquote(blockquoteWithoutClassName));
var nonBlockquote = goog.dom.createDom('span', 'tr_bq');
assertFalse('element should not be detected as splittable',
plugin.isSplittableBlockquote(nonBlockquote));
}
function testIsSplittableBlockquoteWhenNotRequiresClassNameToSplit() {
createPlugin(false);
var blockquoteWithClassName = goog.dom.createDom('blockquote', 'tr_bq');
assertTrue('blockquote should be detected as splittable',
plugin.isSplittableBlockquote(blockquoteWithClassName));
var blockquoteWithoutClassName = goog.dom.createDom('blockquote', 'foo');
assertTrue('blockquote should be detected as splittable',
plugin.isSplittableBlockquote(blockquoteWithoutClassName));
var nonBlockquote = goog.dom.createDom('span', 'tr_bq');
assertFalse('element should not be detected as splittable',
plugin.isSplittableBlockquote(nonBlockquote));
}
function testIsSetupBlockquote() {
createPlugin(false);
var blockquoteWithClassName = goog.dom.createDom('blockquote', 'tr_bq');
assertTrue('blockquote should be detected as setup',
plugin.isSetupBlockquote(blockquoteWithClassName));
var blockquoteWithoutClassName = goog.dom.createDom('blockquote', 'foo');
assertFalse('blockquote should not be detected as setup',
plugin.isSetupBlockquote(blockquoteWithoutClassName));
var nonBlockquote = goog.dom.createDom('span', 'tr_bq');
assertFalse('element should not be detected as setup',
plugin.isSetupBlockquote(nonBlockquote));
}
function testIsUnsetupBlockquote() {
createPlugin(false);
var blockquoteWithClassName = goog.dom.createDom('blockquote', 'tr_bq');
assertFalse('blockquote should not be detected as unsetup',
plugin.isUnsetupBlockquote(blockquoteWithClassName));
var blockquoteWithoutClassName = goog.dom.createDom('blockquote', 'foo');
assertTrue('blockquote should be detected as unsetup',
plugin.isUnsetupBlockquote(blockquoteWithoutClassName));
var nonBlockquote = goog.dom.createDom('span', 'tr_bq');
assertFalse('element should not be detected as unsetup',
plugin.isUnsetupBlockquote(nonBlockquote));
}
@@ -0,0 +1,89 @@
// Copyright 2009 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.
// All Rights Reserved
/**
* @fileoverview Plugin for generating emoticons.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.plugins.Emoticons');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.range');
goog.require('goog.functions');
goog.require('goog.ui.emoji.Emoji');
goog.require('goog.userAgent');
/**
* Plugin for generating emoticons.
*
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.Emoticons = function() {
goog.editor.plugins.Emoticons.base(this, 'constructor');
};
goog.inherits(goog.editor.plugins.Emoticons, goog.editor.Plugin);
/** The emoticon command. */
goog.editor.plugins.Emoticons.COMMAND = '+emoticon';
/** @override */
goog.editor.plugins.Emoticons.prototype.getTrogClassId =
goog.functions.constant(goog.editor.plugins.Emoticons.COMMAND);
/** @override */
goog.editor.plugins.Emoticons.prototype.isSupportedCommand = function(
command) {
return command == goog.editor.plugins.Emoticons.COMMAND;
};
/**
* Inserts an emoticon into the editor at the cursor location. Places the
* cursor to the right of the inserted emoticon.
* @param {string} command Command to execute.
* @param {*=} opt_arg Emoji to insert.
* @return {!Object|undefined} The result of the command.
* @override
*/
goog.editor.plugins.Emoticons.prototype.execCommandInternal = function(
command, opt_arg) {
var emoji = /** @type {goog.ui.emoji.Emoji} */ (opt_arg);
var dom = this.getFieldDomHelper();
var img = dom.createDom(goog.dom.TagName.IMG, {
'src': emoji.getUrl(),
'style': 'margin:0 0.2ex;vertical-align:middle'
});
img.setAttribute(goog.ui.emoji.Emoji.ATTRIBUTE, emoji.getId());
this.getFieldObject().getRange().replaceContentsWithNode(img);
// IE8 does the right thing with the cursor, and has a js error when we try
// to place the cursor manually.
// IE9 loses the cursor when the window is focused, so focus first.
if (!goog.userAgent.IE || goog.userAgent.isDocumentModeOrHigher(9)) {
this.getFieldObject().focus();
goog.editor.range.placeCursorNextTo(img, false);
}
};
@@ -0,0 +1,23 @@
<html>
<!--
Copyright 2010 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.EmoticonsTest');
</script>
</head>
<div id="parent">
<div id="testField">
I am text.
</div>
</div>
<body>
</body>
</html>
@@ -0,0 +1,84 @@
// Copyright 2010 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.
goog.provide('goog.editor.plugins.EmoticonsTest');
goog.setTestOnly('goog.editor.plugins.EmoticonsTest');
goog.require('goog.Uri');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.Emoticons');
goog.require('goog.testing.jsunit');
goog.require('goog.ui.emoji.Emoji');
goog.require('goog.userAgent');
var HTML;
function setUp() {
HTML = goog.dom.getElement('parent').innerHTML;
}
function tearDown() {
goog.dom.getElement('parent').innerHTML = HTML;
}
function testEmojiWithEmoticonsPlugin() {
runEmojiTestWithPlugin(new goog.editor.plugins.Emoticons());
}
function runEmojiTestWithPlugin(plugin) {
var field = new goog.editor.Field('testField');
field.registerPlugin(plugin);
field.makeEditable();
field.focusAndPlaceCursorAtStart();
var src = 'testdata/emoji/4F4.gif';
var id = '4F4';
var emoji = new goog.ui.emoji.Emoji(src, id);
field.execCommand(goog.editor.plugins.Emoticons.COMMAND, emoji);
// The url may be relative or absolute.
var imgs = field.getEditableDomHelper().
getElementsByTagNameAndClass(goog.dom.TagName.IMG);
assertEquals(1, imgs.length);
var img = imgs[0];
assertUriEquals(src, img.getAttribute('src'));
assertEquals(id, img.getAttribute(goog.ui.emoji.Emoji.ATTRIBUTE));
var range = field.getRange();
assertNotNull('must have a selection', range);
assertTrue('range must be a cursor', range.isCollapsed());
if (goog.userAgent.WEBKIT) {
assertEquals('range starts after image',
2, range.getStartOffset());
} else if (goog.userAgent.GECKO) {
assertEquals('range starts after image',
2, goog.array.indexOf(range.getContainerElement().childNodes,
range.getStartNode()));
}
// Firefox 3.6 is still tested, and would fail here - treitel December 2012
if (!(goog.userAgent.GECKO && !goog.userAgent.isVersionOrHigher(2))) {
assertEquals('range must be around image',
img.parentElement, range.getContainerElement());
}
}
function assertUriEquals(expected, actual) {
var winUri = new goog.Uri(window.location);
assertEquals(winUri.resolve(new goog.Uri(expected)).toString(),
winUri.resolve(new goog.Uri(actual)).toString());
}
@@ -0,0 +1,768 @@
// 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.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.EnterHandler');
goog.require('goog.dom');
goog.require('goog.dom.NodeOffset');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
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.functions');
goog.require('goog.object');
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.editor.plugins.EnterHandler.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('<br>');
/**
* 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 <enter> 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: "<h2>foo[cursor]</h2>", 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 <tag>.
* @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 <p> inside of a <div> in some situations,
// such as when breaking out of a list that is contained in a <div>.
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.isVersionOrHigher(9)) {
// IE (before IE9) has a bug where if the cursor is directly before a block
// node (e.g., the content is "foo[cursor]<blockquote>bar</blockquote>"),
// 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('<span id="' + id + '"></span>');
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 <br> 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 <br> 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 = '&nbsp;';
if (goog.userAgent.OPERA &&
container.tagName == goog.dom.TagName.LI) {
// Don't break Opera's native break-out-of-lists behavior.
html = '<br>';
}
goog.editor.node.replaceInnerHtml(container, html);
goog.editor.range.selectNodeStart(container.firstChild);
reselect = false;
}
}
if (isPartialEnd) {
/*
This code handles the following, where | is the cursor:
<div>a|b</div><div>c|d</div>
After removeContents, the remaining HTML is
<div>a</div><div>d</div>
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:
<div>ad</div>
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);
};
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<!--
Tests for goog.editor.plugins.EnterHandler
@author nicksantos@google.com (Nick Santos)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.EnterHandler
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.EnterHandlerTest');
</script>
<style type="text/css">
.tr-field {
border: thin solid blue;
}
.tr_bq {
border-left: thin solid red;
margin-left: 5px;
}
</style>
</head>
<body>
<input type="button" value="Make all fields editable" onclick="setUp()" />
<p>
<div id="container">
This is used to test static utility functions.
</div>
<div id="root">
<div id="field1" class="tr-field">
<blockquote>
This is an
<span id="field1cursor">
selection
</span>
unsetup blockquote
</blockquote>
</div>
<p>
<div id="field2" class="tr-field">
<blockquote class="tr_bq">
This is a
<span id="field2cursor">
selection
</span>
setup blockquote
</blockquote>
</div>
</p>
<p>
</p>
</div>
</p>
</body>
</html>
@@ -0,0 +1,741 @@
// 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.
goog.provide('goog.editor.plugins.EnterHandlerTest');
goog.setTestOnly('goog.editor.plugins.EnterHandlerTest');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Field');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.plugins.Blockquote');
goog.require('goog.editor.plugins.EnterHandler');
goog.require('goog.editor.range');
goog.require('goog.events');
goog.require('goog.events.KeyCodes');
goog.require('goog.testing.ExpectedFailures');
goog.require('goog.testing.MockClock');
goog.require('goog.testing.dom');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var savedHtml;
var field1;
var field2;
var firedDelayedChange;
var firedBeforeChange;
var clock;
var container;
var EXPECTEDFAILURES;
function setUpPage() {
container = goog.dom.getElement('container');
}
function setUp() {
EXPECTEDFAILURES = new goog.testing.ExpectedFailures();
savedHtml = goog.dom.getElement('root').innerHTML;
clock = new goog.testing.MockClock(true);
}
function setUpFields(classnameRequiredToSplitBlockquote) {
field1 = makeField('field1', classnameRequiredToSplitBlockquote);
field2 = makeField('field2', classnameRequiredToSplitBlockquote);
field1.makeEditable();
field2.makeEditable();
}
function tearDown() {
clock.dispose();
EXPECTEDFAILURES.handleTearDown();
goog.dom.getElement('root').innerHTML = savedHtml;
}
function testEnterInNonSetupBlockquote() {
setUpFields(true);
resetChangeFlags();
var prevented = !selectNodeAndHitEnter(field1, 'field1cursor');
waitForChangeEvents();
assertChangeFlags();
// make sure there's just one blockquote, and that the text has been deleted.
var elem = field1.getElement();
var dom = field1.getEditableDomHelper();
EXPECTEDFAILURES.expectFailureFor(goog.userAgent.OPERA,
'The blockquote is overwritten with DIV due to CORE-22104 -- Opera ' +
'overwrites the BLOCKQUOTE ancestor with DIV when doing FormatBlock ' +
'for DIV');
try {
assertEquals('Blockquote should not be split',
1, dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem).length);
} catch (e) {
EXPECTEDFAILURES.handleException(e);
}
assert('Selection should be deleted',
-1 == elem.innerHTML.indexOf('selection'));
assertEquals('The event should have been prevented only on webkit',
prevented, goog.userAgent.WEBKIT);
}
function testEnterInSetupBlockquote() {
setUpFields(true);
resetChangeFlags();
var prevented = !selectNodeAndHitEnter(field2, 'field2cursor');
waitForChangeEvents();
assertChangeFlags();
// make sure there are two blockquotes, and a DIV with nbsp in the middle.
var elem = field2.getElement();
var dom = field2.getEditableDomHelper();
assertEquals('Blockquote should be split', 2,
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem).length);
assert('Selection should be deleted',
-1 == elem.innerHTML.indexOf('selection'));
assert('should have div with &nbsp;',
-1 != elem.innerHTML.indexOf('>' + getNbsp() + '<'));
assert('event should have been prevented', prevented);
}
function testEnterInNonSetupBlockquoteWhenClassnameIsNotRequired() {
setUpFields(false);
resetChangeFlags();
var prevented = !selectNodeAndHitEnter(field1, 'field1cursor');
waitForChangeEvents();
assertChangeFlags();
// make sure there are two blockquotes, and a DIV with nbsp in the middle.
var elem = field1.getElement();
var dom = field1.getEditableDomHelper();
assertEquals('Blockquote should be split', 2,
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem).length);
assert('Selection should be deleted',
-1 == elem.innerHTML.indexOf('selection'));
assert('should have div with &nbsp;',
-1 != elem.innerHTML.indexOf('>' + getNbsp() + '<'));
assert('event should have been prevented', prevented);
}
function testEnterInBlockquoteCreatesDivInBrMode() {
setUpFields(true);
selectNodeAndHitEnter(field2, 'field2cursor');
var elem = field2.getElement();
var dom = field2.getEditableDomHelper();
var firstBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[0];
var div = dom.getNextElementSibling(firstBlockquote);
assertEquals('Element after blockquote should be a div', 'DIV', div.tagName);
assertEquals('Element after div should be second blockquote',
'BLOCKQUOTE', dom.getNextElementSibling(div).tagName);
}
/**
* Tests that breaking after a BR doesn't result in unnecessary newlines.
* @bug 1471047
*/
function testEnterInBlockquoteRemovesUnnecessaryBrWithCursorAfterBr() {
setUpFields(true);
// Assume the following HTML snippet:-
// <blockquote>one<br>|two<br></blockquote>
//
// After enter on the cursor position without the fix, the resulting HTML
// after the blockquote split was:-
// <blockquote>one</blockquote>
// <div>&nbsp;</div>
// <blockquote><br>two<br></blockquote>
//
// This creates the impression on an unnecessary newline. The resulting HTML
// after the fix is:-
//
// <blockquote>one<br></blockquote>
// <div>&nbsp;</div>
// <blockquote>two<br></blockquote>
field1.setHtml(false,
'<blockquote id="quote" class="tr_bq">one<br>' +
'two<br></blockquote>');
var dom = field1.getEditableDomHelper();
goog.dom.Range.createCaret(dom.getElement('quote'), 2).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var elem = field1.getElement();
var secondBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[1];
assertHTMLEquals('two<br>', secondBlockquote.innerHTML);
// Verifies that a blockquote split doesn't happen if it doesn't need to.
field1.setHtml(false,
'<blockquote class="tr_bq">one<br id="brcursor"></blockquote>');
selectNodeAndHitEnter(field1, 'brcursor');
assertEquals(
1, dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem).length);
}
/**
* Tests that breaking in a text node before a BR doesn't result in unnecessary
* newlines.
* @bug 1471047
*/
function testEnterInBlockquoteRemovesUnnecessaryBrWithCursorBeforeBr() {
setUpFields(true);
// Assume the following HTML snippet:-
// <blockquote>one|<br>two<br></blockquote>
//
// After enter on the cursor position, the resulting HTML should be.
// <blockquote>one<br></blockquote>
// <div>&nbsp;</div>
// <blockquote>two<br></blockquote>
field1.setHtml(false,
'<blockquote id="quote" class="tr_bq">one<br>' +
'two<br></blockquote>');
var dom = field1.getEditableDomHelper();
var cursor = dom.getElement('quote').firstChild;
goog.dom.Range.createCaret(cursor, 3).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var elem = field1.getElement();
var secondBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[1];
assertHTMLEquals('two<br>', secondBlockquote.innerHTML);
// Ensures that standard text node split works as expected with the new
// change.
field1.setHtml(false,
'<blockquote id="quote" class="tr_bq">one<b>two</b><br>');
cursor = dom.getElement('quote').firstChild;
goog.dom.Range.createCaret(cursor, 3).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
secondBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[1];
assertHTMLEquals('<b>two</b><br>', secondBlockquote.innerHTML);
}
/**
* Tests that pressing enter in a blockquote doesn't create unnecessary
* DOM subtrees.
*
* @bug 1991539
* @bug 1991392
*/
function testEnterInBlockquoteRemovesExtraNodes() {
setUpFields(true);
// Let's assume we have the following DOM structure and the
// cursor is placed after the first numbered list item "one".
//
// <blockquote class="tr_bq">
// <div><div>a</div><ol><li>one|</li></div>
// <div>two</div>
// </blockquote>
//
// After pressing enter, we have the following structure.
//
// <blockquote class="tr_bq">
// <div><div>a</div><ol><li>one|</li></div>
// </blockquote>
// <div>&nbsp;</div>
// <blockquote class="tr_bq">
// <div><ol><li><span id=""></span></li></ol></div>
// <div>two</div>
// </blockquote>
//
// This appears to the user as an empty list. After the fix, the HTML
// will be
//
// <blockquote class="tr_bq">
// <div><div>a</div><ol><li>one|</li></div>
// </blockquote>
// <div>&nbsp;</div>
// <blockquote class="tr_bq">
// <div>two</div>
// </blockquote>
//
field1.setHtml(false,
'<blockquote class="tr_bq">' +
'<div><div>a</div><ol><li id="cursor">one</li></div>' +
'<div>b</div>' +
'</blockquote>');
var dom = field1.getEditableDomHelper();
goog.dom.Range.createCaret(dom.getElement('cursor').firstChild, 3).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var elem = field1.getElement();
var secondBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[1];
assertHTMLEquals('<div>b</div>', secondBlockquote.innerHTML);
// Ensure that we remove only unnecessary subtrees.
field1.setHtml(false,
'<blockquote class="tr_bq">' +
'<div><span>a</span><div id="cursor">one</div><div>two</div></div>' +
'<div><span>c</span></div>' +
'</blockquote>');
goog.dom.Range.createCaret(dom.getElement('cursor').firstChild, 3).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
secondBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[1];
var expectedHTML = '<div><div>two</div></div>' +
'<div><span>c</span></div>';
assertHTMLEquals(expectedHTML, secondBlockquote.innerHTML);
// Place the cursor in the middle of a line.
field1.setHtml(false,
'<blockquote id="quote" class="tr_bq">' +
'<div>one</div><div>two</div>' +
'</blockquote>');
goog.dom.Range.createCaret(
dom.getElement('quote').firstChild.firstChild, 1).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var blockquotes = dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem);
assertEquals(2, blockquotes.length);
assertHTMLEquals('<div>o</div>', blockquotes[0].innerHTML);
assertHTMLEquals('<div>ne</div><div>two</div>', blockquotes[1].innerHTML);
}
function testEnterInList() {
setUpFields(true);
// <enter> in a list should *never* be handled by custom code. Lists are
// just way too complicated to get right.
field1.setHtml(false,
'<ol><li>hi!<span id="field1cursor"></span></li></ol>');
if (goog.userAgent.OPERA) {
// Opera doesn't actually place the selection in the empty span
// unless we add a text node first.
var dom = field1.getEditableDomHelper();
dom.getElement('field1cursor').appendChild(dom.createTextNode(''));
}
var prevented = !selectNodeAndHitEnter(field1, 'field1cursor');
assertFalse('<enter> in a list should not be prevented', prevented);
}
function testEnterAtEndOfBlockInWebkit() {
setUpFields(true);
if (goog.userAgent.WEBKIT) {
field1.setHtml(false,
'<blockquote>hi!<span id="field1cursor"></span></blockquote>');
var cursor = field1.getEditableDomHelper().getElement('field1cursor');
goog.editor.range.placeCursorNextTo(cursor, false);
goog.dom.removeNode(cursor);
var prevented = !goog.testing.events.fireKeySequence(
field1.getElement(), goog.events.KeyCodes.ENTER);
waitForChangeEvents();
assertChangeFlags();
assert('event should have been prevented', prevented);
// Make sure that the block now has two brs.
var elem = field1.getElement();
assertEquals('should have inserted two br tags: ' + elem.innerHTML,
2, goog.dom.getElementsByTagNameAndClass('BR', null, elem).length);
}
}
/**
* Tests that deleting a BR that comes right before a block element works.
* @bug 1471096
* @bug 2056376
*/
function testDeleteBrBeforeBlock() {
setUpFields(true);
// This test only works on Gecko, because it's testing for manual deletion of
// BR tags, which is done only for Gecko. For other browsers we fall through
// and let the browser do the delete, which can only be tested with a robot
// test (see javascript/apps/editor/tests/delete_br_robot.html).
if (goog.userAgent.GECKO) {
field1.setHtml(false, 'one<br><br><div>two</div>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
helper.select(field1.getElement(), 2); // Between the two BR's.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted exactly one <br>',
'one<br><div>two</div>',
field1.getElement().innerHTML);
// We test the case where the BR has a previous sibling which is not
// a block level element.
field1.setHtml(false, 'one<br><ul><li>two</li></ul>');
helper.select(field1.getElement(), 1); // Between one and BR.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted the <br>',
'one<ul><li>two</li></ul>',
field1.getElement().innerHTML);
// Verify that the cursor is placed at the end of the text node "one".
var range = field1.getRange();
var focusNode = range.getFocusNode();
assertTrue('The selected range should be collapsed', range.isCollapsed());
assertTrue('The focus node should be the text node "one"',
focusNode.nodeType == goog.dom.NodeType.TEXT &&
focusNode.data == 'one');
assertEquals('The focus offset should be at the end of the text node "one"',
focusNode.length,
range.getFocusOffset());
assertTrue('The next sibling of the focus node should be the UL',
focusNode.nextSibling &&
focusNode.nextSibling.tagName == goog.dom.TagName.UL);
// We test the case where the previous sibling of the BR is a block
// level element.
field1.setHtml(false, '<div>foo</div><br><div><span>bar</span></div>');
helper.select(field1.getElement(), 1); // Before the BR.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted the <br>',
'<div>foo</div><div><span>bar</span></div>',
field1.getElement().innerHTML);
range = field1.getRange();
assertEquals('The selected range should be contained within the <span>',
goog.dom.TagName.SPAN,
range.getContainerElement().tagName);
assertTrue('The selected range should be collapsed', range.isCollapsed());
// Verify that the cursor is placed inside the span at the beginning of bar.
focusNode = range.getFocusNode();
assertTrue('The focus node should be the text node "bar"',
focusNode.nodeType == goog.dom.NodeType.TEXT &&
focusNode.data == 'bar');
assertEquals('The focus offset should be at the beginning ' +
'of the text node "bar"',
0,
range.getFocusOffset());
// We test the case where the BR does not have a previous sibling.
field1.setHtml(false, '<br><ul><li>one</li></ul>');
helper.select(field1.getElement(), 0); // Before the BR.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted the <br>',
'<ul><li>one</li></ul>',
field1.getElement().innerHTML);
range = field1.getRange();
// Verify that the cursor is placed inside the LI at the text node "one".
assertEquals('The selected range should be contained within the <li>',
goog.dom.TagName.LI,
range.getContainerElement().tagName);
assertTrue('The selected range should be collapsed', range.isCollapsed());
focusNode = range.getFocusNode();
assertTrue('The focus node should be the text node "one"',
(focusNode.nodeType == goog.dom.NodeType.TEXT &&
focusNode.data == 'one'));
assertEquals('The focus offset should be at the beginning of ' +
'the text node "one"',
0,
range.getFocusOffset());
// Testing deleting a BR followed by a block level element and preceded
// by a BR.
field1.setHtml(false, '<br><br><ul><li>one</li></ul>');
helper.select(field1.getElement(), 1); // Between the BR's.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted the <br>',
'<br><ul><li>one</li></ul>',
field1.getElement().innerHTML);
// Verify that the cursor is placed inside the LI at the text node "one".
range = field1.getRange();
assertEquals('The selected range should be contained within the <li>',
goog.dom.TagName.LI,
range.getContainerElement().tagName);
assertTrue('The selected range should be collapsed', range.isCollapsed());
focusNode = range.getFocusNode();
assertTrue('The focus node should be the text node "one"',
(focusNode.nodeType == goog.dom.NodeType.TEXT &&
focusNode.data == 'one'));
assertEquals('The focus offset should be at the beginning of ' +
'the text node "one"',
0,
range.getFocusOffset());
} // End if GECKO
}
/**
* Tests that deleting a BR before a blockquote doesn't remove quoted text.
* @bug 1471075
*/
function testDeleteBeforeBlockquote() {
setUpFields(true);
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<br><br><div><br><blockquote>foo</blockquote></div>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
helper.select(field1.getElement(), 0); // Before the first BR.
// Fire three deletes in quick succession.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted all the <br>\'s and the blockquote ' +
'isn\'t affected',
'<div><blockquote>foo</blockquote></div>',
field1.getElement().innerHTML);
var range = field1.getRange();
assertEquals('The selected range should be contained within the ' +
'<blockquote>',
goog.dom.TagName.BLOCKQUOTE,
range.getContainerElement().tagName);
assertTrue('The selected range should be collapsed', range.isCollapsed());
var focusNode = range.getFocusNode();
assertTrue('The focus node should be the text node "foo"',
(focusNode.nodeType == goog.dom.NodeType.TEXT &&
focusNode.data == 'foo'));
assertEquals('The focus offset should be at the ' +
'beginning of the text node "foo"',
0,
range.getFocusOffset());
}
}
/**
* Tests that deleting a BR is working normally (that the workaround for the
* bug is not causing double deletes).
* @bug 1471096
*/
function testDeleteBrNormal() {
setUpFields(true);
// This test only works on Gecko, because it's testing for manual deletion of
// BR tags, which is done only for Gecko. For other browsers we fall through
// and let the browser do the delete, which can only be tested with a robot
// test (see javascript/apps/editor/tests/delete_br_robot.html).
if (goog.userAgent.GECKO) {
field1.setHtml(false, 'one<br><br><br>two');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
helper.select(field1.getElement(), 2); // Between the first and second BR's.
field1.getElement().focus();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted exactly one <br>',
'one<br><br>two',
field1.getElement().innerHTML);
} // End if GECKO
}
/**
* Tests that deleteCursorSelectionW3C_ correctly recognizes visually
* collapsed selections in Opera even if they contain a <br>.
* See the deleteCursorSelectionW3C_ comment in enterhandler.js.
*/
function testCollapsedSelectionKeepsBrOpera() {
setUpFields(true);
if (goog.userAgent.OPERA) {
field1.setHtml(false, '<div><br id="pleasedontdeleteme"></div>');
field1.focus();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
assertNotNull('The <br> must not have been deleted',
goog.dom.getElement('pleasedontdeleteme'));
}
}
/**
* Selects the node at the given id, and simulates an ENTER keypress.
* @param {goog.editor.Field} field The field with the node.
* @param {string} id A DOM id.
* @return {boolean} Whether preventDefault was called on the event.
*/
function selectNodeAndHitEnter(field, id) {
var dom = field.getEditableDomHelper();
var cursor = dom.getElement(id);
goog.dom.Range.createFromNodeContents(cursor).select();
return goog.testing.events.fireKeySequence(
cursor, goog.events.KeyCodes.ENTER);
}
/**
* Creates a field with only the enter handler plugged in, for testing.
* @param {string} id A DOM id.
* @return {goog.editor.Field} A field.
*/
function makeField(id, classnameRequiredToSplitBlockquote) {
var field = new goog.editor.Field(id);
field.registerPlugin(new goog.editor.plugins.EnterHandler());
field.registerPlugin(new goog.editor.plugins.Blockquote(
classnameRequiredToSplitBlockquote));
goog.events.listen(field, goog.editor.Field.EventType.BEFORECHANGE,
function() {
// set the global flag that beforechange was fired.
firedBeforeChange = true;
});
goog.events.listen(field, goog.editor.Field.EventType.DELAYEDCHANGE,
function() {
// set the global flag that delayed change was fired.
firedDelayedChange = true;
});
return field;
}
/**
* Reset all the global flags related to change events.
*/
function resetChangeFlags() {
waitForChangeEvents();
firedBeforeChange = firedDelayedChange = false;
}
/**
* Asserts that both change flags were fired since the last reset.
*/
function assertChangeFlags() {
assert('Beforechange should have fired', firedBeforeChange);
assert('Delayedchange should have fired', firedDelayedChange);
}
/**
* Wait for delayedchange to propagate.
*/
function waitForChangeEvents() {
clock.tick(goog.editor.Field.DELAYED_CHANGE_FREQUENCY +
goog.editor.Field.CHANGE_FREQUENCY);
}
function getNbsp() {
// On WebKit (pre-528) and Opera, &nbsp; shows up as its unicode character in
// innerHTML under some circumstances.
return (goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('528')) ||
goog.userAgent.OPERA ? '\u00a0' : '&nbsp;';
}
function testPrepareContent() {
setUpFields(true);
assertPreparedContents('hi', 'hi');
assertPreparedContents(
goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES ? '<br>' : '', ' ');
}
/**
* Assert that the prepared contents matches the expected.
*/
function assertPreparedContents(expected, original) {
assertEquals(expected,
field1.reduceOp_(
goog.editor.Plugin.Op.PREPARE_CONTENTS_HTML, original));
}
// UTILITY FUNCTION TESTS.
function testDeleteW3CSimple() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML = '<div>abcd</div>';
var range = goog.dom.Range.createFromNodes(container.firstChild.firstChild,
1, container.firstChild.firstChild, 3);
range.select();
goog.editor.plugins.EnterHandler.deleteW3cRange_(range);
goog.testing.dom.assertHtmlContentsMatch('<div>ad</div>', container);
}
}
function testDeleteW3CAll() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML = '<div>abcd</div>';
var range = goog.dom.Range.createFromNodes(container.firstChild.firstChild,
0, container.firstChild.firstChild, 4);
range.select();
goog.editor.plugins.EnterHandler.deleteW3cRange_(range);
goog.testing.dom.assertHtmlContentsMatch('<div>&nbsp;</div>', container);
}
}
function testDeleteW3CPartialEnd() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML = '<div>ab</div><div>cd</div>';
var range = goog.dom.Range.createFromNodes(container.firstChild.firstChild,
1, container.lastChild.firstChild, 1);
range.select();
goog.editor.plugins.EnterHandler.deleteW3cRange_(range);
goog.testing.dom.assertHtmlContentsMatch('<div>ad</div>', container);
}
}
function testDeleteW3CNonPartialEnd() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML = '<div>ab</div><div>cd</div>';
var range = goog.dom.Range.createFromNodes(container.firstChild.firstChild,
1, container.lastChild.firstChild, 2);
range.select();
goog.editor.plugins.EnterHandler.deleteW3cRange_(range);
goog.testing.dom.assertHtmlContentsMatch('<div>a</div>', container);
}
}
function testIsInOneContainer() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML = '<div><br></div>';
var div = container.firstChild;
var range = goog.dom.Range.createFromNodes(div, 0, div, 1);
range.select();
assertTrue('Selection must be recognized as being in one container',
goog.editor.plugins.EnterHandler.isInOneContainerW3c_(range));
}
}
function testDeletingEndNodesWithNoNewLine() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML =
'a<div>b</div><div><br></div><div>c</div><div>d</div>';
var range = goog.dom.Range.createFromNodes(
container.childNodes[2], 0, container.childNodes[4].childNodes[0], 1);
range.select();
var newRange = goog.editor.plugins.EnterHandler.deleteW3cRange_(range);
goog.testing.dom.assertHtmlContentsMatch('a<div>b</div>', container);
assertTrue(newRange.isCollapsed());
assertEquals(container, newRange.getStartNode());
assertEquals(2, newRange.getStartOffset());
}
}
@@ -0,0 +1,327 @@
// Copyright 2012 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 A plugin to enable the First Strong Bidi algorithm. The First
* Strong algorithm as a heuristic used to automatically set paragraph direction
* depending on its content.
*
* In the documentation below, a 'paragraph' is the local element which we
* evaluate as a whole for purposes of determining directionality. It may be a
* block-level element (e.g. &lt;div&gt;) or a whole list (e.g. &lt;ul&gt;).
*
* This implementation is based on, but is not identical to, the original
* First Strong algorithm defined in Unicode
* @see http://www.unicode.org/reports/tr9/
* The central difference from the original First Strong algorithm is that this
* implementation decides the paragraph direction based on the first strong
* character that is <em>typed</em> into the paragraph, regardless of its
* location in the paragraph, as opposed to the original algorithm where it is
* the first character in the paragraph <em>by location</em>, regardless of
* whether other strong characters already appear in the paragraph, further its
* start.
*
* <em>Please note</em> that this plugin does not perform the direction change
* itself. Rather, it fires editor commands upon the key up event when a
* direction change needs to be performed; {@code goog.editor.Command.DIR_RTL}
* or {@code goog.editor.Command.DIR_RTL}.
*
*/
goog.provide('goog.editor.plugins.FirstStrong');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.TagIterator');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Command');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.node');
goog.require('goog.editor.range');
goog.require('goog.i18n.bidi');
goog.require('goog.i18n.uChar');
goog.require('goog.iter');
goog.require('goog.userAgent');
/**
* First Strong plugin.
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.FirstStrong = function() {
goog.editor.plugins.FirstStrong.base(this, 'constructor');
/**
* Indicates whether or not the cursor is in a paragraph we have not yet
* finished evaluating for directionality. This is set to true whenever the
* cursor is moved, and set to false after seeing a strong character in the
* paragraph the cursor is currently in.
*
* @type {boolean}
* @private
*/
this.isNewBlock_ = true;
/**
* Indicates whether or not the current paragraph the cursor is in should be
* set to Right-To-Left directionality.
*
* @type {boolean}
* @private
*/
this.switchToRtl_ = false;
/**
* Indicates whether or not the current paragraph the cursor is in should be
* set to Left-To-Right directionality.
*
* @type {boolean}
* @private
*/
this.switchToLtr_ = false;
};
goog.inherits(goog.editor.plugins.FirstStrong, goog.editor.Plugin);
/** @override */
goog.editor.plugins.FirstStrong.prototype.getTrogClassId = function() {
return 'FirstStrong';
};
/** @override */
goog.editor.plugins.FirstStrong.prototype.queryCommandValue =
function(command) {
return false;
};
/** @override */
goog.editor.plugins.FirstStrong.prototype.handleSelectionChange =
function(e, node) {
this.isNewBlock_ = true;
return false;
};
/**
* The name of the attribute which records the input text.
*
* @type {string}
* @const
*/
goog.editor.plugins.FirstStrong.INPUT_ATTRIBUTE = 'fs-input';
/** @override */
goog.editor.plugins.FirstStrong.prototype.handleKeyPress = function(e) {
if (!this.isNewBlock_) {
return false; // We've already determined this paragraph's direction.
}
// Ignore non-character key press events.
if (e.ctrlKey || e.metaKey) {
return false;
}
var newInput = goog.i18n.uChar.fromCharCode(e.charCode);
// IME's may return 0 for the charCode, which is a legitimate, non-Strong
// charCode, or they may return an illegal charCode (for which newInput will
// be false).
if (!newInput || !e.charCode) {
var browserEvent = e.getBrowserEvent();
if (browserEvent) {
if (goog.userAgent.IE && browserEvent['getAttribute']) {
newInput = browserEvent['getAttribute'](
goog.editor.plugins.FirstStrong.INPUT_ATTRIBUTE);
} else {
newInput = browserEvent[
goog.editor.plugins.FirstStrong.INPUT_ATTRIBUTE];
}
}
}
if (!newInput) {
return false; // Unrecognized key.
}
var isLtr = goog.i18n.bidi.isLtrChar(newInput);
var isRtl = !isLtr && goog.i18n.bidi.isRtlChar(newInput);
if (!isLtr && !isRtl) {
return false; // This character cannot change anything (it is not Strong).
}
// This character is Strongly LTR or Strongly RTL. We might switch direction
// on it now, but in any case we do not need to check any more characters in
// this paragraph after it.
this.isNewBlock_ = false;
// Are there no Strong characters already in the paragraph?
if (this.isNeutralBlock_()) {
this.switchToRtl_ = isRtl;
this.switchToLtr_ = isLtr;
}
return false;
};
/**
* Calls the flip directionality commands. This is done here so things go into
* the redo-undo stack at the expected order; fist enter the input, then flip
* directionality.
* @override
*/
goog.editor.plugins.FirstStrong.prototype.handleKeyUp = function(e) {
if (this.switchToRtl_) {
var field = this.getFieldObject();
field.dispatchChange(true);
field.execCommand(goog.editor.Command.DIR_RTL);
this.switchToRtl_ = false;
} else if (this.switchToLtr_) {
var field = this.getFieldObject();
field.dispatchChange(true);
field.execCommand(goog.editor.Command.DIR_LTR);
this.switchToLtr_ = false;
}
return false;
};
/**
* @return {Element} The lowest Block element ancestor of the node where the
* next character will be placed.
* @private
*/
goog.editor.plugins.FirstStrong.prototype.getBlockAncestor_ = function() {
var start = this.getFieldObject().getRange().getStartNode();
// Go up in the DOM until we reach a Block element.
while (!goog.editor.plugins.FirstStrong.isBlock_(start)) {
start = start.parentNode;
}
return /** @type {Element} */ (start);
};
/**
* @return {boolean} Whether the paragraph where the next character will be
* entered contains only non-Strong characters.
* @private
*/
goog.editor.plugins.FirstStrong.prototype.isNeutralBlock_ = function() {
var root = this.getBlockAncestor_();
// The exact node with the cursor location. Simply calling getStartNode() on
// the range only returns the containing block node.
var cursor = goog.editor.range.getDeepEndPoint(
this.getFieldObject().getRange(), false).node;
// In FireFox the BR tag also represents a change in paragraph if not inside a
// list. So we need special handling to only look at the sub-block between
// BR elements.
var blockFunction = (goog.userAgent.GECKO &&
!this.isList_(root)) ?
goog.editor.plugins.FirstStrong.isGeckoBlock_ :
goog.editor.plugins.FirstStrong.isBlock_;
var paragraph = this.getTextAround_(root, cursor,
blockFunction);
// Not using {@code goog.i18n.bidi.isNeutralText} as it contains additional,
// unwanted checks to the content.
return !goog.i18n.bidi.hasAnyLtr(paragraph) &&
!goog.i18n.bidi.hasAnyRtl(paragraph);
};
/**
* Checks if an element is a list element ('UL' or 'OL').
*
* @param {Element} element The element to test.
* @return {boolean} Whether the element is a list element ('UL' or 'OL').
* @private
*/
goog.editor.plugins.FirstStrong.prototype.isList_ = function(element) {
if (!element) {
return false;
}
var tagName = element.tagName;
return tagName == goog.dom.TagName.UL || tagName == goog.dom.TagName.OL;
};
/**
* Returns the text within the local paragraph around the cursor.
* Notice that for GECKO a BR represents a pargraph change despite not being a
* block element.
*
* @param {Element} root The first block element ancestor of the node the cursor
* is in.
* @param {Node} cursorLocation Node where the cursor currently is, marking the
* paragraph whose text we will return.
* @param {function(Node): boolean} isParagraphBoundary The function to
* determine if a node represents the start or end of the paragraph.
* @return {string} the text in the paragraph around the cursor location.
* @private
*/
goog.editor.plugins.FirstStrong.prototype.getTextAround_ = function(root,
cursorLocation, isParagraphBoundary) {
// The buffer where we're collecting the text.
var buffer = [];
// Have we reached the cursor yet, or are we still before it?
var pastCursorLocation = false;
if (root && cursorLocation) {
goog.iter.some(new goog.dom.TagIterator(root), function(node) {
if (node == cursorLocation) {
pastCursorLocation = true;
} else if (isParagraphBoundary(node)) {
if (pastCursorLocation) {
// This is the end of the paragraph containing the cursor. We're done.
return true;
} else {
// All we collected so far does not count; it was in a previous
// paragraph that did not contain the cursor.
buffer = [];
}
}
if (node.nodeType == goog.dom.NodeType.TEXT) {
buffer.push(node.nodeValue);
}
return false; // Keep going.
});
}
return buffer.join('');
};
/**
* @param {Node} node Node to check.
* @return {boolean} Does the given node represent a Block element? Notice we do
* not consider list items as Block elements in the algorithm.
* @private
*/
goog.editor.plugins.FirstStrong.isBlock_ = function(node) {
return !!node && goog.editor.node.isBlockTag(node) &&
node.tagName != goog.dom.TagName.LI;
};
/**
* @param {Node} node Node to check.
* @return {boolean} Does the given node represent a Block element from the
* point of view of FireFox? Notice we do not consider list items as Block
* elements in the algorithm.
* @private
*/
goog.editor.plugins.FirstStrong.isGeckoBlock_ = function(node) {
return !!node && (node.tagName == goog.dom.TagName.BR ||
goog.editor.plugins.FirstStrong.isBlock_(node));
};
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<!--
Copyright 2012 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<html>
<head>
<meta charset="utf-8" />
<title>
Trogedit Unit Tests - goog.editor.plugins.FirstStrong
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.FirstStrongTest');
</script>
</head>
<body>
<div id="root">
<div id="field">
</div>
</div>
</body>
</html>
@@ -0,0 +1,406 @@
// Copyright 2012 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.
goog.provide('goog.editor.plugins.FirstStrongTest');
goog.setTestOnly('goog.editor.plugins.FirstStrongTest');
goog.require('goog.dom.Range');
goog.require('goog.editor.Command');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.FirstStrong');
goog.require('goog.editor.range');
goog.require('goog.events.KeyCodes');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
// The key code for the Hebrew א, a strongly RTL letter.
var ALEPH_KEYCODE = 1488;
var field;
var fieldElement;
var dom;
var helper;
var triggeredCommand = null;
function setUp() {
field = new goog.editor.Field('field');
field.registerPlugin(new goog.editor.plugins.FirstStrong());
field.makeEditable();
fieldElement = field.getElement();
helper = new goog.testing.editor.TestHelper(fieldElement);
dom = field.getEditableDomHelper();
// Mock out execCommand to see if a direction change has been triggered.
field.execCommand = function(command) {
if (command == goog.editor.Command.DIR_LTR ||
command == goog.editor.Command.DIR_RTL)
triggeredCommand = command;
};
}
function tearDown() {
goog.dispose(field);
goog.dispose(helper);
triggeredCommand = null;
}
function testFirstCharacter_RTL() {
field.setHtml(false, '<div id="text">&nbsp;</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstCharacter_LTR() {
field.setHtml(false, '<div dir="rtl" id="text">&nbsp;</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
function testFirstStrongCharacter_RTL() {
field.setHtml(false, '<div id="text">123.7 3121, <b><++{}></b> - $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacter_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text">123.7 3121, <b><++{}></b> - $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
function testNotStrongCharacter_RTL() {
field.setHtml(false, '<div id="text">123.7 3121, - $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement, goog.events.KeyCodes.NINE);
assertNoCommand();
}
function testNotStrongCharacter_LTR() {
field.setHtml(false, '<div dir="rtl" id="text">123.7 3121 $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement, goog.events.KeyCodes.NINE);
assertNoCommand();
}
function testNotFirstStrongCharacter_RTL() {
field.setHtml(false, '<div id="text">123.7 3121, <b>English</b> - $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertNoCommand();
}
function testNotFirstStrongCharacter_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text">123.7 3121, <b>עברית</b> - $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertNoCommand();
}
function testFirstStrongCharacterWithInnerDiv_RTL() {
field.setHtml(false,
'<div id="text">123.7 3121, <b id="b"><++{}></b>' +
'<div id="inner">English</div>' +
'</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterWithInnerDiv_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text">123.7 3121, <b id="b"><++{}></b>' +
'<div id="inner">English</div>' +
'</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
/**
* Regression for {@link http://b/7549696}
*/
function testFirstStrongCharacterInNewLine_RTL() {
field.setHtml(false, '<div><b id="cur">English<br>1</b></div>');
goog.dom.Range.createCaret(dom.$('cur'), 2).select();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
// Only GECKO treats <br> as a new paragraph.
if (goog.userAgent.GECKO) {
assertRTL();
} else {
assertNoCommand();
}
}
function testFirstStrongCharacterInParagraph_RTL() {
field.setHtml(false,
'<div id="text1">1&gt; English</div>' +
'<div id="text2">2&gt;</div>' +
'<div id="text3">3&gt;</div>');
goog.dom.Range.createCaret(dom.$('text2'), 0).select();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterInParagraph_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text1">1&gt; עברית</div>' +
'<div dir="rtl" id="text2">2&gt;</div>' +
'<div dir="rtl" id="text3">3&gt;</div>');
goog.dom.Range.createCaret(dom.$('text2'), 0).select();
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
function testFirstStrongCharacterInList_RTL() {
field.setHtml(false,
'<div id="text1">1&gt; English</div>' +
'<ul id="list">' +
'<li>10&gt;</li>' +
'<li id="li2"></li>' +
'<li>30</li>' +
'</ul>' +
'<div id="text3">3&gt;</div>');
goog.editor.range.placeCursorNextTo(dom.$('li2'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterInList_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text1">1&gt; English</div>' +
'<ul dir="rtl" id="list">' +
'<li>10&gt;</li>' +
'<li id="li2"></li>' +
'<li>30</li>' +
'</ul>' +
'<div dir="rtl" id="text3">3&gt;</div>');
goog.editor.range.placeCursorNextTo(dom.$('li2'), true);
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
function testNotFirstStrongCharacterInList_RTL() {
field.setHtml(false,
'<div id="text1">1</div>' +
'<ul id="list">' +
'<li>10&gt;</li>' +
'<li id="li2"></li>' +
'<li>30<b>3<i>Hidden English</i>32</b></li>' +
'</ul>' +
'<div id="text3">3&gt;</div>');
goog.editor.range.placeCursorNextTo(dom.$('li2'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertNoCommand();
}
function testNotFirstStrongCharacterInList_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text1">1&gt; English</div>' +
'<ul dir="rtl" id="list">' +
'<li>10&gt;</li>' +
'<li id="li2"></li>' +
'<li>30<b>3<i>עברית סמויה</i>32</b></li>' +
'</ul>' +
'<div dir="rtl" id="text3">3&gt;</div>');
goog.editor.range.placeCursorNextTo(dom.$('li2'), true);
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertNoCommand();
}
function testFirstStrongCharacterWithBR_RTL() {
field.setHtml(false,
'<div id="container">' +
'<div id="text1">ABC</div>' +
'<div id="text2">' +
'1<br>' +
'2<b id="inner">3</b><i>4<u>5<br>' +
'6</u>7</i>8</b>9<br>' +
'10' +
'</div>' +
'<div id="text3">11</div>' +
'</div>');
goog.editor.range.placeCursorNextTo(dom.$('inner'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterWithBR_LTR() {
field.setHtml(false,
'<div dir="rtl" id="container">' +
'<div dir="rtl" id="text1">אבג</div>' +
'<div dir="rtl" id="text2">' +
'1<br>' +
'2<b id="inner">3</b><i>4<u>5<br>' +
'6</u>7</i>8</b>9<br>' +
'10' +
'</div>' +
'<div dir="rtl" id="text3">11</div>' +
'</div>');
goog.editor.range.placeCursorNextTo(dom.$('inner'), true);
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
function testNotFirstStrongCharacterInBR_RTL() {
field.setHtml(false,
'<div id="container">' +
'<div id="text1">ABC</div>' +
'<div id="text2">' +
'1<br>' +
'2<b id="inner">3</b><i><em>4G</em><u>5<br>' +
'6</u>7</i>8</b>9<br>' +
'10' +
'</div>' +
'<div id="text3">11</div>' +
'</div>');
goog.editor.range.placeCursorNextTo(dom.$('inner'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertNoCommand();
}
function testNotFirstStrongCharacterInBR_LTR() {
field.setHtml(false,
'<div dir="rtl" id="container">' +
'<div dir="rtl" id="text1">ABC</div>' +
'<div dir="rtl" id="text2">' +
'1<br>' +
'2<b id="inner">3</b><i><em>4G</em><u>5<br>' +
'6</u>7</i>8</b>9<br>' +
'10' +
'</div>' +
'<div dir="rtl" id="text3">11</div>' +
'</div>');
goog.editor.range.placeCursorNextTo(dom.$('inner'), true);
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertNoCommand();
}
/**
* Regression for {@link http://b/7530985}
*/
function testFirstStrongCharacterWithPreviousBlockSibling_RTL() {
field.setHtml(false, '<div>Te<div>xt</div>1<b id="cur">2</b>3</div>');
goog.editor.range.placeCursorNextTo(dom.$('cur'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterWithPreviousBlockSibling_LTR() {
field.setHtml(
false, '<div dir="rtl">טק<div>סט</div>1<b id="cur">2</b>3</div>');
goog.editor.range.placeCursorNextTo(dom.$('cur'), true);
goog.testing.events.fireKeySequence(fieldElement, goog.events.KeyCodes.A);
assertLTR();
}
function testFirstStrongCharacterWithFollowingBlockSibling_RTL() {
field.setHtml(false, '<div>1<b id="cur">2</b>3<div>Te</div>xt</div>');
goog.editor.range.placeCursorNextTo(dom.$('cur'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterWithFollowingBlockSibling_RTL() {
field.setHtml(false, '<div dir="rtl">1<b id="cur">2</b>3<div>א</div>ב</div>');
goog.editor.range.placeCursorNextTo(dom.$('cur'), true);
goog.testing.events.fireKeySequence(fieldElement, goog.events.KeyCodes.A);
assertLTR();
}
function testFirstStrongCharacterFromIME_RTL() {
field.setHtml(false, '<div id="text">123.7 3121, </div>');
field.focusAndPlaceCursorAtStart();
var attributes = {};
attributes[goog.editor.plugins.FirstStrong.INPUT_ATTRIBUTE] = 'אבג';
goog.testing.events.fireNonAsciiKeySequence(fieldElement, 0, 0, attributes);
if (goog.userAgent.IE) {
// goog.testing.events.fireNonAsciiKeySequence doesn't send KEYPRESS event
// so no command is expected.
assertNoCommand();
} else {
assertRTL();
}
}
function testFirstCharacterFromIME_LTR() {
field.setHtml(false, '<div dir="rtl" id="text"> 1234 </div>');
field.focusAndPlaceCursorAtStart();
var attributes = {};
attributes[goog.editor.plugins.FirstStrong.INPUT_ATTRIBUTE] = 'ABC';
goog.testing.events.fireNonAsciiKeySequence(fieldElement, 0, 0, attributes);
if (goog.userAgent.IE) {
// goog.testing.events.fireNonAsciiKeySequence doesn't send KEYPRESS event
// so no command is expected.
assertNoCommand();
} else {
assertLTR();
}
}
function assertRTL() {
assertEquals(goog.editor.Command.DIR_RTL, triggeredCommand);
}
function assertLTR() {
assertEquals(goog.editor.Command.DIR_LTR, triggeredCommand);
}
function assertNoCommand() {
assertNull(triggeredCommand);
}
@@ -0,0 +1,96 @@
// 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 Handles applying header styles to text.
*
*/
goog.provide('goog.editor.plugins.HeaderFormatter');
goog.require('goog.editor.Command');
goog.require('goog.editor.Plugin');
goog.require('goog.userAgent');
/**
* Applies header styles to text.
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.HeaderFormatter = function() {
goog.editor.Plugin.call(this);
};
goog.inherits(goog.editor.plugins.HeaderFormatter, goog.editor.Plugin);
/** @override */
goog.editor.plugins.HeaderFormatter.prototype.getTrogClassId = function() {
return 'HeaderFormatter';
};
// TODO(user): Move execCommand functionality from basictextformatter into
// here for headers. I'm not doing this now because it depends on the
// switch statements in basictextformatter and we'll need to abstract that out
// in order to seperate out any of the functions from basictextformatter.
/**
* Commands that can be passed as the optional argument to execCommand.
* @enum {string}
*/
goog.editor.plugins.HeaderFormatter.HEADER_COMMAND = {
H1: 'H1',
H2: 'H2',
H3: 'H3',
H4: 'H4'
};
/**
* @override
*/
goog.editor.plugins.HeaderFormatter.prototype.handleKeyboardShortcut = function(
e, key, isModifierPressed) {
if (!isModifierPressed) {
return false;
}
var command = null;
switch (key) {
case '1':
command = goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H1;
break;
case '2':
command = goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H2;
break;
case '3':
command = goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H3;
break;
case '4':
command = goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H4;
break;
}
if (command) {
this.getFieldObject().execCommand(
goog.editor.Command.FORMAT_BLOCK, command);
// Prevent default isn't enough to cancel tab navigation in FF.
if (goog.userAgent.GECKO) {
e.stopPropagation();
}
return true;
}
return false;
};
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.HeaderFormatter Tests
</title>
<script type="text/javascript" src="../../base.js">
</script>
<script type="text/javascript">
goog.require('goog.editor.plugins.HeaderFormatterTest');
</script>
</head>
<body>
<div id="field">
</div>
</body>
</html>
@@ -0,0 +1,95 @@
// 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.
goog.provide('goog.editor.plugins.HeaderFormatterTest');
goog.setTestOnly('goog.editor.plugins.HeaderFormatterTest');
goog.require('goog.dom');
goog.require('goog.editor.Command');
goog.require('goog.editor.plugins.BasicTextFormatter');
goog.require('goog.editor.plugins.HeaderFormatter');
goog.require('goog.events.BrowserEvent');
goog.require('goog.testing.LooseMock');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var field;
var editableField;
var headerFormatter;
var btf;
var testHelper;
function setUpPage() {
field = goog.dom.getElement('field');
testHelper = new goog.testing.editor.TestHelper(field);
}
function setUp() {
testHelper.setUpEditableElement();
editableField = new goog.testing.editor.FieldMock();
headerFormatter = new goog.editor.plugins.HeaderFormatter();
headerFormatter.registerFieldObject(editableField);
btf = new goog.editor.plugins.BasicTextFormatter();
btf.registerFieldObject(editableField);
}
function tearDown() {
editableField = null;
headerFormatter.dispose();
testHelper.tearDownEditableElement();
}
function testHeaderShortcuts() {
field.innerHTML = 'myText';
var textNode = field.firstChild;
testHelper.select(textNode, 0, textNode, textNode.length);
editableField.getElement();
editableField.$anyTimes();
editableField.$returns(field);
editableField.getPluginByClassId('Bidi');
editableField.$anyTimes();
editableField.$returns(null);
editableField.execCommand(
goog.editor.Command.FORMAT_BLOCK,
goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H1);
// Bypass EditableField's execCommand and directly call
// basicTextFormatter's. Future version of headerformatter will include
// that code in its own execCommand.
editableField.$does(function() {
btf.execCommandInternal(
goog.editor.plugins.BasicTextFormatter.COMMAND.FORMAT_BLOCK,
goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H1); });
var event = new goog.testing.LooseMock(goog.events.BrowserEvent);
if (goog.userAgent.GECKO) {
event.stopPropagation();
}
editableField.$replay();
event.$replay();
assertTrue('Event handled',
headerFormatter.handleKeyboardShortcut(event, '1', true));
assertEquals('Field contains a header', 'H1', field.firstChild.nodeName);
editableField.$verify();
event.$verify();
}
@@ -0,0 +1,585 @@
// 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 Base class for bubble plugins.
*
*/
goog.provide('goog.editor.plugins.LinkBubble');
goog.provide('goog.editor.plugins.LinkBubble.Action');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Command');
goog.require('goog.editor.Link');
goog.require('goog.editor.plugins.AbstractBubblePlugin');
goog.require('goog.editor.range');
goog.require('goog.functions');
goog.require('goog.string');
goog.require('goog.style');
goog.require('goog.ui.editor.messages');
goog.require('goog.uri.utils');
goog.require('goog.window');
/**
* Property bubble plugin for links.
* @param {...!goog.editor.plugins.LinkBubble.Action} var_args List of
* extra actions supported by the bubble.
* @constructor
* @extends {goog.editor.plugins.AbstractBubblePlugin}
*/
goog.editor.plugins.LinkBubble = function(var_args) {
goog.editor.plugins.LinkBubble.base(this, 'constructor');
/**
* List of extra actions supported by the bubble.
* @type {Array<!goog.editor.plugins.LinkBubble.Action>}
* @private
*/
this.extraActions_ = goog.array.toArray(arguments);
/**
* List of spans corresponding to the extra actions.
* @type {Array<!Element>}
* @private
*/
this.actionSpans_ = [];
/**
* A list of whitelisted URL schemes which are safe to open.
* @type {Array<string>}
* @private
*/
this.safeToOpenSchemes_ = ['http', 'https', 'ftp'];
};
goog.inherits(goog.editor.plugins.LinkBubble,
goog.editor.plugins.AbstractBubblePlugin);
/**
* Element id for the link text.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.LINK_TEXT_ID_ = 'tr_link-text';
/**
* Element id for the test link span.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_ = 'tr_test-link-span';
/**
* Element id for the test link.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.TEST_LINK_ID_ = 'tr_test-link';
/**
* Element id for the change link span.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.CHANGE_LINK_SPAN_ID_ = 'tr_change-link-span';
/**
* Element id for the link.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.CHANGE_LINK_ID_ = 'tr_change-link';
/**
* Element id for the delete link span.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.DELETE_LINK_SPAN_ID_ = 'tr_delete-link-span';
/**
* Element id for the delete link.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.DELETE_LINK_ID_ = 'tr_delete-link';
/**
* Element id for the link bubble wrapper div.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.LINK_DIV_ID_ = 'tr_link-div';
/**
* @desc Text label for link that lets the user click it to see where the link
* this bubble is for point to.
*/
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_TEST_LINK = goog.getMsg(
'Go to link: ');
/**
* @desc Label that pops up a dialog to change the link.
*/
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_CHANGE = goog.getMsg(
'Change');
/**
* @desc Label that allow the user to remove this link.
*/
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_REMOVE = goog.getMsg(
'Remove');
/**
* @desc Message shown in a link bubble when the link is not a valid url.
*/
goog.editor.plugins.LinkBubble.MSG_INVALID_URL_LINK_BUBBLE = goog.getMsg(
'invalid url');
/**
* Whether to stop leaking the page's url via the referrer header when the
* link text link is clicked.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkBubble.prototype.stopReferrerLeaks_ = false;
/**
* Whether to block opening links with a non-whitelisted URL scheme.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkBubble.prototype.blockOpeningUnsafeSchemes_ =
true;
/**
* Tells the plugin to stop leaking the page's url via the referrer header when
* the link text link is clicked. When the user clicks on a link, the
* browser makes a request for the link url, passing the url of the current page
* in the request headers. If the user wants the current url to be kept secret
* (e.g. an unpublished document), the owner of the url that was clicked will
* see the secret url in the request headers, and it will no longer be a secret.
* Calling this method will not send a referrer header in the request, just as
* if the user had opened a blank window and typed the url in themselves.
*/
goog.editor.plugins.LinkBubble.prototype.stopReferrerLeaks = function() {
// TODO(user): Right now only 2 plugins have this API to stop
// referrer leaks. If more plugins need to do this, come up with a way to
// enable the functionality in all plugins at once. Same thing for
// setBlockOpeningUnsafeSchemes and associated functionality.
this.stopReferrerLeaks_ = true;
};
/**
* Tells the plugin whether to block URLs with schemes not in the whitelist.
* If blocking is enabled, this plugin will not linkify the link in the bubble
* popup.
* @param {boolean} blockOpeningUnsafeSchemes Whether to block non-whitelisted
* schemes.
*/
goog.editor.plugins.LinkBubble.prototype.setBlockOpeningUnsafeSchemes =
function(blockOpeningUnsafeSchemes) {
this.blockOpeningUnsafeSchemes_ = blockOpeningUnsafeSchemes;
};
/**
* Sets a whitelist of allowed URL schemes that are safe to open.
* Schemes should all be in lowercase. If the plugin is set to block opening
* unsafe schemes, user-entered URLs will be converted to lowercase and checked
* against this list. The whitelist has no effect if blocking is not enabled.
* @param {Array<string>} schemes String array of URL schemes to allow (http,
* https, etc.).
*/
goog.editor.plugins.LinkBubble.prototype.setSafeToOpenSchemes =
function(schemes) {
this.safeToOpenSchemes_ = schemes;
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.getTrogClassId = function() {
return 'LinkBubble';
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.isSupportedCommand =
function(command) {
return command == goog.editor.Command.UPDATE_LINK_BUBBLE;
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.execCommandInternal =
function(command, var_args) {
if (command == goog.editor.Command.UPDATE_LINK_BUBBLE) {
this.updateLink_();
}
};
/**
* Updates the href in the link bubble with a new link.
* @private
*/
goog.editor.plugins.LinkBubble.prototype.updateLink_ = function() {
var targetEl = this.getTargetElement();
if (targetEl) {
this.closeBubble();
this.createBubble(targetEl);
}
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.getBubbleTargetFromSelection =
function(selectedElement) {
var bubbleTarget = goog.dom.getAncestorByTagNameAndClass(selectedElement,
goog.dom.TagName.A);
if (!bubbleTarget) {
// See if the selection is touching the right side of a link, and if so,
// show a bubble for that link. The check for "touching" is very brittle,
// and currently only guarantees that it will pop up a bubble at the
// position the cursor is placed at after the link dialog is closed.
// NOTE(robbyw): This assumes this method is always called with
// selected element = range.getContainerElement(). Right now this is true,
// but attempts to re-use this method for other purposes could cause issues.
// TODO(robbyw): Refactor this method to also take a range, and use that.
var range = this.getFieldObject().getRange();
if (range && range.isCollapsed() && range.getStartOffset() == 0) {
var startNode = range.getStartNode();
var previous = startNode.previousSibling;
if (previous && previous.tagName == goog.dom.TagName.A) {
bubbleTarget = previous;
}
}
}
return /** @type {Element} */ (bubbleTarget);
};
/**
* Set the optional function for getting the "test" link of a url.
* @param {function(string) : string} func The function to use.
*/
goog.editor.plugins.LinkBubble.prototype.setTestLinkUrlFn = function(func) {
this.testLinkUrlFn_ = func;
};
/**
* Returns the target element url for the bubble.
* @return {string} The url href.
* @protected
*/
goog.editor.plugins.LinkBubble.prototype.getTargetUrl = function() {
// Get the href-attribute through getAttribute() rather than the href property
// because Google-Toolbar on Firefox with "Send with Gmail" turned on
// modifies the href-property of 'mailto:' links but leaves the attribute
// untouched.
return this.getTargetElement().getAttribute('href') || '';
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.getBubbleType = function() {
return goog.dom.TagName.A;
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.getBubbleTitle = function() {
return goog.ui.editor.messages.MSG_LINK_CAPTION;
};
/**
* Returns the message to display for testing a link.
* @return {string} The message for testing a link.
* @protected
*/
goog.editor.plugins.LinkBubble.prototype.getTestLinkMessage = function() {
return goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_TEST_LINK;
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.createBubbleContents = function(
bubbleContainer) {
var linkObj = this.getLinkToTextObj_();
// Create linkTextSpan, show plain text for e-mail address or truncate the
// text to <= 48 characters so that property bubbles don't grow too wide and
// create a link if URL. Only linkify valid links.
// TODO(robbyw): Repalce this color with a CSS class.
var color = linkObj.valid ? 'black' : 'red';
var shouldOpenUrl = this.shouldOpenUrl(linkObj.linkText);
var linkTextSpan;
if (goog.editor.Link.isLikelyEmailAddress(linkObj.linkText) ||
!linkObj.valid || !shouldOpenUrl) {
linkTextSpan = this.dom_.createDom(goog.dom.TagName.SPAN,
{
id: goog.editor.plugins.LinkBubble.LINK_TEXT_ID_,
style: 'color:' + color
}, this.dom_.createTextNode(linkObj.linkText));
} else {
var testMsgSpan = this.dom_.createDom(goog.dom.TagName.SPAN,
{id: goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_},
this.getTestLinkMessage());
linkTextSpan = this.dom_.createDom(goog.dom.TagName.SPAN,
{
id: goog.editor.plugins.LinkBubble.LINK_TEXT_ID_,
style: 'color:' + color
}, '');
var linkText = goog.string.truncateMiddle(linkObj.linkText, 48);
// Actually creates a pseudo-link that can't be right-clicked to open in a
// new tab, because that would avoid the logic to stop referrer leaks.
this.createLink(goog.editor.plugins.LinkBubble.TEST_LINK_ID_,
this.dom_.createTextNode(linkText).data,
this.testLink,
linkTextSpan);
}
var changeLinkSpan = this.createLinkOption(
goog.editor.plugins.LinkBubble.CHANGE_LINK_SPAN_ID_);
this.createLink(goog.editor.plugins.LinkBubble.CHANGE_LINK_ID_,
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_CHANGE,
this.showLinkDialog_, changeLinkSpan);
// This function is called multiple times - we have to reset the array.
this.actionSpans_ = [];
for (var i = 0; i < this.extraActions_.length; i++) {
var action = this.extraActions_[i];
var actionSpan = this.createLinkOption(action.spanId_);
this.actionSpans_.push(actionSpan);
this.createLink(action.linkId_, action.message_,
function() {
action.actionFn_(this.getTargetUrl());
},
actionSpan);
}
var removeLinkSpan = this.createLinkOption(
goog.editor.plugins.LinkBubble.DELETE_LINK_SPAN_ID_);
this.createLink(goog.editor.plugins.LinkBubble.DELETE_LINK_ID_,
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_REMOVE,
this.deleteLink_, removeLinkSpan);
this.onShow();
var bubbleContents = this.dom_.createDom(goog.dom.TagName.DIV,
{id: goog.editor.plugins.LinkBubble.LINK_DIV_ID_},
testMsgSpan || '', linkTextSpan, changeLinkSpan);
for (i = 0; i < this.actionSpans_.length; i++) {
bubbleContents.appendChild(this.actionSpans_[i]);
}
bubbleContents.appendChild(removeLinkSpan);
goog.dom.appendChild(bubbleContainer, bubbleContents);
};
/**
* Tests the link by opening it in a new tab/window. Should be used as the
* click event handler for the test pseudo-link.
* @protected
*/
goog.editor.plugins.LinkBubble.prototype.testLink = function() {
goog.window.open(this.getTestLinkAction_(),
{
'target': '_blank',
'noreferrer': this.stopReferrerLeaks_
}, this.getFieldObject().getAppWindow());
};
/**
* Returns whether the URL should be considered invalid. This always returns
* false in the base class, and should be overridden by subclasses that wish
* to impose validity rules on URLs.
* @param {string} url The url to check.
* @return {boolean} Whether the URL should be considered invalid.
*/
goog.editor.plugins.LinkBubble.prototype.isInvalidUrl = goog.functions.FALSE;
/**
* Gets the text to display for a link, based on the type of link
* @return {!Object} Returns an object of the form:
* {linkText: displayTextForLinkTarget, valid: ifTheLinkIsValid}.
* @private
*/
goog.editor.plugins.LinkBubble.prototype.getLinkToTextObj_ = function() {
var isError;
var targetUrl = this.getTargetUrl();
if (this.isInvalidUrl(targetUrl)) {
targetUrl = goog.editor.plugins.LinkBubble.MSG_INVALID_URL_LINK_BUBBLE;
isError = true;
} else if (goog.editor.Link.isMailto(targetUrl)) {
targetUrl = targetUrl.substring(7); // 7 == "mailto:".length
}
return {linkText: targetUrl, valid: !isError};
};
/**
* Shows the link dialog.
* @param {goog.events.BrowserEvent} e The event.
* @private
*/
goog.editor.plugins.LinkBubble.prototype.showLinkDialog_ = function(e) {
// Needed when this occurs due to an ENTER key event, else the newly created
// dialog manages to have its OK button pressed, causing it to disappear.
e.preventDefault();
this.getFieldObject().execCommand(goog.editor.Command.MODAL_LINK_EDITOR,
new goog.editor.Link(
/** @type {HTMLAnchorElement} */ (this.getTargetElement()),
false));
this.closeBubble();
};
/**
* Deletes the link associated with the bubble
* @private
*/
goog.editor.plugins.LinkBubble.prototype.deleteLink_ = function() {
this.getFieldObject().dispatchBeforeChange();
var link = this.getTargetElement();
var child = link.lastChild;
goog.dom.flattenElement(link);
goog.editor.range.placeCursorNextTo(child, false);
this.closeBubble();
this.getFieldObject().dispatchChange();
this.getFieldObject().focus();
};
/**
* Sets the proper state for the action links.
* @protected
* @override
*/
goog.editor.plugins.LinkBubble.prototype.onShow = function() {
var linkDiv = this.dom_.getElement(
goog.editor.plugins.LinkBubble.LINK_DIV_ID_);
if (linkDiv) {
var testLinkSpan = this.dom_.getElement(
goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_);
if (testLinkSpan) {
var url = this.getTargetUrl();
goog.style.setElementShown(testLinkSpan, !goog.editor.Link.isMailto(url));
}
for (var i = 0; i < this.extraActions_.length; i++) {
var action = this.extraActions_[i];
var actionSpan = this.dom_.getElement(action.spanId_);
if (actionSpan) {
goog.style.setElementShown(actionSpan, action.toShowFn_(
this.getTargetUrl()));
}
}
}
};
/**
* Gets the url for the bubble test link. The test link is the link in the
* bubble the user can click on to make sure the link they entered is correct.
* @return {string} The url for the bubble link href.
* @private
*/
goog.editor.plugins.LinkBubble.prototype.getTestLinkAction_ = function() {
var targetUrl = this.getTargetUrl();
return this.testLinkUrlFn_ ? this.testLinkUrlFn_(targetUrl) : targetUrl;
};
/**
* Checks whether the plugin should open the given url in a new window.
* @param {string} url The url to check.
* @return {boolean} If the plugin should open the given url in a new window.
* @protected
*/
goog.editor.plugins.LinkBubble.prototype.shouldOpenUrl = function(url) {
return !this.blockOpeningUnsafeSchemes_ || this.isSafeSchemeToOpen_(url);
};
/**
* Determines whether or not a url has a scheme which is safe to open.
* Schemes like javascript are unsafe due to the possibility of XSS.
* @param {string} url A url.
* @return {boolean} Whether the url has a safe scheme.
* @private
*/
goog.editor.plugins.LinkBubble.prototype.isSafeSchemeToOpen_ =
function(url) {
var scheme = goog.uri.utils.getScheme(url) || 'http';
return goog.array.contains(this.safeToOpenSchemes_, scheme.toLowerCase());
};
/**
* Constructor for extra actions that can be added to the link bubble.
* @param {string} spanId The ID for the span showing the action.
* @param {string} linkId The ID for the link showing the action.
* @param {string} message The text for the link showing the action.
* @param {function(string):boolean} toShowFn Test function to determine whether
* to show the action for the given URL.
* @param {function(string):void} actionFn Action function to run when the
* action is clicked. Takes the current target URL as a parameter.
* @constructor
* @final
*/
goog.editor.plugins.LinkBubble.Action = function(spanId, linkId, message,
toShowFn, actionFn) {
this.spanId_ = spanId;
this.linkId_ = linkId;
this.message_ = message;
this.toShowFn_ = toShowFn;
this.actionFn_ = actionFn;
};
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
@author tildahl@google.com (Michael Tildahl)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.LinkBubble Tests
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.LinkBubbleTest');
</script>
</head>
<body>
<div id="field"><a href="http://www.google.com/">Google</a></div>
</body>
</html>
@@ -0,0 +1,396 @@
// 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.
goog.provide('goog.editor.plugins.LinkBubbleTest');
goog.setTestOnly('goog.editor.plugins.LinkBubbleTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Command');
goog.require('goog.editor.Link');
goog.require('goog.editor.plugins.LinkBubble');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.Event');
goog.require('goog.events.EventType');
goog.require('goog.string');
goog.require('goog.style');
goog.require('goog.testing.FunctionMock');
goog.require('goog.testing.PropertyReplacer');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var fieldDiv;
var FIELDMOCK;
var linkBubble;
var link;
var mockWindowOpen;
var stubs;
var testHelper;
function setUpPage() {
fieldDiv = goog.dom.$('field');
stubs = new goog.testing.PropertyReplacer();
testHelper = new goog.testing.editor.TestHelper(goog.dom.getElement('field'));
}
function setUp() {
testHelper.setUpEditableElement();
FIELDMOCK = new goog.testing.editor.FieldMock();
linkBubble = new goog.editor.plugins.LinkBubble();
linkBubble.fieldObject = FIELDMOCK;
link = fieldDiv.firstChild;
mockWindowOpen = new goog.testing.FunctionMock('open');
stubs.set(window, 'open', mockWindowOpen);
}
function tearDown() {
linkBubble.closeBubble();
testHelper.tearDownEditableElement();
stubs.reset();
}
function testLinkSelected() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
goog.dom.Range.createFromNodeContents(link).select();
linkBubble.handleSelectionChange();
assertBubble();
FIELDMOCK.$verify();
}
function testLinkClicked() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
FIELDMOCK.$verify();
}
function testImageLink() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
link.setAttribute('imageanchor', 1);
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
FIELDMOCK.$verify();
}
function closeBox() {
var closeBox = goog.dom.getElementsByTagNameAndClass(goog.dom.TagName.DIV,
'tr_bubble_closebox');
assertEquals('Should find only one close box', 1, closeBox.length);
assertNotNull('Found close box', closeBox[0]);
goog.testing.events.fireClickSequence(closeBox[0]);
}
function testCloseBox() {
testLinkClicked();
closeBox();
assertNoBubble();
FIELDMOCK.$verify();
}
function testChangeClicked() {
FIELDMOCK.execCommand(goog.editor.Command.MODAL_LINK_EDITOR,
new goog.editor.Link(link, false));
FIELDMOCK.$registerArgumentListVerifier('execCommand', function(arr1, arr2) {
return arr1.length == arr2.length &&
arr1.length == 2 &&
arr1[0] == goog.editor.Command.MODAL_LINK_EDITOR &&
arr2[0] == goog.editor.Command.MODAL_LINK_EDITOR &&
arr1[1] instanceof goog.editor.Link &&
arr2[1] instanceof goog.editor.Link;
});
FIELDMOCK.$times(1);
FIELDMOCK.$returns(true);
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
goog.testing.events.fireClickSequence(
goog.dom.$(goog.editor.plugins.LinkBubble.CHANGE_LINK_ID_));
assertNoBubble();
FIELDMOCK.$verify();
}
function testDeleteClicked() {
FIELDMOCK.dispatchBeforeChange();
FIELDMOCK.$times(1);
FIELDMOCK.dispatchChange();
FIELDMOCK.$times(1);
FIELDMOCK.focus();
FIELDMOCK.$times(1);
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
goog.testing.events.fireClickSequence(
goog.dom.$(goog.editor.plugins.LinkBubble.DELETE_LINK_ID_));
var element = goog.userAgent.GECKO ? document.body : fieldDiv;
assertNotEquals('Link removed', element.firstChild.nodeName,
goog.dom.TagName.A);
assertNoBubble();
FIELDMOCK.$verify();
}
function testActionClicked() {
var SPAN = 'actionSpanId';
var LINK = 'actionLinkId';
var toShowCount = 0;
var actionCount = 0;
var linkAction = new goog.editor.plugins.LinkBubble.Action(
SPAN, LINK, 'message',
function() {
toShowCount++;
return toShowCount == 1; // Show it the first time.
},
function() {
actionCount++;
});
linkBubble = new goog.editor.plugins.LinkBubble(linkAction);
linkBubble.fieldObject = FIELDMOCK;
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
// The first time the bubble is shown, show our custom action.
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
assertEquals('Should check showing the action', 1, toShowCount);
assertEquals('Action should not have fired yet', 0, actionCount);
assertTrue('Action should be visible 1st time', goog.style.isElementShown(
goog.dom.$(SPAN)));
goog.testing.events.fireClickSequence(goog.dom.$(LINK));
assertEquals('Should not check showing again yet', 1, toShowCount);
assertEquals('Action should be fired', 1, actionCount);
closeBox();
assertNoBubble();
// The action won't be shown the second time around.
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
assertEquals('Should check showing again', 2, toShowCount);
assertEquals('Action should not fire again', 1, actionCount);
assertFalse('Action should not be shown 2nd time', goog.style.isElementShown(
goog.dom.$(SPAN)));
FIELDMOCK.$verify();
}
function testLinkTextClicked() {
mockWindowOpen('http://www.google.com/', '_blank', '');
mockWindowOpen.$replay();
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
goog.testing.events.fireClickSequence(
goog.dom.$(goog.editor.plugins.LinkBubble.TEST_LINK_ID_));
assertBubble();
mockWindowOpen.$verify();
FIELDMOCK.$verify();
}
function testLinkTextClickedCustomUrlFn() {
mockWindowOpen('http://images.google.com/', '_blank', '');
mockWindowOpen.$replay();
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
linkBubble.setTestLinkUrlFn(function(url) {
return url.replace('www', 'images');
});
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
goog.testing.events.fireClickSequence(
goog.dom.$(goog.editor.plugins.LinkBubble.TEST_LINK_ID_));
assertBubble();
mockWindowOpen.$verify();
FIELDMOCK.$verify();
}
/**
* Urls with invalid schemes shouldn't be linkified.
* @bug 2585360
*/
function testDontLinkifyInvalidScheme() {
mockWindowOpen.$replay();
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
var badLink = document.createElement('a');
badLink.href = 'javascript:alert(1)';
badLink.innerHTML = 'bad link';
linkBubble.handleSelectionChange(createMouseEvent(badLink));
assertBubble();
// The link shouldn't exist at all
assertNull(goog.dom.$(goog.editor.plugins.LinkBubble.TEST_LINK_ID_));
assertBubble();
mockWindowOpen.$verify();
FIELDMOCK.$verify();
}
function testIsSafeSchemeToOpen() {
// Urls with no scheme at all are ok too since 'http://' will be prepended.
var good = [
'http://google.com', 'http://google.com/', 'https://google.com',
'null@google.com', 'http://www.google.com', 'http://site.com',
'google.com', 'google', 'http://google', 'HTTP://GOOGLE.COM',
'HtTp://www.google.com'
];
var bad = [
'javascript:google.com', 'httpp://google.com', 'data:foo',
'javascript:alert(\'hi\');', 'abc:def'
];
for (var i = 0; i < good.length; i++) {
assertTrue(good[i] + ' should have a safe scheme',
linkBubble.isSafeSchemeToOpen_(good[i]));
}
for (i = 0; i < bad.length; i++) {
assertFalse(bad[i] + ' should have an unsafe scheme',
linkBubble.isSafeSchemeToOpen_(bad[i]));
}
}
function testShouldOpenWithWhitelist() {
linkBubble.setSafeToOpenSchemes(['abc']);
assertTrue('Scheme should be safe',
linkBubble.shouldOpenUrl('abc://google.com'));
assertFalse('Scheme should be unsafe',
linkBubble.shouldOpenUrl('http://google.com'));
linkBubble.setBlockOpeningUnsafeSchemes(false);
assertTrue('Non-whitelisted should now be safe after disabling blocking',
linkBubble.shouldOpenUrl('http://google.com'));
}
/**
* @bug 763211
* @bug 2182147
*/
function testLongUrlTestLinkAnchorTextCorrect() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
var longUrl = 'http://www.reallylonglinkthatshouldbetruncated' +
'becauseitistoolong.com';
var truncatedLongUrl = goog.string.truncateMiddle(longUrl, 48);
var longLink = document.createElement('a');
longLink.href = longUrl;
longLink.innerHTML = 'Google';
fieldDiv.appendChild(longLink);
linkBubble.handleSelectionChange(createMouseEvent(longLink));
assertBubble();
var testLinkEl = goog.dom.$(goog.editor.plugins.LinkBubble.TEST_LINK_ID_);
assertEquals(
'The test link\'s anchor text should be the truncated URL.',
truncatedLongUrl,
testLinkEl.innerHTML);
fieldDiv.removeChild(longLink);
FIELDMOCK.$verify();
}
/**
* @bug 2416024
*/
function testOverridingCreateBubbleContentsDoesntNpeGetTargetUrl() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
stubs.set(linkBubble, 'createBubbleContents',
function(elem) {
// getTargetUrl would cause an NPE if urlUtil_ wasn't defined yet.
linkBubble.getTargetUrl();
});
assertNotThrows('Accessing this.urlUtil_ should not NPE',
goog.bind(linkBubble.handleSelectionChange,
linkBubble, createMouseEvent(link)));
FIELDMOCK.$verify();
}
/**
* @bug 15379294
*/
function testUpdateLinkCommandDoesNotTriggerAnException() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
// At this point, the bubble was not created yet using its createBubble
// public method.
assertNotThrows(
'Executing goog.editor.Command.UPDATE_LINK_BUBBLE should not trigger ' +
'an exception even if the bubble was not created yet using its ' +
'createBubble method.',
goog.bind(linkBubble.execCommandInternal, linkBubble,
goog.editor.Command.UPDATE_LINK_BUBBLE));
FIELDMOCK.$verify();
}
function assertBubble() {
assertTrue('Link bubble visible', linkBubble.isVisible());
assertNotNull('Link bubble created',
goog.dom.$(goog.editor.plugins.LinkBubble.LINK_DIV_ID_));
}
function assertNoBubble() {
assertFalse('Link bubble not visible', linkBubble.isVisible());
assertNull('Link bubble not created',
goog.dom.$(goog.editor.plugins.LinkBubble.LINK_DIV_ID_));
}
function createMouseEvent(target) {
var eventObj = new goog.events.Event(goog.events.EventType.MOUSEUP, target);
eventObj.button = goog.events.BrowserEvent.MouseButton.LEFT;
return new goog.events.BrowserEvent(eventObj, target);
}
@@ -0,0 +1,438 @@
// 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 A plugin for the LinkDialog.
*
* @author nicksantos@google.com (Nick Santos)
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.LinkDialogPlugin');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.editor.Command');
goog.require('goog.editor.plugins.AbstractDialogPlugin');
goog.require('goog.events.EventHandler');
goog.require('goog.functions');
goog.require('goog.ui.editor.AbstractDialog');
goog.require('goog.ui.editor.LinkDialog');
goog.require('goog.uri.utils');
/**
* A plugin that opens the link dialog.
* @constructor
* @extends {goog.editor.plugins.AbstractDialogPlugin}
*/
goog.editor.plugins.LinkDialogPlugin = function() {
goog.editor.plugins.LinkDialogPlugin.base(
this, 'constructor', goog.editor.Command.MODAL_LINK_EDITOR);
/**
* Event handler for this object.
* @type {goog.events.EventHandler<!goog.editor.plugins.LinkDialogPlugin>}
* @private
*/
this.eventHandler_ = new goog.events.EventHandler(this);
/**
* A list of whitelisted URL schemes which are safe to open.
* @type {Array<string>}
* @private
*/
this.safeToOpenSchemes_ = ['http', 'https', 'ftp'];
};
goog.inherits(goog.editor.plugins.LinkDialogPlugin,
goog.editor.plugins.AbstractDialogPlugin);
/**
* Link object that the dialog is editing.
* @type {goog.editor.Link}
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.currentLink_;
/**
* Optional warning to show about email addresses.
* @type {goog.html.SafeHtml}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.emailWarning_;
/**
* Whether to show a checkbox where the user can choose to have the link open in
* a new window.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.showOpenLinkInNewWindow_ = false;
/**
* Whether the "open link in new window" checkbox should be checked when the
* dialog is shown, and also whether it was checked last time the dialog was
* closed.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.isOpenLinkInNewWindowChecked_ =
false;
/**
* Weather to show a checkbox where the user can choose to add 'rel=nofollow'
* attribute added to the link.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.showRelNoFollow_ = false;
/**
* Whether to stop referrer leaks. Defaults to false.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.stopReferrerLeaks_ = false;
/**
* Whether to block opening links with a non-whitelisted URL scheme.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.blockOpeningUnsafeSchemes_ =
true;
/** @override */
goog.editor.plugins.LinkDialogPlugin.prototype.getTrogClassId =
goog.functions.constant('LinkDialogPlugin');
/**
* Tells the plugin whether to block URLs with schemes not in the whitelist.
* If blocking is enabled, this plugin will stop the 'Test Link' popup
* window from being created. Blocking doesn't affect link creation--if the
* user clicks the 'OK' button with an unsafe URL, the link will still be
* created as normal.
* @param {boolean} blockOpeningUnsafeSchemes Whether to block non-whitelisted
* schemes.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.setBlockOpeningUnsafeSchemes =
function(blockOpeningUnsafeSchemes) {
this.blockOpeningUnsafeSchemes_ = blockOpeningUnsafeSchemes;
};
/**
* Sets a whitelist of allowed URL schemes that are safe to open.
* Schemes should all be in lowercase. If the plugin is set to block opening
* unsafe schemes, user-entered URLs will be converted to lowercase and checked
* against this list. The whitelist has no effect if blocking is not enabled.
* @param {Array<string>} schemes String array of URL schemes to allow (http,
* https, etc.).
*/
goog.editor.plugins.LinkDialogPlugin.prototype.setSafeToOpenSchemes =
function(schemes) {
this.safeToOpenSchemes_ = schemes;
};
/**
* Tells the dialog to show a checkbox where the user can choose to have the
* link open in a new window.
* @param {boolean} startChecked Whether to check the checkbox the first
* time the dialog is shown. Subesquent times the checkbox will remember its
* previous state.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.showOpenLinkInNewWindow =
function(startChecked) {
this.showOpenLinkInNewWindow_ = true;
this.isOpenLinkInNewWindowChecked_ = startChecked;
};
/**
* Tells the dialog to show a checkbox where the user can choose to have
* 'rel=nofollow' attribute added to the link.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.showRelNoFollow = function() {
this.showRelNoFollow_ = true;
};
/**
* Returns whether the"open link in new window" checkbox was checked last time
* the dialog was closed.
* @return {boolean} Whether the"open link in new window" checkbox was checked
* last time the dialog was closed.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.
getOpenLinkInNewWindowCheckedState = function() {
return this.isOpenLinkInNewWindowChecked_;
};
/**
* Tells the plugin to stop leaking the page's url via the referrer header when
* the "test this link" link is clicked. When the user clicks on a link, the
* browser makes a request for the link url, passing the url of the current page
* in the request headers. If the user wants the current url to be kept secret
* (e.g. an unpublished document), the owner of the url that was clicked will
* see the secret url in the request headers, and it will no longer be a secret.
* Calling this method will not send a referrer header in the request, just as
* if the user had opened a blank window and typed the url in themselves.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.stopReferrerLeaks = function() {
this.stopReferrerLeaks_ = true;
};
/**
* Sets the warning message to show to users about including email addresses on
* public web pages.
* @param {!goog.html.SafeHtml} emailWarning Warning message to show users about
* including email addresses on the web.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.setEmailWarning = function(
emailWarning) {
this.emailWarning_ = emailWarning;
};
/**
* Handles execCommand by opening the dialog.
* @param {string} command The command to execute.
* @param {*=} opt_arg {@link A goog.editor.Link} object representing the link
* being edited.
* @return {*} Always returns true, indicating the dialog was shown.
* @protected
* @override
*/
goog.editor.plugins.LinkDialogPlugin.prototype.execCommandInternal = function(
command, opt_arg) {
this.currentLink_ = /** @type {goog.editor.Link} */(opt_arg);
return goog.editor.plugins.LinkDialogPlugin.base(
this, 'execCommandInternal', command, opt_arg);
};
/**
* Handles when the dialog closes.
* @param {goog.events.Event} e The AFTER_HIDE event object.
* @override
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.handleAfterHide = function(e) {
goog.editor.plugins.LinkDialogPlugin.base(this, 'handleAfterHide', e);
this.currentLink_ = null;
};
/**
* @return {goog.events.EventHandler<T>} The event handler.
* @protected
* @this T
* @template T
*/
goog.editor.plugins.LinkDialogPlugin.prototype.getEventHandler = function() {
return this.eventHandler_;
};
/**
* @return {goog.editor.Link} The link being edited.
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.getCurrentLink = function() {
return this.currentLink_;
};
/**
* Creates a new instance of the dialog and registers for the relevant events.
* @param {goog.dom.DomHelper} dialogDomHelper The dom helper to be used to
* create the dialog.
* @param {*=} opt_link The target link (should be a goog.editor.Link).
* @return {!goog.ui.editor.LinkDialog} The dialog.
* @override
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.createDialog = function(
dialogDomHelper, opt_link) {
var dialog = new goog.ui.editor.LinkDialog(dialogDomHelper,
/** @type {goog.editor.Link} */ (opt_link));
if (this.emailWarning_) {
dialog.setEmailWarning(this.emailWarning_);
}
if (this.showOpenLinkInNewWindow_) {
dialog.showOpenLinkInNewWindow(this.isOpenLinkInNewWindowChecked_);
}
if (this.showRelNoFollow_) {
dialog.showRelNoFollow();
}
dialog.setStopReferrerLeaks(this.stopReferrerLeaks_);
this.eventHandler_.
listen(dialog, goog.ui.editor.AbstractDialog.EventType.OK,
this.handleOk).
listen(dialog, goog.ui.editor.AbstractDialog.EventType.CANCEL,
this.handleCancel_).
listen(dialog, goog.ui.editor.LinkDialog.EventType.BEFORE_TEST_LINK,
this.handleBeforeTestLink);
return dialog;
};
/** @override */
goog.editor.plugins.LinkDialogPlugin.prototype.disposeInternal = function() {
goog.editor.plugins.LinkDialogPlugin.base(this, 'disposeInternal');
this.eventHandler_.dispose();
};
/**
* Handles the OK event from the dialog by updating the link in the field.
* @param {goog.ui.editor.LinkDialog.OkEvent} e OK event object.
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.handleOk = function(e) {
// We're not restoring the original selection, so clear it out.
this.disposeOriginalSelection();
this.currentLink_.setTextAndUrl(e.linkText, e.linkUrl);
if (this.showOpenLinkInNewWindow_) {
// Save checkbox state for next time.
this.isOpenLinkInNewWindowChecked_ = e.openInNewWindow;
}
var anchor = this.currentLink_.getAnchor();
this.touchUpAnchorOnOk_(anchor, e);
var extraAnchors = this.currentLink_.getExtraAnchors();
for (var i = 0; i < extraAnchors.length; ++i) {
extraAnchors[i].href = anchor.href;
this.touchUpAnchorOnOk_(extraAnchors[i], e);
}
// Place cursor to the right of the modified link.
this.currentLink_.placeCursorRightOf();
this.getFieldObject().focus();
this.getFieldObject().dispatchSelectionChangeEvent();
this.getFieldObject().dispatchChange();
this.eventHandler_.removeAll();
};
/**
* Apply the necessary properties to a link upon Ok being clicked in the dialog.
* @param {HTMLAnchorElement} anchor The anchor to set properties on.
* @param {goog.events.Event} e Event object.
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.touchUpAnchorOnOk_ =
function(anchor, e) {
if (this.showOpenLinkInNewWindow_) {
if (e.openInNewWindow) {
anchor.target = '_blank';
} else {
if (anchor.target == '_blank') {
anchor.target = '';
}
// If user didn't indicate to open in a new window but the link already
// had a target other than '_blank', let's leave what they had before.
}
}
if (this.showRelNoFollow_) {
var alreadyPresent = goog.ui.editor.LinkDialog.hasNoFollow(anchor.rel);
if (alreadyPresent && !e.noFollow) {
anchor.rel = goog.ui.editor.LinkDialog.removeNoFollow(anchor.rel);
} else if (!alreadyPresent && e.noFollow) {
anchor.rel = anchor.rel ? anchor.rel + ' nofollow' : 'nofollow';
}
}
};
/**
* Handles the CANCEL event from the dialog by clearing the anchor if needed.
* @param {goog.events.Event} e Event object.
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.handleCancel_ = function(e) {
if (this.currentLink_.isNew()) {
goog.dom.flattenElement(this.currentLink_.getAnchor());
var extraAnchors = this.currentLink_.getExtraAnchors();
for (var i = 0; i < extraAnchors.length; ++i) {
goog.dom.flattenElement(extraAnchors[i]);
}
// Make sure listeners know the anchor was flattened out.
this.getFieldObject().dispatchChange();
}
this.eventHandler_.removeAll();
};
/**
* Handles the BeforeTestLink event fired when the 'test' link is clicked.
* @param {goog.ui.editor.LinkDialog.BeforeTestLinkEvent} e BeforeTestLink event
* object.
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.handleBeforeTestLink =
function(e) {
if (!this.shouldOpenUrl(e.url)) {
/** @desc Message when the user tries to test (preview) a link, but the
* link cannot be tested. */
var MSG_UNSAFE_LINK = goog.getMsg('This link cannot be tested.');
alert(MSG_UNSAFE_LINK);
e.preventDefault();
}
};
/**
* Checks whether the plugin should open the given url in a new window.
* @param {string} url The url to check.
* @return {boolean} If the plugin should open the given url in a new window.
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.shouldOpenUrl = function(url) {
return !this.blockOpeningUnsafeSchemes_ || this.isSafeSchemeToOpen_(url);
};
/**
* Determines whether or not a url has a scheme which is safe to open.
* Schemes like javascript are unsafe due to the possibility of XSS.
* @param {string} url A url.
* @return {boolean} Whether the url has a safe scheme.
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.isSafeSchemeToOpen_ =
function(url) {
var scheme = goog.uri.utils.getScheme(url) || 'http';
return goog.array.contains(this.safeToOpenSchemes_, scheme.toLowerCase());
};
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<!--
Copyright 2010 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<!--
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.LinkDialogPlugin Tests
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.ui.editor.plugins.LinkDialogTest');
</script>
<link rel="stylesheet" href="../../css/dialog.css" />
<link rel="stylesheet" href="../../css/editor/dialog.css" />
<link rel="stylesheet" href="../../css/editor/linkdialog.css" />
</head>
<body>
<div id="test">
</div>
</body>
</html>
@@ -0,0 +1,749 @@
// Copyright 2010 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.
goog.provide('goog.ui.editor.plugins.LinkDialogTest');
goog.setTestOnly('goog.ui.editor.plugins.LinkDialogTest');
goog.require('goog.dom');
goog.require('goog.dom.DomHelper');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Command');
goog.require('goog.editor.Field');
goog.require('goog.editor.Link');
goog.require('goog.editor.plugins.LinkDialogPlugin');
goog.require('goog.string');
goog.require('goog.string.Unicode');
goog.require('goog.testing.MockControl');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.editor.dom');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.testing.mockmatchers');
goog.require('goog.ui.editor.AbstractDialog');
goog.require('goog.ui.editor.LinkDialog');
goog.require('goog.userAgent');
var plugin;
var anchorElem;
var extraAnchors;
var isNew;
var testDiv;
var mockCtrl;
var mockField;
var mockLink;
var mockAlert;
var OLD_LINK_TEXT = 'old text';
var OLD_LINK_URL = 'http://old.url/';
var NEW_LINK_TEXT = 'My Link Text';
var NEW_LINK_URL = 'http://my.link/url/';
var fieldElem;
var fieldObj;
var linkObj;
function setUp() {
testDiv = goog.dom.getDocument().getElementById('test');
testDiv.innerHTML = 'Some preceeding text';
anchorElem = goog.dom.createElement(goog.dom.TagName.A);
anchorElem.href = 'http://www.google.com/';
anchorElem.innerHTML = 'anchor text';
goog.dom.appendChild(testDiv, anchorElem);
extraAnchors = [];
mockCtrl = new goog.testing.MockControl();
mockField = new goog.testing.editor.FieldMock();
mockCtrl.addMock(mockField);
mockLink = mockCtrl.createLooseMock(goog.editor.Link);
mockAlert = mockCtrl.createGlobalFunctionMock('alert');
isNew = false;
mockLink.isNew().$anyTimes().$does(function() {
return isNew;
});
mockLink.
setTextAndUrl(goog.testing.mockmatchers.isString,
goog.testing.mockmatchers.isString).
$anyTimes().
$does(function(text, url) {
anchorElem.innerHTML = text;
anchorElem.href = url;
});
mockLink.getAnchor().$anyTimes().$returns(anchorElem);
mockLink.getExtraAnchors().$anyTimes().$returns(extraAnchors);
}
function tearDown() {
plugin.dispose();
tearDownRealEditableField();
testDiv.innerHTML = '';
mockCtrl.$tearDown();
}
function setUpAnchor(text, href, opt_isNew, opt_target, opt_rel) {
setUpGivenAnchor(anchorElem, text, href, opt_isNew, opt_target, opt_rel);
}
function setUpGivenAnchor(
anchor, text, href, opt_isNew, opt_target, opt_rel) {
anchor.innerHTML = text;
anchor.href = href;
isNew = !!opt_isNew;
if (opt_target) {
anchor.target = opt_target;
}
if (opt_rel) {
anchor.rel = opt_rel;
}
}
/**
* Tests that the plugin's dialog is properly created.
*/
function testCreateDialog() {
// Note: this tests simply creating the dialog because that's the only
// functionality added to this class. Opening or closing effects (editing
// the actual link) is tested in linkdialog_test.html, but should be moved
// here if that functionality gets refactored from the dialog to the plugin.
mockCtrl.$replayAll();
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
assertTrue('Dialog should be of type goog.ui.editor.LinkDialog',
dialog instanceof goog.ui.editor.LinkDialog);
mockCtrl.$verifyAll();
}
/**
* Tests that when the OK event fires the link is properly updated.
*/
function testOk() {
mockLink.placeCursorRightOf();
mockField.dispatchSelectionChangeEvent();
mockField.dispatchChange();
mockField.focus();
mockCtrl.$replayAll();
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL);
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + clicking OK without actually opening the dialog.
plugin.currentLink_ = mockLink;
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(NEW_LINK_TEXT,
NEW_LINK_URL));
assertEquals('Display text incorrect',
NEW_LINK_TEXT,
anchorElem.innerHTML);
assertEquals('Anchor element href incorrect',
NEW_LINK_URL,
anchorElem.href);
mockCtrl.$verifyAll();
}
/**
* Tests that when the Cancel event fires the link is unchanged.
*/
function testCancel() {
mockCtrl.$replayAll();
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL);
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + cancel without actually opening the dialog.
plugin.currentLink_ = mockLink;
dialog.dispatchEvent(goog.ui.editor.AbstractDialog.EventType.CANCEL);
assertEquals('Display text should not be changed',
OLD_LINK_TEXT,
anchorElem.innerHTML);
assertEquals('Anchor element href should not be changed',
OLD_LINK_URL,
anchorElem.href);
mockCtrl.$verifyAll();
}
/**
* Tests that when the Cancel event fires for a new link it gets removed.
*/
function testCancelNew() {
mockField.dispatchChange(); // Should be fired because link was removed.
mockCtrl.$replayAll();
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL, true);
var prevSib = anchorElem.previousSibling;
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + cancel without actually opening the dialog.
plugin.currentLink_ = mockLink;
dialog.dispatchEvent(goog.ui.editor.AbstractDialog.EventType.CANCEL);
assertNotEquals('Anchor element should be removed from document body',
testDiv,
anchorElem.parentNode);
var newElem = prevSib.nextSibling;
assertEquals('Link should be replaced by text node',
goog.dom.NodeType.TEXT,
newElem.nodeType);
assertEquals('Original text should be left behind',
OLD_LINK_TEXT,
newElem.nodeValue);
mockCtrl.$verifyAll();
}
/**
* Tests that when the Cancel event fires for a new link it gets removed.
*/
function testCancelNewMultiple() {
mockField.dispatchChange(); // Should be fired because link was removed.
mockCtrl.$replayAll();
var anchorElem1 = anchorElem;
var parent1 = goog.dom.createDom(goog.dom.TagName.DIV, null,
anchorElem1);
goog.dom.appendChild(testDiv, parent1);
setUpGivenAnchor(anchorElem1, OLD_LINK_TEXT + '1', OLD_LINK_URL + '1',
true);
anchorElem2 = goog.dom.createDom(goog.dom.TagName.A);
var parent2 = goog.dom.createDom(goog.dom.TagName.DIV, null,
anchorElem2);
goog.dom.appendChild(testDiv, parent2);
setUpGivenAnchor(anchorElem2, OLD_LINK_TEXT + '2', OLD_LINK_URL + '2',
true);
extraAnchors.push(anchorElem2);
anchorElem3 = goog.dom.createDom(goog.dom.TagName.A);
var parent3 = goog.dom.createDom(goog.dom.TagName.DIV, null,
anchorElem3);
goog.dom.appendChild(testDiv, parent3);
setUpGivenAnchor(anchorElem3, OLD_LINK_TEXT + '3', OLD_LINK_URL + '3',
true);
extraAnchors.push(anchorElem3);
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + cancel without actually opening the dialog.
plugin.currentLink_ = mockLink;
dialog.dispatchEvent(goog.ui.editor.AbstractDialog.EventType.CANCEL);
assertNotEquals('Anchor 1 element should be removed from document body',
parent1,
anchorElem1.parentNode);
assertNotEquals('Anchor 2 element should be removed from document body',
parent2,
anchorElem2.parentNode);
assertNotEquals('Anchor 3 element should be removed from document body',
parent3,
anchorElem3.parentNode);
assertEquals('Link 1 should be replaced by text node',
goog.dom.NodeType.TEXT,
parent1.firstChild.nodeType);
assertEquals('Link 2 should be replaced by text node',
goog.dom.NodeType.TEXT,
parent2.firstChild.nodeType);
assertEquals('Link 3 should be replaced by text node',
goog.dom.NodeType.TEXT,
parent3.firstChild.nodeType);
assertEquals('Original text 1 should be left behind',
OLD_LINK_TEXT + '1',
parent1.firstChild.nodeValue);
assertEquals('Original text 2 should be left behind',
OLD_LINK_TEXT + '2',
parent2.firstChild.nodeValue);
assertEquals('Original text 3 should be left behind',
OLD_LINK_TEXT + '3',
parent3.firstChild.nodeValue);
mockCtrl.$verifyAll();
}
/**
* Tests that when the Cancel event fires for a new link it gets removed.
*/
function testOkNewMultiple() {
mockLink.placeCursorRightOf();
mockField.dispatchSelectionChangeEvent();
mockField.dispatchChange();
mockField.focus();
mockCtrl.$replayAll();
var anchorElem1 = anchorElem;
setUpGivenAnchor(anchorElem1, OLD_LINK_TEXT + '1', OLD_LINK_URL + '1',
true);
anchorElem2 = goog.dom.createElement(goog.dom.TagName.A);
goog.dom.appendChild(testDiv, anchorElem2);
setUpGivenAnchor(anchorElem2, OLD_LINK_TEXT + '2', OLD_LINK_URL + '2',
true);
extraAnchors.push(anchorElem2);
anchorElem3 = goog.dom.createElement(goog.dom.TagName.A);
goog.dom.appendChild(testDiv, anchorElem3);
setUpGivenAnchor(anchorElem3, OLD_LINK_TEXT + '3', OLD_LINK_URL + '3',
true);
extraAnchors.push(anchorElem3);
var prevSib = anchorElem1.previousSibling;
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + clicking OK without actually opening the dialog.
plugin.currentLink_ = mockLink;
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(NEW_LINK_TEXT,
NEW_LINK_URL));
assertEquals('Display text 1 must update', NEW_LINK_TEXT,
anchorElem1.innerHTML);
assertEquals('Display text 2 must not update', OLD_LINK_TEXT + '2',
anchorElem2.innerHTML);
assertEquals('Display text 3 must not update', OLD_LINK_TEXT + '3',
anchorElem3.innerHTML);
assertEquals('Anchor element 1 href must update', NEW_LINK_URL,
anchorElem1.href);
assertEquals('Anchor element 2 href must update', NEW_LINK_URL,
anchorElem2.href);
assertEquals('Anchor element 3 href must update', NEW_LINK_URL,
anchorElem3.href);
mockCtrl.$verifyAll();
}
/**
* Tests the anchor's target is correctly modified with the "open in new
* window" feature on.
*/
function testOkOpenInNewWindow() {
mockLink.placeCursorRightOf().$anyTimes();
mockField.dispatchSelectionChangeEvent().$anyTimes();
mockField.dispatchChange().$anyTimes();
mockField.focus().$anyTimes();
mockCtrl.$replayAll();
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
plugin.showOpenLinkInNewWindow(false);
plugin.currentLink_ = mockLink;
// Edit a link that doesn't open in a new window and leave it as such.
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(
NEW_LINK_TEXT, NEW_LINK_URL, false, false));
assertEquals(
'Target should not be set for link that doesn\'t open in new window',
'', anchorElem.target);
assertFalse('Checked state should stay false',
plugin.getOpenLinkInNewWindowCheckedState());
// Edit a link that doesn't open in a new window and toggle it on.
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL);
dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(
NEW_LINK_TEXT, NEW_LINK_URL, true));
assertEquals(
'Target should be set to _blank for link that opens in new window',
'_blank', anchorElem.target);
assertTrue('Checked state should be true after toggling a link on',
plugin.getOpenLinkInNewWindowCheckedState());
// Edit a link that doesn't open in a named window and don't touch it.
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL, false, 'named');
dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(
NEW_LINK_TEXT, NEW_LINK_URL, false));
assertEquals(
'Target should keep its original value',
'named', anchorElem.target);
assertFalse('Checked state should be false again',
plugin.getOpenLinkInNewWindowCheckedState());
// Edit a link that opens in a new window and toggle it off.
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL, false, '_blank');
dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(
NEW_LINK_TEXT, NEW_LINK_URL, false));
assertEquals(
'Target should not be set for link that doesn\'t open in new window',
'', anchorElem.target);
mockCtrl.$verifyAll();
}
function testOkNoFollowEnabled() {
verifyRelNoFollow(true, null, 'nofollow');
}
function testOkNoFollowInUppercase() {
verifyRelNoFollow(true, 'NOFOLLOW', 'NOFOLLOW');
}
function testOkNoFollowEnabledHasMoreRelValues() {
verifyRelNoFollow(true, 'author', 'author nofollow');
}
function testOkNoFollowDisabled() {
verifyRelNoFollow(false, null, '');
}
function testOkNoFollowDisabledHasMoreRelValues() {
verifyRelNoFollow(false, 'author', 'author');
}
function testOkNoFollowDisabledHasMoreRelValues() {
verifyRelNoFollow(false, 'author nofollow', 'author ');
}
function testOkNoFollowInUppercaseWithMoreValues() {
verifyRelNoFollow(true, 'NOFOLLOW author', 'NOFOLLOW author');
}
function verifyRelNoFollow(noFollow, originalRel, expectedRel) {
mockLink.placeCursorRightOf();
mockField.dispatchSelectionChangeEvent();
mockField.dispatchChange();
mockField.focus();
mockCtrl.$replayAll();
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
plugin.showRelNoFollow();
plugin.currentLink_ = mockLink;
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL, true, null, originalRel);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(
NEW_LINK_TEXT, NEW_LINK_URL, false, noFollow));
assertEquals(expectedRel, anchorElem.rel);
mockCtrl.$verifyAll();
}
/**
* Tests that the selection is cleared when the dialog opens and is
* correctly restored after cancel is clicked.
*/
function testRestoreSelectionOnOk() {
setUpAnchor('12345', '/');
setUpRealEditableField();
var elem = fieldObj.getElement();
var helper = new goog.testing.editor.TestHelper(elem);
helper.select('12345', 1, '12345', 4); // Selects '234'.
assertEquals('Incorrect text selected before dialog is opened',
'234',
fieldObj.getRange().getText());
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
if (!goog.userAgent.IE && !goog.userAgent.OPERA) {
// IE returns some bogus range when field doesn't have selection.
// You can't remove the selection from a whitebox field in Opera.
assertNull('There should be no selection while dialog is open',
fieldObj.getRange());
}
goog.testing.events.fireClickSequence(
plugin.dialog_.getOkButtonElement());
assertEquals('No text should be selected after clicking ok',
'',
fieldObj.getRange().getText());
// Test that the caret is placed at the end of the link text.
goog.testing.editor.dom.assertRangeBetweenText(
// If the browser gets stuck in links, an nbsp was added after the link
// to avoid that, otherwise we just look for the 5.
goog.editor.BrowserFeature.GETS_STUCK_IN_LINKS ?
goog.string.Unicode.NBSP : '5',
'',
fieldObj.getRange());
// NOTE(user): The functionality to avoid getting stuck in links is
// tested in editablelink_test.html::testPlaceCursorRightOf().
}
/**
* Tests that the selection is cleared when the dialog opens and is
* correctly restored after cancel is clicked.
* @param {boolean=} opt_isNew Whether to test behavior when creating a new
* link (cancelling will flatten it).
*/
function testRestoreSelectionOnCancel(opt_isNew) {
setUpAnchor('12345', '/', opt_isNew);
setUpRealEditableField();
var elem = fieldObj.getElement();
var helper = new goog.testing.editor.TestHelper(elem);
helper.select('12345', 1, '12345', 4); // Selects '234'.
assertEquals('Incorrect text selected before dialog is opened',
'234',
fieldObj.getRange().getText());
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
if (!goog.userAgent.IE && !goog.userAgent.OPERA) {
// IE returns some bogus range when field doesn't have selection.
// You can't remove the selection from a whitebox field in Opera.
assertNull('There should be no selection while dialog is open',
fieldObj.getRange());
}
goog.testing.events.fireClickSequence(
plugin.dialog_.getCancelButtonElement());
assertEquals('Incorrect text selected after clicking cancel',
'234',
fieldObj.getRange().getText());
}
/**
* Tests that the selection is cleared when the dialog opens and is
* correctly restored after cancel is clicked and the new link is removed.
*/
function testRestoreSelectionOnCancelNew() {
testRestoreSelectionOnCancel(true);
}
/**
* Tests that the BeforeTestLink event is suppressed for invalid url schemes.
*/
function testTestLinkDisabledForInvalidScheme() {
mockAlert(goog.testing.mockmatchers.isString);
mockCtrl.$replayAll();
var invalidUrl = 'javascript:document.write(\'hello\');';
plugin = new goog.editor.plugins.LinkDialogPlugin();
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + clicking test without actually opening the dialog.
var dispatched = dialog.dispatchEvent(
new goog.ui.editor.LinkDialog.BeforeTestLinkEvent(invalidUrl));
assertFalse(dispatched);
mockCtrl.$verifyAll();
}
function testIsSafeSchemeToOpen() {
plugin = new goog.editor.plugins.LinkDialogPlugin();
// Urls with no scheme at all are ok too since 'http://' will be prepended.
var good = [
'http://google.com', 'http://google.com/', 'https://google.com',
'null@google.com', 'http://www.google.com', 'http://site.com',
'google.com', 'google', 'http://google', 'HTTP://GOOGLE.COM',
'HtTp://www.google.com'
];
var bad = [
'javascript:google.com', 'httpp://google.com', 'data:foo',
'javascript:alert(\'hi\');', 'abc:def'
];
for (var i = 0; i < good.length; i++) {
assertTrue(good[i] + ' should have a safe scheme',
plugin.isSafeSchemeToOpen_(good[i]));
}
for (i = 0; i < bad.length; i++) {
assertFalse(bad[i] + ' should have an unsafe scheme',
plugin.isSafeSchemeToOpen_(bad[i]));
}
}
function testShouldOpenWithWhitelist() {
plugin.setSafeToOpenSchemes(['abc']);
assertTrue('Scheme should be safe',
plugin.shouldOpenUrl('abc://google.com'));
assertFalse('Scheme should be unsafe',
plugin.shouldOpenUrl('http://google.com'));
plugin.setBlockOpeningUnsafeSchemes(false);
assertTrue('Non-whitelisted should now be safe after disabling blocking',
plugin.shouldOpenUrl('http://google.com'));
}
/**
* Regression test for http://b/issue?id=1607766 . Without the fix, this
* should give an Invalid Argument error in IE, because the editable field
* caches a selection util that has a reference to the node of the link text
* before it is edited (which gets replaced by a new node for the new text
* after editing).
*/
function testBug1607766() {
setUpAnchor('abc', 'def');
setUpRealEditableField();
var elem = fieldObj.getElement();
var helper = new goog.testing.editor.TestHelper(elem);
helper.select('abc', 1, 'abc', 2); // Selects 'b'.
// Dispatching a selection event causes the field to cache a selection
// util, which is the root of the bug.
plugin.fieldObject.dispatchSelectionChangeEvent();
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.TEXT_TO_DISPLAY).value = 'Abc';
goog.testing.events.fireClickSequence(plugin.dialog_.getOkButtonElement());
// In IE the unit test somehow doesn't cause a browser focus event, so we
// need to manually invoke this, which is where the bug happens.
plugin.fieldObject.dispatchFocus_();
}
/**
* Regression test for http://b/issue?id=2215546 .
*/
function testBug2215546() {
setUpRealEditableField();
var elem = fieldObj.getElement();
fieldObj.setHtml(false, '<div><a href="/"></a></div>');
anchorElem = elem.firstChild.firstChild;
linkObj = new goog.editor.Link(anchorElem, true);
var helper = new goog.testing.editor.TestHelper(elem);
// Select "</a>" in a way, simulating what IE does if you hit enter twice,
// arrow up into the blank line and open the link dialog.
helper.select(anchorElem, 0, elem.firstChild, 1);
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.TEXT_TO_DISPLAY).value = 'foo';
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.ON_WEB_INPUT).value = 'foo';
var okButton = plugin.dialog_.getOkButtonElement();
okButton.disabled = false;
goog.testing.events.fireClickSequence(okButton);
assertEquals('Link text should have been inserted',
'foo', anchorElem.innerHTML);
}
/**
* Test that link insertion doesn't scroll the field to the top
* after clicking Cancel or OK.
*/
function testBug7279077ScrollOnFocus() {
if (goog.userAgent.IE) {
return; // TODO(user): take this out once b/7279077 fixed for IE too.
}
setUpAnchor('12345', '/');
setUpRealEditableField();
// Make the field scrollable and kinda small.
var elem = fieldObj.getElement();
elem.style.overflow = 'auto';
elem.style.height = '40px';
elem.style.width = '200px';
elem.style.contenteditable = 'true';
// Add a bunch of text before the anchor tag.
var longTextElem = document.createElement('span');
longTextElem.innerHTML = goog.string.repeat('All work and no play.<p>', 20);
elem.insertBefore(longTextElem, elem.firstChild);
var helper = new goog.testing.editor.TestHelper(elem);
helper.select('12345', 1, '12345', 4); // Selects '234'.
// Scroll down.
elem.scrollTop = 60;
// Bring up the link insertion dialog, then cancel.
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.TEXT_TO_DISPLAY).value = 'foo';
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.ON_WEB_INPUT).value = 'foo';
var cancelButton = plugin.dialog_.getCancelButtonElement();
goog.testing.events.fireClickSequence(cancelButton);
assertEquals('Field should not have scrolled after cancel',
60, elem.scrollTop);
// Now let's try it with clicking the OK button.
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.TEXT_TO_DISPLAY).value = 'foo';
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.ON_WEB_INPUT).value = 'foo';
var okButton = plugin.dialog_.getOkButtonElement();
goog.testing.events.fireClickSequence(okButton);
assertEquals('Field should not have scrolled after OK',
60, elem.scrollTop);
}
/**
* Setup a real editable field (instead of a mock) and register the plugin to
* it.
*/
function setUpRealEditableField() {
fieldElem = document.createElement('div');
fieldElem.id = 'myField';
document.body.appendChild(fieldElem);
fieldElem.appendChild(anchorElem);
fieldObj = new goog.editor.Field('myField', document);
fieldObj.makeEditable();
linkObj = new goog.editor.Link(fieldObj.getElement().firstChild, isNew);
// Register the plugin to that field.
plugin = new goog.editor.plugins.LinkDialogPlugin();
fieldObj.registerPlugin(plugin);
}
/**
* Tear down the real editable field.
*/
function tearDownRealEditableField() {
if (fieldObj) {
fieldObj.makeUneditable();
fieldObj.dispose();
fieldObj = null;
}
goog.dom.removeNode(fieldElem);
}
@@ -0,0 +1,62 @@
// Copyright 2011 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 Adds a keyboard shortcut for the link command.
*
*/
goog.provide('goog.editor.plugins.LinkShortcutPlugin');
goog.require('goog.editor.Command');
goog.require('goog.editor.Plugin');
/**
* Plugin to add a keyboard shortcut for the link command
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.LinkShortcutPlugin = function() {
goog.editor.plugins.LinkShortcutPlugin.base(this, 'constructor');
};
goog.inherits(goog.editor.plugins.LinkShortcutPlugin, goog.editor.Plugin);
/** @override */
goog.editor.plugins.LinkShortcutPlugin.prototype.getTrogClassId = function() {
return 'LinkShortcutPlugin';
};
/**
* @override
*/
goog.editor.plugins.LinkShortcutPlugin.prototype.handleKeyboardShortcut =
function(e, key, isModifierPressed) {
var command;
if (isModifierPressed && key == 'k' && !e.shiftKey) {
var link = /** @type {goog.editor.Link?} */ (
this.getFieldObject().execCommand(goog.editor.Command.LINK));
if (link) {
link.finishLinkCreation(this.getFieldObject());
}
return true;
}
return false;
};
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<!--
Copyright 2011 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.LinkShortcutPlugin Tests
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.LinkShortcutPluginTest');
</script>
</head>
<body>
<div id="cleanup">
<div id="field">
http://www.google.com/
</div>
</div>
</body>
</html>
@@ -0,0 +1,65 @@
// Copyright 2011 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.
goog.provide('goog.editor.plugins.LinkShortcutPluginTest');
goog.setTestOnly('goog.editor.plugins.LinkShortcutPluginTest');
goog.require('goog.dom');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.BasicTextFormatter');
goog.require('goog.editor.plugins.LinkBubble');
goog.require('goog.editor.plugins.LinkShortcutPlugin');
goog.require('goog.events.KeyCodes');
goog.require('goog.testing.PropertyReplacer');
goog.require('goog.testing.dom');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
var propertyReplacer;
function setUp() {
propertyReplacer = new goog.testing.PropertyReplacer();
}
function tearDown() {
propertyReplacer.reset();
var field = document.getElementById('cleanup');
goog.dom.removeChildren(field);
field.innerHTML = '<div id="field">http://www.google.com/</div>';
}
function testShortcutCreatesALink() {
propertyReplacer.set(window, 'prompt', function() {
return 'http://www.google.com/'; });
var linkBubble = new goog.editor.plugins.LinkBubble();
var formatter = new goog.editor.plugins.BasicTextFormatter();
var plugin = new goog.editor.plugins.LinkShortcutPlugin();
var fieldEl = document.getElementById('field');
var field = new goog.editor.Field('field');
field.registerPlugin(formatter);
field.registerPlugin(linkBubble);
field.registerPlugin(plugin);
field.makeEditable();
field.focusAndPlaceCursorAtStart();
var textNode = goog.testing.dom.findTextNode('http://www.google.com/',
fieldEl);
goog.testing.events.fireKeySequence(
field.getElement(), goog.events.KeyCodes.K, { ctrlKey: true });
var href = field.getElement().getElementsByTagName('A')[0];
assertEquals('http://www.google.com/', href.href);
var bubbleLink =
document.getElementById(goog.editor.plugins.LinkBubble.TEST_LINK_ID_);
assertEquals('http://www.google.com/', bubbleLink.innerHTML);
}
@@ -0,0 +1,68 @@
// 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 Editor plugin to handle tab keys in lists to indent and
* outdent.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.ListTabHandler');
goog.require('goog.dom');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Command');
goog.require('goog.editor.plugins.AbstractTabHandler');
goog.require('goog.iter');
/**
* Plugin to handle tab keys in lists to indent and outdent.
* @constructor
* @extends {goog.editor.plugins.AbstractTabHandler}
* @final
*/
goog.editor.plugins.ListTabHandler = function() {
goog.editor.plugins.AbstractTabHandler.call(this);
};
goog.inherits(goog.editor.plugins.ListTabHandler,
goog.editor.plugins.AbstractTabHandler);
/** @override */
goog.editor.plugins.ListTabHandler.prototype.getTrogClassId = function() {
return 'ListTabHandler';
};
/** @override */
goog.editor.plugins.ListTabHandler.prototype.handleTabKey = function(e) {
var range = this.getFieldObject().getRange();
if (goog.dom.getAncestorByTagNameAndClass(range.getContainerElement(),
goog.dom.TagName.LI) ||
goog.iter.some(range, function(node) {
return node.tagName == goog.dom.TagName.LI;
})) {
this.getFieldObject().execCommand(e.shiftKey ?
goog.editor.Command.OUTDENT :
goog.editor.Command.INDENT);
e.preventDefault();
return true;
}
return false;
};
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<!--
@author robbyw@google.com (Robby Walker)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.ListTabHandler
</title>
<script src="../../base.js">
</script>
<script src="../../deps.js">
</script>
<script>
goog.require('goog.editor.plugins.ListTabHandlerTest');
</script>
</head>
<body>
<div id="field">
</div>
</body>
</html>
@@ -0,0 +1,167 @@
// 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.
goog.provide('goog.editor.plugins.ListTabHandlerTest');
goog.setTestOnly('goog.editor.plugins.ListTabHandlerTest');
goog.require('goog.dom');
goog.require('goog.editor.Command');
goog.require('goog.editor.plugins.ListTabHandler');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.KeyCodes');
goog.require('goog.functions');
goog.require('goog.testing.StrictMock');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
var field;
var editableField;
var tabHandler;
var testHelper;
function setUpPage() {
field = goog.dom.getElement('field');
}
function setUp() {
editableField = new goog.testing.editor.FieldMock();
// Modal mode behavior tested as part of AbstractTabHandler tests.
editableField.inModalMode = goog.functions.FALSE;
tabHandler = new goog.editor.plugins.ListTabHandler();
tabHandler.registerFieldObject(editableField);
testHelper = new goog.testing.editor.TestHelper(field);
testHelper.setUpEditableElement();
}
function tearDown() {
editableField = null;
testHelper.tearDownEditableElement();
tabHandler.dispose();
}
function testListIndentInLi() {
field.innerHTML = '<ul><li>Text</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(testText, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.execCommand(goog.editor.Command.INDENT);
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event must be handled',
tabHandler.handleKeyboardShortcut(event, '', false));
editableField.$verify();
event.$verify();
}
function testListIndentContainLi() {
field.innerHTML = '<ul><li>Text</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(field.firstChild, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.execCommand(goog.editor.Command.INDENT);
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event must be handled',
tabHandler.handleKeyboardShortcut(event, '', false));
editableField.$verify();
event.$verify();
}
function testListOutdentInLi() {
field.innerHTML = '<ul><li>Text</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(testText, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = true;
editableField.execCommand(goog.editor.Command.OUTDENT);
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event must be handled',
tabHandler.handleKeyboardShortcut(event, '', false));
editableField.$verify();
event.$verify();
}
function testListOutdentContainLi() {
field.innerHTML = '<ul><li>Text</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(field.firstChild, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = true;
editableField.execCommand(goog.editor.Command.OUTDENT);
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event must be handled',
tabHandler.handleKeyboardShortcut(event, '', false));
editableField.$verify();
event.$verify();
}
function testNoOp() {
field.innerHTML = 'Text';
var testText = field.firstChild;
testHelper.select(testText, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = true;
editableField.$replay();
event.$replay();
assertFalse('Event must not be handled',
tabHandler.handleKeyboardShortcut(event, '', false));
editableField.$verify();
event.$verify();
}
@@ -0,0 +1,192 @@
// 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 A plugin that fills the field with lorem ipsum text when it's
* empty and does not have the focus. Applies to both editable and uneditable
* fields.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.plugins.LoremIpsum');
goog.require('goog.asserts');
goog.require('goog.dom');
goog.require('goog.editor.Command');
goog.require('goog.editor.Field');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.node');
goog.require('goog.functions');
goog.require('goog.userAgent');
/**
* A plugin that manages lorem ipsum state of editable fields.
* @param {string} message The lorem ipsum message.
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.LoremIpsum = function(message) {
goog.editor.Plugin.call(this);
/**
* The lorem ipsum message.
* @type {string}
* @private
*/
this.message_ = message;
};
goog.inherits(goog.editor.plugins.LoremIpsum, goog.editor.Plugin);
/** @override */
goog.editor.plugins.LoremIpsum.prototype.getTrogClassId =
goog.functions.constant('LoremIpsum');
/** @override */
goog.editor.plugins.LoremIpsum.prototype.activeOnUneditableFields =
goog.functions.TRUE;
/**
* Whether the field is currently filled with lorem ipsum text.
* @type {boolean}
* @private
*/
goog.editor.plugins.LoremIpsum.prototype.usingLorem_ = false;
/**
* Handles queryCommandValue.
* @param {string} command The command to query.
* @return {boolean} The result.
* @override
*/
goog.editor.plugins.LoremIpsum.prototype.queryCommandValue = function(command) {
return command == goog.editor.Command.USING_LOREM && this.usingLorem_;
};
/**
* Handles execCommand.
* @param {string} command The command to execute.
* Should be CLEAR_LOREM or UPDATE_LOREM.
* @param {*=} opt_placeCursor Whether to place the cursor in the field
* after clearing lorem. Should be a boolean.
* @override
*/
goog.editor.plugins.LoremIpsum.prototype.execCommand = function(command,
opt_placeCursor) {
if (command == goog.editor.Command.CLEAR_LOREM) {
this.clearLorem_(!!opt_placeCursor);
} else if (command == goog.editor.Command.UPDATE_LOREM) {
this.updateLorem_();
}
};
/** @override */
goog.editor.plugins.LoremIpsum.prototype.isSupportedCommand =
function(command) {
return command == goog.editor.Command.CLEAR_LOREM ||
command == goog.editor.Command.UPDATE_LOREM ||
command == goog.editor.Command.USING_LOREM;
};
/**
* Set the lorem ipsum text in a goog.editor.Field if needed.
* @private
*/
goog.editor.plugins.LoremIpsum.prototype.updateLorem_ = function() {
// Try to apply lorem ipsum if:
// 1) We have lorem ipsum text
// 2) There's not a dialog open, as that screws
// with the dialog's ability to properly restore the selection
// on dialog close (since the DOM nodes would get clobbered in FF)
// 3) We're not using lorem already
// 4) The field is not currently active (doesn't have focus).
var fieldObj = this.getFieldObject();
if (!this.usingLorem_ &&
!fieldObj.inModalMode() &&
goog.editor.Field.getActiveFieldId() != fieldObj.id) {
var field = fieldObj.getElement();
if (!field) {
// Fallback on the original element. This is needed by
// fields managed by click-to-edit.
field = fieldObj.getOriginalElement();
}
goog.asserts.assert(field);
if (goog.editor.node.isEmpty(field)) {
this.usingLorem_ = true;
// Save the old font style so it can be restored when we
// clear the lorem ipsum style.
this.oldFontStyle_ = field.style.fontStyle;
field.style.fontStyle = 'italic';
fieldObj.setHtml(true, this.message_, true);
}
}
};
/**
* Clear an EditableField's lorem ipsum and put in initial text if needed.
*
* If using click-to-edit mode (where Trogedit manages whether the field
* is editable), this works for both editable and uneditable fields.
*
* TODO(user): Is this really necessary? See TODO below.
* @param {boolean=} opt_placeCursor Whether to place the cursor in the field
* after clearing lorem.
* @private
*/
goog.editor.plugins.LoremIpsum.prototype.clearLorem_ = function(
opt_placeCursor) {
// Don't mess with lorem state when a dialog is open as that screws
// with the dialog's ability to properly restore the selection
// on dialog close (since the DOM nodes would get clobbered)
var fieldObj = this.getFieldObject();
if (this.usingLorem_ && !fieldObj.inModalMode()) {
var field = fieldObj.getElement();
if (!field) {
// Fallback on the original element. This is needed by
// fields managed by click-to-edit.
field = fieldObj.getOriginalElement();
}
goog.asserts.assert(field);
this.usingLorem_ = false;
field.style.fontStyle = this.oldFontStyle_;
fieldObj.setHtml(true, null, true);
// TODO(nicksantos): I'm pretty sure that this is a hack, but talk to
// Julie about why this is necessary and what to do with it. Really,
// we need to figure out where it's necessary and remove it where it's
// not. Safari never places the cursor on its own willpower.
if (opt_placeCursor && fieldObj.isLoaded()) {
if (goog.userAgent.WEBKIT) {
goog.dom.getOwnerDocument(fieldObj.getElement()).body.focus();
fieldObj.focusAndPlaceCursorAtStart();
} else if (goog.userAgent.OPERA) {
fieldObj.placeCursorAtStart();
}
}
}
};
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
@author nicksantos@google.com (Nick Santos)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.LoremIpsum Tests
</title>
<script type="text/javascript" src="../../base.js">
</script>
<script type="text/javascript">
goog.require('goog.editor.plugins.LoremIpsumTest');
</script>
</head>
<body>
<div id="root">
<div id="field">
</div>
</div>
</body>
</html>
@@ -0,0 +1,156 @@
// 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.
goog.provide('goog.editor.plugins.LoremIpsumTest');
goog.setTestOnly('goog.editor.plugins.LoremIpsumTest');
goog.require('goog.dom');
goog.require('goog.editor.Command');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.LoremIpsum');
goog.require('goog.string.Unicode');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var FIELD;
var PLUGIN;
var HTML;
var UPPERCASE_CONTENTS = '<P>THE OWLS ARE NOT WHAT THEY SEEM.</P>';
function setUp() {
HTML = goog.dom.getElement('root').innerHTML;
FIELD = new goog.editor.Field('field');
PLUGIN = new goog.editor.plugins.LoremIpsum(
'The owls are not what they seem.');
FIELD.registerPlugin(PLUGIN);
}
function tearDown() {
FIELD.dispose();
goog.dom.getElement('root').innerHTML = HTML;
}
function testQueryUsingLorem() {
FIELD.makeEditable();
assertTrue(FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
FIELD.setHtml(true, 'fresh content', false, true);
assertFalse(FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
}
function testUpdateLoremIpsum() {
goog.dom.getElement('field').innerHTML = 'stuff';
var loremPlugin = FIELD.getPluginByClassId('LoremIpsum');
FIELD.makeEditable();
var content = '<div>foo</div>';
FIELD.setHtml(false, '', false, /* Don't update lorem */ false);
assertFalse('Field started with content, lorem must not be enabled.',
FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
FIELD.execCommand(goog.editor.Command.UPDATE_LOREM);
assertTrue('Field was set to empty, update must turn on lorem ipsum',
FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
FIELD.unregisterPlugin(loremPlugin);
FIELD.setHtml(false, content, false,
/* Update (turn off) lorem */ true);
FIELD.setHtml(false, '', false, /* Don't update lorem */ false);
FIELD.execCommand(goog.editor.Command.UPDATE_LOREM);
assertFalse('Field with no lorem message must not use lorem ipsum',
FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
FIELD.registerPlugin(loremPlugin);
FIELD.setHtml(false, content, false, true);
FIELD.setHtml(false, '', false, false);
goog.editor.Field.setActiveFieldId(FIELD.id);
FIELD.execCommand(goog.editor.Command.UPDATE_LOREM);
assertFalse('Active field must not use lorem ipsum',
FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
goog.editor.Field.setActiveFieldId(null);
FIELD.setHtml(false, content, false, true);
FIELD.setHtml(false, '', false, false);
FIELD.setModalMode(true);
FIELD.execCommand(goog.editor.Command.UPDATE_LOREM);
assertFalse('Must not turn on lorem ipsum while a dialog is open.',
FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
FIELD.setModalMode(true);
FIELD.dispose();
}
function testLoremIpsumAndGetCleanContents() {
goog.dom.getElement('field').innerHTML = 'This is a field';
FIELD.makeEditable();
// test direct getCleanContents
assertEquals('field reported wrong contents', 'This is a field',
FIELD.getCleanContents());
// test indirect getCleanContents
var contents = FIELD.getCleanContents();
assertEquals('field reported wrong contents', 'This is a field', contents);
// set field html, but explicitly forbid converting to lorem ipsum text
FIELD.setHtml(false, '&nbsp;', true, false /* no lorem */);
assertEquals('field contains unexpected contents', getNbsp(),
FIELD.getElement().innerHTML);
assertEquals('field reported wrong contents', getNbsp(),
FIELD.getCleanContents());
// now set field html allowing lorem
FIELD.setHtml(false, '&nbsp;', true, true /* lorem */);
assertEquals('field reported wrong contents', goog.string.Unicode.NBSP,
FIELD.getCleanContents());
assertEquals('field contains unexpected contents', UPPERCASE_CONTENTS,
FIELD.getElement().innerHTML.toUpperCase());
}
function testLoremIpsumAndGetCleanContents2() {
// make a field blank before we make it editable, and then check
// that making it editable activates lorem.
assert('field is editable', FIELD.isUneditable());
goog.dom.getElement('field').innerHTML = ' ';
FIELD.makeEditable();
assertEquals('field contains unexpected contents',
UPPERCASE_CONTENTS, FIELD.getElement().innerHTML.toUpperCase());
FIELD.makeUneditable();
assertEquals('field contains unexpected contents',
UPPERCASE_CONTENTS, goog.dom.getElement('field').innerHTML.toUpperCase());
}
function testLoremIpsumInClickToEditMode() {
// in click-to-edit mode, trogedit manages the editable state of the editor,
// so we must manage lorem ipsum in uneditable mode too.
FIELD.makeEditable();
assertEquals('field contains unexpected contents',
UPPERCASE_CONTENTS, FIELD.getElement().innerHTML.toUpperCase());
FIELD.makeUneditable();
assertEquals('field contains unexpected contents',
UPPERCASE_CONTENTS, goog.dom.getElement('field').innerHTML.toUpperCase());
}
function getNbsp() {
// On WebKit (pre-528) and Opera, &nbsp; shows up as its unicode character in
// innerHTML under some circumstances.
return (goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('528')) ||
goog.userAgent.OPERA ? '\u00a0' : '&nbsp;';
}
@@ -0,0 +1,780 @@
// 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.
// All Rights Reserved.
/**
* @fileoverview Plugin to handle Remove Formatting.
*
*/
goog.provide('goog.editor.plugins.RemoveFormatting');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.node');
goog.require('goog.editor.range');
goog.require('goog.string');
goog.require('goog.userAgent');
/**
* A plugin to handle removing formatting from selected text.
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.RemoveFormatting = function() {
goog.editor.Plugin.call(this);
/**
* Optional function to perform remove formatting in place of the
* provided removeFormattingWorker_.
* @type {?function(string): string}
* @private
*/
this.optRemoveFormattingFunc_ = null;
};
goog.inherits(goog.editor.plugins.RemoveFormatting, goog.editor.Plugin);
/**
* The editor command this plugin in handling.
* @type {string}
*/
goog.editor.plugins.RemoveFormatting.REMOVE_FORMATTING_COMMAND =
'+removeFormat';
/**
* Regular expression that matches a block tag name.
* @type {RegExp}
* @private
*/
goog.editor.plugins.RemoveFormatting.BLOCK_RE_ =
/^(DIV|TR|LI|BLOCKQUOTE|H\d|PRE|XMP)/;
/**
* Appends a new line to a string buffer.
* @param {Array<string>} sb The string buffer to add to.
* @private
*/
goog.editor.plugins.RemoveFormatting.appendNewline_ = function(sb) {
sb.push('<br>');
};
/**
* Create a new range delimited by the start point of the first range and
* the end point of the second range.
* @param {goog.dom.AbstractRange} startRange Use the start point of this
* range as the beginning of the new range.
* @param {goog.dom.AbstractRange} endRange Use the end point of this
* range as the end of the new range.
* @return {!goog.dom.AbstractRange} The new range.
* @private
*/
goog.editor.plugins.RemoveFormatting.createRangeDelimitedByRanges_ = function(
startRange, endRange) {
return goog.dom.Range.createFromNodes(
startRange.getStartNode(), startRange.getStartOffset(),
endRange.getEndNode(), endRange.getEndOffset());
};
/** @override */
goog.editor.plugins.RemoveFormatting.prototype.getTrogClassId = function() {
return 'RemoveFormatting';
};
/** @override */
goog.editor.plugins.RemoveFormatting.prototype.isSupportedCommand = function(
command) {
return command ==
goog.editor.plugins.RemoveFormatting.REMOVE_FORMATTING_COMMAND;
};
/** @override */
goog.editor.plugins.RemoveFormatting.prototype.execCommandInternal =
function(command, var_args) {
if (command ==
goog.editor.plugins.RemoveFormatting.REMOVE_FORMATTING_COMMAND) {
this.removeFormatting_();
}
};
/** @override */
goog.editor.plugins.RemoveFormatting.prototype.handleKeyboardShortcut =
function(e, key, isModifierPressed) {
if (!isModifierPressed) {
return false;
}
if (key == ' ') {
this.getFieldObject().execCommand(
goog.editor.plugins.RemoveFormatting.REMOVE_FORMATTING_COMMAND);
return true;
}
return false;
};
/**
* Removes formatting from the current selection. Removes basic formatting
* (B/I/U) using the browser's execCommand. Then extracts the html from the
* selection to convert, calls either a client's specified removeFormattingFunc
* callback or trogedit's general built-in removeFormattingWorker_,
* and then replaces the current selection with the converted text.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.removeFormatting_ = function() {
var range = this.getFieldObject().getRange();
if (range.isCollapsed()) {
return;
}
// Get the html to format and send it off for formatting. Built in
// removeFormat only strips some inline elements and some inline CSS styles
var convFunc = this.optRemoveFormattingFunc_ ||
goog.bind(this.removeFormattingWorker_, this);
this.convertSelectedHtmlText_(convFunc);
// Do the execCommand last as it needs block elements removed to work
// properly on background/fontColor in FF. There are, unfortunately, still
// cases where background/fontColor are not removed here.
var doc = this.getFieldDomHelper().getDocument();
doc.execCommand('RemoveFormat', false, undefined);
if (goog.editor.BrowserFeature.ADDS_NBSPS_IN_REMOVE_FORMAT) {
// WebKit converts spaces to non-breaking spaces when doing a RemoveFormat.
// See: https://bugs.webkit.org/show_bug.cgi?id=14062
this.convertSelectedHtmlText_(function(text) {
// This loses anything that might have legitimately been a non-breaking
// space, but that's better than the alternative of only having non-
// breaking spaces.
// Old versions of WebKit (Safari 3, Chrome 1) incorrectly match /u00A0
// and newer versions properly match &nbsp;.
var nbspRegExp =
goog.userAgent.isVersionOrHigher('528') ? /&nbsp;/g : /\u00A0/g;
return text.replace(nbspRegExp, ' ');
});
}
};
/**
* Finds the nearest ancestor of the node that is a table.
* @param {Node} nodeToCheck Node to search from.
* @return {Node} The table, or null if one was not found.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.getTableAncestor_ = function(
nodeToCheck) {
var fieldElement = this.getFieldObject().getElement();
while (nodeToCheck && nodeToCheck != fieldElement) {
if (nodeToCheck.tagName == goog.dom.TagName.TABLE) {
return nodeToCheck;
}
nodeToCheck = nodeToCheck.parentNode;
}
return null;
};
/**
* Replaces the contents of the selection with html. Does its best to maintain
* the original selection. Also does its best to result in a valid DOM.
*
* TODO(user): See if there's any way to make this work on Ranges, and then
* move it into goog.editor.range. The Firefox implementation uses execCommand
* on the document, so must work on the actual selection.
*
* @param {string} html The html string to insert into the range.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.pasteHtml_ = function(html) {
var range = this.getFieldObject().getRange();
var dh = this.getFieldDomHelper();
// Use markers to set the extent of the selection so that we can reselect it
// afterwards. This works better than builtin range manipulation in FF and IE
// because their implementations are so self-inconsistent and buggy.
var startSpanId = goog.string.createUniqueString();
var endSpanId = goog.string.createUniqueString();
html = '<span id="' + startSpanId + '"></span>' + html +
'<span id="' + endSpanId + '"></span>';
var dummyNodeId = goog.string.createUniqueString();
var dummySpanText = '<span id="' + dummyNodeId + '"></span>';
if (goog.editor.BrowserFeature.HAS_IE_RANGES) {
// IE's selection often doesn't include the outermost tags.
// We want to use pasteHTML to replace the range contents with the newly
// unformatted text, so we have to check to make sure we aren't just
// pasting into some stray tags. To do this, we first clear out the
// contents of the range and then delete all empty nodes parenting the now
// empty range. This way, the pasted contents are never re-embedded into
// formated nodes. Pasting purely empty html does not work, since IE moves
// the selection inside the next node, so we insert a dummy span.
var textRange = range.getTextRange(0).getBrowserRangeObject();
textRange.pasteHTML(dummySpanText);
var parent;
while ((parent = textRange.parentElement()) &&
goog.editor.node.isEmpty(parent) &&
!goog.editor.node.isEditableContainer(parent)) {
var tag = parent.nodeName;
// We can't remove these table tags as it will invalidate the table dom.
if (tag == goog.dom.TagName.TD ||
tag == goog.dom.TagName.TR ||
tag == goog.dom.TagName.TH) {
break;
}
goog.dom.removeNode(parent);
}
textRange.pasteHTML(html);
var dummySpan = dh.getElement(dummyNodeId);
// If we entered the while loop above, the node has already been removed
// since it was a child of parent and parent was removed.
if (dummySpan) {
goog.dom.removeNode(dummySpan);
}
} else if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
// insertHtml and range.insertNode don't merge blocks correctly.
// (e.g. if your selection spans two paragraphs)
dh.getDocument().execCommand('insertImage', false, dummyNodeId);
var dummyImageNodePattern = new RegExp('<[^<]*' + dummyNodeId + '[^>]*>');
var parent = this.getFieldObject().getRange().getContainerElement();
if (parent.nodeType == goog.dom.NodeType.TEXT) {
// Opera sometimes returns a text node here.
// TODO(user): perhaps we should modify getParentContainer?
parent = parent.parentNode;
}
// We have to search up the DOM because in some cases, notably when
// selecting li's within a list, execCommand('insertImage') actually splits
// tags in such a way that parent that used to contain the selection does
// not contain inserted image.
while (!dummyImageNodePattern.test(parent.innerHTML)) {
parent = parent.parentNode;
}
// Like the IE case above, sometimes the selection does not include the
// outermost tags. For Gecko, we have already expanded the range so that
// it does, so we can just replace the dummy image with the final html.
// For WebKit, we use the same approach as we do with IE - we
// inject a dummy span where we will eventually place the contents, and
// remove parentNodes of the span while they are empty.
if (goog.userAgent.GECKO) {
goog.editor.node.replaceInnerHtml(parent,
parent.innerHTML.replace(dummyImageNodePattern, html));
} else {
goog.editor.node.replaceInnerHtml(parent,
parent.innerHTML.replace(dummyImageNodePattern, dummySpanText));
var dummySpan = dh.getElement(dummyNodeId);
parent = dummySpan;
while ((parent = dummySpan.parentNode) &&
goog.editor.node.isEmpty(parent) &&
!goog.editor.node.isEditableContainer(parent)) {
var tag = parent.nodeName;
// We can't remove these table tags as it will invalidate the table dom.
if (tag == goog.dom.TagName.TD ||
tag == goog.dom.TagName.TR ||
tag == goog.dom.TagName.TH) {
break;
}
// We can't just remove parent since dummySpan is inside it, and we need
// to keep dummy span around for the replacement. So we move the
// dummySpan up as we go.
goog.dom.insertSiblingAfter(dummySpan, parent);
goog.dom.removeNode(parent);
}
goog.editor.node.replaceInnerHtml(parent,
parent.innerHTML.replace(new RegExp(dummySpanText, 'i'), html));
}
}
var startSpan = dh.getElement(startSpanId);
var endSpan = dh.getElement(endSpanId);
goog.dom.Range.createFromNodes(startSpan, 0, endSpan,
endSpan.childNodes.length).select();
goog.dom.removeNode(startSpan);
goog.dom.removeNode(endSpan);
};
/**
* Gets the html inside the selection to send off for further processing.
*
* TODO(user): Make this general so that it can be moved into
* goog.editor.range. The main reason it can't be moved is becuase we need to
* get the range before we do the execCommand and continue to operate on that
* same range (reasons are documented above).
*
* @param {goog.dom.AbstractRange} range The selection.
* @return {string} The html string to format.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.getHtmlText_ = function(range) {
var div = this.getFieldDomHelper().createDom('div');
var textRange = range.getBrowserRangeObject();
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
// Get the text to convert.
div.appendChild(textRange.cloneContents());
} else if (goog.editor.BrowserFeature.HAS_IE_RANGES) {
// Trim the whitespace on the ends of the range, so that it the container
// will be the container of only the text content that we are changing.
// This gets around issues in IE where the spaces are included in the
// selection, but ignored sometimes by execCommand, and left orphaned.
var rngText = range.getText();
// BRs get reported as \r\n, but only count as one character for moves.
// Adjust the string so our move counter is correct.
rngText = rngText.replace(/\r\n/g, '\r');
var rngTextLength = rngText.length;
var left = rngTextLength - goog.string.trimLeft(rngText).length;
var right = rngTextLength - goog.string.trimRight(rngText).length;
textRange.moveStart('character', left);
textRange.moveEnd('character', -right);
var htmlText = textRange.htmlText;
// Check if in pretag and fix up formatting so that new lines are preserved.
if (textRange.queryCommandValue('formatBlock') == 'Formatted') {
htmlText = goog.string.newLineToBr(textRange.htmlText);
}
div.innerHTML = htmlText;
}
// Get the innerHTML of the node instead of just returning the text above
// so that its properly html escaped.
return div.innerHTML;
};
/**
* Move the range so that it doesn't include any partially selected tables.
* @param {goog.dom.AbstractRange} range The range to adjust.
* @param {Node} startInTable Table node that the range starts in.
* @param {Node} endInTable Table node that the range ends in.
* @return {!goog.dom.SavedCaretRange} Range to use to restore the
* selection after we run our custom remove formatting.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.adjustRangeForTables_ =
function(range, startInTable, endInTable) {
// Create placeholders for the current selection so we can restore it
// later.
var savedCaretRange = goog.editor.range.saveUsingNormalizedCarets(range);
var startNode = range.getStartNode();
var startOffset = range.getStartOffset();
var endNode = range.getEndNode();
var endOffset = range.getEndOffset();
var dh = this.getFieldDomHelper();
// Move start after the table.
if (startInTable) {
var textNode = dh.createTextNode('');
goog.dom.insertSiblingAfter(textNode, startInTable);
startNode = textNode;
startOffset = 0;
}
// Move end before the table.
if (endInTable) {
var textNode = dh.createTextNode('');
goog.dom.insertSiblingBefore(textNode, endInTable);
endNode = textNode;
endOffset = 0;
}
goog.dom.Range.createFromNodes(startNode, startOffset,
endNode, endOffset).select();
return savedCaretRange;
};
/**
* Remove a caret from the dom and hide it in a safe place, so it can
* be restored later via restoreCaretsFromCave.
* @param {goog.dom.SavedCaretRange} caretRange The caret range to
* get the carets from.
* @param {boolean} isStart Whether this is the start or end caret.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.putCaretInCave_ = function(
caretRange, isStart) {
var cavedCaret = goog.dom.removeNode(caretRange.getCaret(isStart));
if (isStart) {
this.startCaretInCave_ = cavedCaret;
} else {
this.endCaretInCave_ = cavedCaret;
}
};
/**
* Restore carets that were hidden away by adding them back into the dom.
* Note: this does not restore to the original dom location, as that
* will likely have been modified with remove formatting. The only
* guarentees here are that start will still be before end, and that
* they will be in the editable region. This should only be used when
* you don't actually intend to USE the caret again.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.restoreCaretsFromCave_ =
function() {
// To keep start before end, we put the end caret at the bottom of the field
// and the start caret at the start of the field.
var field = this.getFieldObject().getElement();
if (this.startCaretInCave_) {
field.insertBefore(this.startCaretInCave_, field.firstChild);
this.startCaretInCave_ = null;
}
if (this.endCaretInCave_) {
field.appendChild(this.endCaretInCave_);
this.endCaretInCave_ = null;
}
};
/**
* Gets the html inside the current selection, passes it through the given
* conversion function, and puts it back into the selection.
*
* @param {function(string): string} convertFunc A conversion function that
* transforms an html string to new html string.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.convertSelectedHtmlText_ =
function(convertFunc) {
var range = this.getFieldObject().getRange();
// For multiple ranges, it is really hard to do our custom remove formatting
// without invalidating other ranges. So instead of always losing the
// content, this solution at least lets the browser do its own remove
// formatting which works correctly most of the time.
if (range.getTextRangeCount() > 1) {
return;
}
if (goog.userAgent.GECKO) {
// Determine if we need to handle tables, since they are special cases.
// If the selection is entirely within a table, there is no extra
// formatting removal we can do. If a table is fully selected, we will
// just blow it away. If a table is only partially selected, we can
// perform custom remove formatting only on the non table parts, since we
// we can't just remove the parts and paste back into it (eg. we can't
// inject html where a TR used to be).
// If the selection contains the table and more, this is automatically
// handled, but if just the table is selected, it can be tricky to figure
// this case out, because of the numerous ways selections can be formed -
// ex. if a table has a single tr with a single td with a single text node
// in it, and the selection is (textNode: 0), (textNode: nextNode.length)
// then the entire table is selected, even though the start and end aren't
// the table itself. We are truly inside a table if the expanded endpoints
// are still inside the table.
// Expand the selection to include any outermost tags that weren't included
// in the selection, but have the same visible selection. Stop expanding
// if we reach the top level field.
var expandedRange = goog.editor.range.expand(range,
this.getFieldObject().getElement());
var startInTable = this.getTableAncestor_(expandedRange.getStartNode());
var endInTable = this.getTableAncestor_(expandedRange.getEndNode());
if (startInTable || endInTable) {
if (startInTable == endInTable) {
// We are fully contained in the same table, there is no extra
// remove formatting that we can do, just return and run browser
// formatting only.
return;
}
// Adjust the range to not contain any partially selected tables, since
// we don't want to run our custom remove formatting on them.
var savedCaretRange = this.adjustRangeForTables_(range,
startInTable, endInTable);
// Hack alert!!
// If start is not in a table, then the saved caret will get sent out
// for uber remove formatting, and it will get blown away. This is
// fine, except that we need to be able to re-create a range from the
// savedCaretRange later on. So, we just remove it from the dom, and
// put it back later so we can create a range later (not exactly in the
// same spot, but don't worry we don't actually try to use it later)
// and then it will be removed when we dispose the range.
if (!startInTable) {
this.putCaretInCave_(savedCaretRange, true);
}
if (!endInTable) {
this.putCaretInCave_(savedCaretRange, false);
}
// Re-fetch the range, and re-expand it, since we just modified it.
range = this.getFieldObject().getRange();
expandedRange = goog.editor.range.expand(range,
this.getFieldObject().getElement());
}
expandedRange.select();
range = expandedRange;
}
// Convert the selected text to the format-less version, paste back into
// the selection.
var text = this.getHtmlText_(range);
this.pasteHtml_(convertFunc(text));
if (goog.userAgent.GECKO && savedCaretRange) {
// If we moved the selection, move it back so the user can't tell we did
// anything crazy and so the browser removeFormat that we call next
// will operate on the entire originally selected range.
range = this.getFieldObject().getRange();
this.restoreCaretsFromCave_();
var realSavedCaretRange = savedCaretRange.toAbstractRange();
var startRange = startInTable ? realSavedCaretRange : range;
var endRange = endInTable ? realSavedCaretRange : range;
var restoredRange =
goog.editor.plugins.RemoveFormatting.createRangeDelimitedByRanges_(
startRange, endRange);
restoredRange.select();
savedCaretRange.dispose();
}
};
/**
* Does a best-effort attempt at clobbering all formatting that the
* browser's execCommand couldn't clobber without being totally inefficient.
* Attempts to convert visual line breaks to BRs. Leaves anchors that contain an
* href and images.
* Adapted from Gmail's MessageUtil's htmlToPlainText. http://go/messageutil.js
* @param {string} html The original html of the message.
* @return {string} The unformatted html, which is just text, br's, anchors and
* images.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.removeFormattingWorker_ =
function(html) {
var el = goog.dom.createElement('div');
el.innerHTML = html;
// Put everything into a string buffer to avoid lots of expensive string
// concatenation along the way.
var sb = [];
var stack = [el.childNodes, 0];
// Keep separate stacks for places where we need to keep track of
// how deeply embedded we are. These are analogous to the general stack.
var preTagStack = [];
var preTagLevel = 0; // Length of the prestack.
var tableStack = [];
var tableLevel = 0;
// sp = stack pointer, pointing to the stack array.
// decrement by 2 since the stack alternates node lists and
// processed node counts
for (var sp = 0; sp >= 0; sp -= 2) {
// Check if we should pop the table level.
var changedLevel = false;
while (tableLevel > 0 && sp <= tableStack[tableLevel - 1]) {
tableLevel--;
changedLevel = true;
}
if (changedLevel) {
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
}
// Check if we should pop the <pre>/<xmp> level.
changedLevel = false;
while (preTagLevel > 0 && sp <= preTagStack[preTagLevel - 1]) {
preTagLevel--;
changedLevel = true;
}
if (changedLevel) {
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
}
// The list of of nodes to process at the current stack level.
var nodeList = stack[sp];
// The number of nodes processed so far, stored in the stack immediately
// following the node list for that stack level.
var numNodesProcessed = stack[sp + 1];
while (numNodesProcessed < nodeList.length) {
var node = nodeList[numNodesProcessed++];
var nodeName = node.nodeName;
var formatted = this.getValueForNode(node);
if (goog.isDefAndNotNull(formatted)) {
sb.push(formatted);
continue;
}
// TODO(user): Handle case 'EMBED' and case 'OBJECT'.
switch (nodeName) {
case '#text':
// Note that IE does not preserve whitespace in the dom
// values, even in a pre tag, so this is useless for IE.
var nodeValue = preTagLevel > 0 ?
node.nodeValue :
goog.string.stripNewlines(node.nodeValue);
nodeValue = goog.string.htmlEscape(nodeValue);
sb.push(nodeValue);
continue;
case goog.dom.TagName.P:
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
break; // break (not continue) so that child nodes are processed.
case goog.dom.TagName.BR:
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
continue;
case goog.dom.TagName.TABLE:
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
tableStack[tableLevel++] = sp;
break;
case goog.dom.TagName.PRE:
case 'XMP':
// This doesn't fully handle xmp, since
// it doesn't actually ignore tags within the xmp tag.
preTagStack[preTagLevel++] = sp;
break;
case goog.dom.TagName.STYLE:
case goog.dom.TagName.SCRIPT:
case goog.dom.TagName.SELECT:
continue;
case goog.dom.TagName.A:
if (node.href && node.href != '') {
sb.push("<a href='");
sb.push(node.href);
sb.push("'>");
sb.push(this.removeFormattingWorker_(node.innerHTML));
sb.push('</a>');
continue; // Children taken care of.
} else {
break; // Take care of the children.
}
case goog.dom.TagName.IMG:
sb.push("<img src='");
sb.push(node.src);
sb.push("'");
// border=0 is a common way to not show a blue border around an image
// that is wrapped by a link. If we remove that, the blue border will
// show up, which to the user looks like adding format, not removing.
if (node.border == '0') {
sb.push(" border='0'");
}
sb.push('>');
continue;
case goog.dom.TagName.TD:
// Don't add a space for the first TD, we only want spaces to
// separate td's.
if (node.previousSibling) {
sb.push(' ');
}
break;
case goog.dom.TagName.TR:
// Don't add a newline for the first TR.
if (node.previousSibling) {
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
}
break;
case goog.dom.TagName.DIV:
var parent = node.parentNode;
if (parent.firstChild == node &&
goog.editor.plugins.RemoveFormatting.BLOCK_RE_.test(
parent.tagName)) {
// If a DIV is the first child of another element that itself is a
// block element, the DIV does not add a new line.
break;
}
// Otherwise, the DIV does add a new line. Fall through.
default:
if (goog.editor.plugins.RemoveFormatting.BLOCK_RE_.test(nodeName)) {
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
}
}
// Recurse down the node.
var children = node.childNodes;
if (children.length > 0) {
// Push the current state on the stack.
stack[sp++] = nodeList;
stack[sp++] = numNodesProcessed;
// Iterate through the children nodes.
nodeList = children;
numNodesProcessed = 0;
}
}
}
// Replace &nbsp; with white space.
return goog.string.normalizeSpaces(sb.join(''));
};
/**
* Handle per node special processing if neccessary. If this function returns
* null then standard cleanup is applied. Otherwise this node and all children
* are assumed to be cleaned.
* NOTE(user): If an alternate RemoveFormatting processor is provided
* (setRemoveFormattingFunc()), this will no longer work.
* @param {Element} node The node to clean.
* @return {?string} The HTML strig representation of the cleaned data.
*/
goog.editor.plugins.RemoveFormatting.prototype.getValueForNode = function(
node) {
return null;
};
/**
* Sets a function to be used for remove formatting.
* @param {function(string): string} removeFormattingFunc - A function that
* takes a string of html and returns a string of html that does any other
* formatting changes desired. Use this only if trogedit's behavior doesn't
* meet your needs.
*/
goog.editor.plugins.RemoveFormatting.prototype.setRemoveFormattingFunc =
function(removeFormattingFunc) {
this.optRemoveFormattingFunc_ = removeFormattingFunc;
};
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<!--
This test has not yet been updated to run on IE8. See http://b/hotlist?id=36311
-->
<!--meta http-equiv="X-UA-Compatible" content="IE=edge"-->
<meta http-equiv="X-UA-Compatible" content="IE=EmulateIE7" />
<title>
goog.editor.plugins.RemoveFormatting Tests
</title>
<script type="text/javascript" src="../../base.js">
</script>
<script type="text/javascript">
goog.require('goog.editor.plugins.RemoveFormattingTest');
</script>
</head>
<body>
<!--
This wrapper table is outside the mock editor and only used ensure that it is
ignored by the tests.
-->
<table>
<tr>
<td>
<div id="html">
</div>
</td>
</tr>
</table>
<div id="abcde">abcde</div>
</body>
</html>
@@ -0,0 +1,955 @@
// 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.
goog.provide('goog.editor.plugins.RemoveFormattingTest');
goog.setTestOnly('goog.editor.plugins.RemoveFormattingTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.plugins.RemoveFormatting');
goog.require('goog.string');
goog.require('goog.testing.ExpectedFailures');
goog.require('goog.testing.dom');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var SAVED_HTML;
var FIELDMOCK;
var FORMATTER;
var testHelper;
var WEBKIT_BEFORE_CHROME_8;
var WEBKIT_AFTER_CHROME_16;
var WEBKIT_AFTER_CHROME_21;
var insertImageBoldGarbage = '';
var insertImageFontGarbage = '';
var controlHtml;
var controlCleanHtml;
var expectedFailures;
function setUpPage() {
WEBKIT_BEFORE_CHROME_8 = goog.userAgent.WEBKIT &&
!goog.userAgent.isVersionOrHigher('534.10');
WEBKIT_AFTER_CHROME_16 = goog.userAgent.WEBKIT &&
goog.userAgent.isVersionOrHigher('535.7');
WEBKIT_AFTER_CHROME_21 = goog.userAgent.WEBKIT &&
goog.userAgent.isVersionOrHigher('537.1');
// On Chrome 16, execCommand('insertImage') inserts a garbage BR
// after the image that we insert. We use this command to paste HTML
// in-place, because it has better paragraph-preserving semantics.
//
// TODO(nicksantos): Figure out if there are better chrome APIs that we
// should be using, or if insertImage should just be fixed.
if (WEBKIT_AFTER_CHROME_21) {
insertImageBoldGarbage = '<br>';
insertImageFontGarbage = '<br>';
} else if (WEBKIT_AFTER_CHROME_16) {
insertImageBoldGarbage = '<b><br/></b>';
insertImageFontGarbage = '<font size="1"><br/></font>';
}
// Extra html to add to test html to make sure removeformatting is actually
// getting called when you're testing if it leaves certain styles alone
// (instead of not even running at all due to some other bug). However, adding
// this extra text into the node to be selected screws up IE.
// (e.g. <a><img></a><b>t</b> --> <a></a><a><img></a>t )
// TODO(user): Remove this special casing once http://b/3131117 is
// fixed.
controlHtml = goog.userAgent.IE ? '' : '<u>control</u>';
controlCleanHtml = goog.userAgent.IE ? '' : 'control';
expectedFailures = new goog.testing.ExpectedFailures();
}
function setUp() {
testHelper = new goog.testing.editor.TestHelper(
document.getElementById('html'));
testHelper.setUpEditableElement();
FIELDMOCK = new goog.testing.editor.FieldMock();
FIELDMOCK.getElement();
FIELDMOCK.$anyTimes();
FIELDMOCK.$returns(document.getElementById('html'));
FORMATTER = new goog.editor.plugins.RemoveFormatting();
FORMATTER.fieldObject = FIELDMOCK;
FIELDMOCK.$replay();
}
function tearDown() {
expectedFailures.handleTearDown();
testHelper.tearDownEditableElement();
}
function setUpTableTests() {
var div = document.getElementById('html');
div.innerHTML = '<table><tr> <th> head1</th><th id= "outerTh">' +
'<span id="emptyTh">head2</span></th> </tr><tr> <td> one </td> <td>' +
'two </td> </tr><tr><td> three</td><td id="outerTd"> ' +
'<span id="emptyTd"><strong>four</strong></span></td></tr>' +
'<tr id="outerTr"><td><span id="emptyTr"> five </span></td></tr>' +
'<tr id="outerTr2"><td id="cell1"><b>seven</b></td><td id="cell2">' +
'<u>eight</u><span id="cellspan2"> foo</span></td></tr></table>';
}
function testTableTagsAreNotRemoved() {
setUpTableTests();
var span;
// TD
span = document.getElementById('emptyTd');
goog.dom.Range.createFromNodeContents(span).select();
FORMATTER.removeFormatting_();
var elem = document.getElementById('outerTd');
assert('TD should not be removed', !!elem);
if (!goog.userAgent.WEBKIT) {
// webkit seems to have an Apple-style-span
assertEquals('TD should be clean', 'four',
goog.string.trim(elem.innerHTML));
}
// TR
span = document.getElementById('outerTr');
goog.dom.Range.createFromNodeContents(span).select();
FORMATTER.removeFormatting_();
var elem = document.getElementById('outerTr');
assert('TR should not be removed', !!elem);
// TH
span = document.getElementById('emptyTh');
goog.dom.Range.createFromNodeContents(span).select();
FORMATTER.removeFormatting_();
var elem = document.getElementById('outerTh');
assert('TH should not be removed', !!elem);
if (!goog.userAgent.WEBKIT) {
// webkit seems to have an Apple-style-span
assertEquals('TH should be clean', 'head2', elem.innerHTML);
}
}
/**
* We select two cells from the table and then make sure that there is no
* data loss and basic formatting is removed from each cell.
*/
function testTableDataIsNotRemoved() {
setUpTableTests();
if (goog.userAgent.IE) {
// IE returns an "unspecified error" which seems to be beyond
// ExpectedFailures' ability to catch.
return;
}
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'The content moves out of the table in WEBKIT.');
if (goog.userAgent.IE) {
// Not used since we bail out early for IE, but this is there so that
// developers can easily reproduce IE error.
goog.dom.Range.createFromNodeContents(
document.getElementById('outerTr2')).select();
} else {
var selection = window.getSelection();
if (selection.rangeCount > 0) selection.removeAllRanges();
var range = document.createRange();
range.selectNode(document.getElementById('cell1'));
selection.addRange(range);
range = document.createRange();
range.selectNode(document.getElementById('cell2'));
selection.addRange(range);
}
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
span = document.getElementById('outerTr2');
assertEquals('Table data should not be removed',
'<td id="cell1">seven</td><td id="cell2">eight foo</td>',
span.innerHTML);
});
}
function testLinksAreNotRemoved() {
expectedFailures.expectFailureFor(WEBKIT_BEFORE_CHROME_8,
'WebKit\'s removeFormatting command removes links.');
var anchor;
var div = document.getElementById('html');
div.innerHTML = 'Foo<span id="link">Pre<a href="http://www.google.com">' +
'Outside Span<span style="font-size:15pt">Inside Span' +
'</span></a></span>';
anchor = document.getElementById('link');
goog.dom.Range.createFromNodeContents(anchor).select();
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
assertHTMLEquals('link should not be removed',
'FooPre<a href="http://www.google.com/">Outside SpanInside Span</a>',
div.innerHTML);
});
}
/**
* A short formatting removal function for use with the RemoveFormatting
* plugin. Does enough that we can tell this function was run over the
* document.
* @param {string} text The HTML in from the document.
* @return {string} The "cleaned" HTML out.
*/
function replacementFormattingFunc(text) {
// Really basic so that we can just see this is executing.
return text.replace(/Foo/gi, 'Bar').replace(/<[\/]*span[^>]*>/gi, '');
}
function testAlternateRemoveFormattingFunction() {
var div = document.getElementById('html');
div.innerHTML = 'Start<span id="remFormat">Foo<pre>Bar</pre>Baz</span>';
FORMATTER.setRemoveFormattingFunc(replacementFormattingFunc);
var area = document.getElementById('remFormat');
goog.dom.Range.createFromNodeContents(area).select();
FORMATTER.removeFormatting_();
// Webkit will change all tags to non-formatted ones anyway.
// Make sure 'Foo' was changed to 'Bar'
if (WEBKIT_BEFORE_CHROME_8) {
assertHTMLEquals('regular cleaner should not have run',
'StartBar<br>Bar<br>Baz',
div.innerHTML);
} else {
assertHTMLEquals('regular cleaner should not have run',
'StartBar<pre>Bar</pre>Baz',
div.innerHTML);
}
}
function testGetValueForNode() {
// Override getValueForNode to keep bold tags.
var oldGetValue =
goog.editor.plugins.RemoveFormatting.prototype.getValueForNode;
goog.editor.plugins.RemoveFormatting.prototype.getValueForNode =
function(node) {
if (node.nodeName == goog.dom.TagName.B) {
return '<b>' + this.removeFormattingWorker_(node.innerHTML) + '</b>';
}
return null;
};
var html = FORMATTER.removeFormattingWorker_('<div>foo<b>bar</b></div>');
assertHTMLEquals('B tags should remain', 'foo<b>bar</b>', html);
// Override getValueForNode to throw out bold tags, and their contents.
goog.editor.plugins.RemoveFormatting.prototype.getValueForNode =
function(node) {
if (node.nodeName == goog.dom.TagName.B) {
return '';
}
return null;
};
html = FORMATTER.removeFormattingWorker_('<div>foo<b>bar</b></div>');
assertHTMLEquals('B tag and its contents should be removed', 'foo', html);
FIELDMOCK.$verify();
goog.editor.plugins.RemoveFormatting.prototype.getValueForNode =
oldGetValue;
}
function testRemoveFormattingAddsNoNbsps() {
var div = document.getElementById('html');
div.innerHTML = '"<span id="toStrip">Twin <b>Cinema</b></span>"';
var span = document.getElementById('toStrip');
goog.dom.Range.createFromNodeContents(span).select();
FORMATTER.removeFormatting_();
assertEquals('Text should be the same, with no non-breaking spaces',
'"Twin Cinema"', div.innerHTML);
FIELDMOCK.$verify();
}
/**
* @bug 992795
*/
function testRemoveFormattingNestedDivs() {
var html = FORMATTER.removeFormattingWorker_(
'<div>1</div><div><div>2</div></div>');
goog.testing.dom.assertHtmlMatches('1<br>2', html);
}
/**
* Test that when we perform remove formatting on an entire table,
* that the visual look is similiar to as if there was a table there.
*/
function testRemoveFormattingForTableFormatting() {
// We preserve the table formatting as much as possible.
// Spaces separate TD's, <br>'s separate TR's.
// <br>'s separate the start and end of a table.
var html = '<table><tr><td>cell00</td><td>cell01</td></tr>' +
'<tr><td>cell10</td><td>cell11</td></tr></table>';
html = FORMATTER.removeFormattingWorker_(html);
assertHTMLEquals('<br>cell00 cell01<br>cell10 cell11<br>', html);
}
/**
* @bug 1319715
*/
function testRemoveFormattingDoesNotShrinkSelection() {
var div = document.getElementById('html');
div.innerHTML = '<div>l </div><div><br><b>a</b>foo bar</div>';
var div2 = div.lastChild;
goog.dom.Range.createFromNodes(div2.firstChild, 0,
div2.lastChild, 7).select();
FORMATTER.removeFormatting_();
var range = goog.dom.Range.createFromWindow();
assertEquals('Correct text should be selected', 'afoo bar',
range.getText());
// We have to trim out the leading BR in IE due to execCommand issues,
// so it isn't sent off to the removeFormattingWorker.
// Workaround for broken removeFormat in old webkit added an extra
// <br> to the end of the html.
var html = '<div>l </div><br class="GECKO WEBKIT">afoo bar' +
(goog.editor.BrowserFeature.ADDS_NBSPS_IN_REMOVE_FORMAT ? '<br>' : '');
goog.testing.dom.assertHtmlContentsMatch(html, div);
FIELDMOCK.$verify();
}
/**
* @bug 1447374
*/
function testInsideListRemoveFormat() {
var div = document.getElementById('html');
div.innerHTML = '<ul><li>one</li><li><b>two</b></li><li>three</li></ul>';
var twoLi = div.firstChild.childNodes[1];
goog.dom.Range.createFromNodeContents(twoLi).select();
expectedFailures.expectFailureFor(goog.userAgent.IE,
'IE adds the "two" to the "three" li, and leaves empty B tags.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'WebKit leave the "two" orphaned outside of an li but ' +
'inside the ul (invalid HTML).');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Test that we split the list.
assertHTMLEquals('<ul><li>one</li></ul><br>two<ul><li>three</li></ul>',
div.innerHTML);
FIELDMOCK.$verify();
});
}
function testFullListRemoveFormat() {
var div = document.getElementById('html');
div.innerHTML =
'<ul><li>one</li><li><b>two</b></li><li>three</li></ul>after';
goog.dom.Range.createFromNodeContents(div.firstChild).select();
// Note: This may just be a createFromNodeContents issue, as
// I can't ever make this happen with real user selection.
expectedFailures.expectFailureFor(goog.userAgent.IE,
'IE combines everything into a single LI and leaves the UL.');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Test that we completely remove the list.
assertHTMLEquals('<br>one<br>two<br>threeafter',
div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 1440935
*/
function testPartialListRemoveFormat() {
var div = document.getElementById('html');
div.innerHTML =
'<ul><li>one</li><li>two</li><li>three</li></ul>after';
// Select "two three after".
goog.dom.Range.createFromNodes(div.firstChild.childNodes[1], 0,
div.lastChild, 5).select();
expectedFailures.expectFailureFor(goog.userAgent.IE,
'IE leaves behind an empty LI.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'WebKit completely loses the "one".');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Test that we leave the list start alone.
assertHTMLEquals('<ul><li>one</li></ul><br>two<br>threeafter',
div.innerHTML);
FIELDMOCK.$verify();
});
}
function testBasicRemoveFormatting() {
// IE will clobber the editable div.
// Note: I can't repro this using normal user selections.
if (goog.userAgent.IE) {
return;
}
var div = document.getElementById('html');
div.innerHTML = '<b>bold<i>italic</i></b>';
goog.dom.Range.createFromNodeContents(div).select();
expectedFailures.expectFailureFor(
goog.editor.BrowserFeature.ADDS_NBSPS_IN_REMOVE_FORMAT,
'The workaround for the nbsp bug adds an extra br at the end.');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
assertHTMLEquals('bolditalic' + insertImageBoldGarbage,
div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 1480260
*/
function testPartialBasicRemoveFormatting() {
var div = document.getElementById('html');
div.innerHTML = '<b>bold<i>italic</i></b>';
goog.dom.Range.createFromNodes(div.firstChild.firstChild, 2,
div.firstChild.lastChild.firstChild, 3).select();
expectedFailures.expectFailureFor(WEBKIT_BEFORE_CHROME_8,
'WebKit just gets this all wrong. Everything stays bold and ' +
'"lditalic" gets italicised.');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
assertHTMLEquals('<b>bo</b>ldita<b><i>lic</i></b>',
div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 3075557
*/
function testRemoveFormattingLinkedImageBorderZero() {
var testHtml = '<a href="http://www.google.com/">' +
'<img src="http://www.google.com/images/logo.gif" border="0"></a>';
var div = document.getElementById('html');
div.innerHTML = testHtml + controlHtml;
goog.dom.Range.createFromNodeContents(div).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'WebKit removes the image entirely, see ' +
'https://bugs.webkit.org/show_bug.cgi?id=13125 .');
expectedFailures.run(function() {
assertHTMLEquals(
'Image\'s border=0 should not be removed during remove formatting',
testHtml + controlCleanHtml, div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 3075557
*/
function testRemoveFormattingLinkedImageBorderNonzero() {
var testHtml = '<a href="http://www.google.com/">' +
'<img src="http://www.google.com/images/logo.gif" border="1"></a>';
var div = document.getElementById('html');
div.innerHTML = testHtml + controlHtml;
goog.dom.Range.createFromNodeContents(div).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'WebKit removes the image entirely, see ' +
'https://bugs.webkit.org/show_bug.cgi?id=13125 .');
expectedFailures.run(function() {
assertHTMLEquals(
'Image\'s border should be removed during remove formatting' +
' if non-zero',
testHtml.replace(' border="1"', '') + controlCleanHtml,
div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 3075557
*/
function testRemoveFormattingUnlinkedImage() {
var testHtml =
'<img src="http://www.google.com/images/logo.gif" border="0">';
var div = document.getElementById('html');
div.innerHTML = testHtml + controlHtml;
goog.dom.Range.createFromNodeContents(div).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'WebKit removes the image entirely, see ' +
'https://bugs.webkit.org/show_bug.cgi?id=13125 .');
expectedFailures.run(function() {
assertHTMLEquals(
'Image\'s border=0 should not be removed during remove formatting' +
' even if not wrapped by a link',
testHtml + controlCleanHtml, div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 3075557
*/
function testRemoveFormattingLinkedImageDeep() {
var testHtml = '<a href="http://www.google.com/"><b>hello' +
'<img src="http://www.google.com/images/logo.gif" border="0">' +
'world</b></a>';
var div = document.getElementById('html');
div.innerHTML = testHtml + controlHtml;
goog.dom.Range.createFromNodeContents(div).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(WEBKIT_BEFORE_CHROME_8,
'WebKit removes the image entirely, see ' +
'https://bugs.webkit.org/show_bug.cgi?id=13125 .');
expectedFailures.run(function() {
assertHTMLEquals(
'Image\'s border=0 should not be removed during remove formatting' +
' even if deep inside anchor tag',
testHtml.replace(/<\/?b>/g, '') +
controlCleanHtml + insertImageBoldGarbage,
div.innerHTML);
FIELDMOCK.$verify();
});
}
function testFullTableRemoveFormatting() {
// Something goes horrible wrong in case 1 below. It was crashing all
// WebKit browsers, and now seems to be giving errors as it is trying
// to perform remove formatting on the little expected failures window
// instead of the dom we select. WTF. Since I'm gutting this code,
// I'm not going to look into this anymore right now. For what its worth,
// I can't repro any issues in standalone TrogEdit.
if (goog.userAgent.WEBKIT) {
return;
}
var div = document.getElementById('html');
// WebKit has an extra BR in case 2.
expectedFailures.expectFailureFor(goog.userAgent.IE,
'IE clobbers the editable node in case 2 (can\'t repro with real ' +
'user selections). IE doesn\'t remove the table in case 1.');
expectedFailures.run(function() {
// When a full table is selected, we remove it completely.
div.innerHTML = 'foo<table><tr><td>bar</td></tr></table>baz1';
goog.dom.Range.createFromNodeContents(div.childNodes[1]).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('foo<br>bar<br>baz1', div.innerHTML);
FIELDMOCK.$verify();
// Remove the full table when it is selected with additional
// contents too.
div.innerHTML = 'foo<table><tr><td>bar</td></tr></table>baz2';
goog.dom.Range.createFromNodes(div.firstChild, 0,
div.lastChild, 1).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('foo<br>bar<br>baz2', div.innerHTML);
FIELDMOCK.$verify();
// We should still remove the table, even if the selection is inside the
// table and it is fully selected.
div.innerHTML = 'foo<table><tr><td id=\'td\'>bar</td></tr></table>baz3';
goog.dom.Range.createFromNodeContents(
goog.dom.getElement('td').firstChild).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('foo<br>bar<br>baz3', div.innerHTML);
FIELDMOCK.$verify();
});
}
function testInsideTableRemoveFormatting() {
var div = document.getElementById('html');
div.innerHTML =
'<table><tr><td><b id="b">foo</b></td></tr><tr><td>ba</td></tr></table>';
goog.dom.Range.createFromNodeContents(goog.dom.getElement('b')).select();
// Webkit adds some apple style span crap during execCommand("removeFormat")
// Our workaround for the nbsp bug removes these, but causes worse problems.
// See bugs.webkit.org/show_bug.cgi?id=29164 for more details.
expectedFailures.expectFailureFor(
WEBKIT_BEFORE_CHROME_8 &&
!goog.editor.BrowserFeature.ADDS_NBSPS_IN_REMOVE_FORMAT,
'Extra apple-style-spans');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Only remove styling from inside tables.
assertHTMLEquals(
'<table><tr><td>foo' + insertImageBoldGarbage +
'</td></tr><tr><td>ba</td></tr></table>',
div.innerHTML);
FIELDMOCK.$verify();
});
}
function testPartialTableRemoveFormatting() {
if (goog.userAgent.IE) {
// IE returns an "unspecified error" which seems to be beyond
// ExpectedFailures' ability to catch.
return;
}
var div = document.getElementById('html');
div.innerHTML = 'bar<table><tr><td><b id="b">foo</b></td></tr>' +
'<tr><td><i>banana</i></td></tr></table><div id="baz">' +
'baz</div>';
// Select from the "oo" inside the b tag to the end of "baz".
goog.dom.Range.createFromNodes(goog.dom.getElement('b').firstChild, 1,
goog.dom.getElement('baz').firstChild, 3).select();
// All browsers currently clobber the table cells that are selected.
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT);
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Only remove styling from inside tables.
assertHTMLEquals('bar<table><tr><td><b id="b">f</b>oo</td></tr>' +
'<tr><td>banana</td></tr></table>baz', div.innerHTML);
FIELDMOCK.$verify();
});
}
// Runs tests knowing some browsers will fail, because the new
// table functionality hasn't been implemented in them yet.
function runExpectingFailuresForUnimplementedBrowsers(func) {
if (goog.userAgent.IE) {
// IE returns an "unspecified error" which seems to be beyond
// ExpectedFailures' ability to catch.
return;
}
expectedFailures.expectFailureFor(goog.userAgent.IE,
'Proper behavior not yet implemented for IE.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'Proper behavior not yet implemented for WebKit.');
expectedFailures.run(func);
}
function testTwoTablesSelectedFullyRemoveFormatting() {
runExpectingFailuresForUnimplementedBrowsers(function() {
var div = document.getElementById('html');
// When two tables are fully selected, we remove them completely.
div.innerHTML = '<table><tr><td>foo</td></tr></table>' +
'<table><tr><td>bar</td></tr></table>';
goog.dom.Range.createFromNodes(div.firstChild, 0,
div.lastChild, 1).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('<br>foo<br><br>bar<br>', div.innerHTML);
FIELDMOCK.$verify();
});
}
function testTwoTablesSelectedFullyInsideRemoveFormatting() {
if (goog.userAgent.WEBKIT) {
// Something goes very wrong here, but it did before
// Julie started writing v2. Will address when converting
// safari to v2.
return;
}
runExpectingFailuresForUnimplementedBrowsers(function() {
var div = document.getElementById('html');
// When two tables are selected from inside but fully,
// also remove them completely.
div.innerHTML = '<table><tr><td id="td1">foo</td></tr></table>' +
'<table><tr><td id="td2">bar</td></tr></table>';
goog.dom.Range.createFromNodes(goog.dom.getElement('td1').firstChild, 0,
goog.dom.getElement('td2').firstChild, 3).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('<br>foo<br><br>bar<br>', div.innerHTML);
FIELDMOCK.$verify();
});
}
function testTwoTablesSelectedFullyAndPartiallyRemoveFormatting() {
runExpectingFailuresForUnimplementedBrowsers(function() {
var div = document.getElementById('html');
// Two tables selected, one fully, one partially. Remove
// only the fully selected one and remove styles only from
// partially selected one.
div.innerHTML = '<table><tr><td id="td1">foo</td></tr></table>' +
'<table><tr><td id="td2"><b>bar<b></td></tr></table>';
goog.dom.Range.createFromNodes(goog.dom.getElement('td1').firstChild, 0,
goog.dom.getElement('td2').firstChild.firstChild, 2).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('<br>foo<br>' +
'<table><tr><td id="td2">ba<b>r</b></td></tr></table>',
div.innerHTML);
FIELDMOCK.$verify();
});
}
function testTwoTablesSelectedPartiallyRemoveFormatting() {
runExpectingFailuresForUnimplementedBrowsers(function() {
var div = document.getElementById('html');
// Two tables selected, both partially. Don't remove tables,
// but remove styles.
div.innerHTML = '<table><tr><td id="td1">f<i>o</i>o</td></tr></table>' +
'<table><tr><td id="td2">b<b>a</b>r</td></tr></table>';
goog.dom.Range.createFromNodes(goog.dom.getElement('td1').firstChild, 1,
goog.dom.getElement('td2').childNodes[1], 1).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('<table><tr><td id="td1">foo</td></tr></table>' +
'<table><tr><td id="td2">bar</td></tr></table>',
div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* Test a random snippet from Google News (Google News has complicated
* dom structure, including tables, links, images, etc).
*/
function testRandomGoogleNewsSnippetRemoveFormatting() {
if (goog.userAgent.IE) {
// IE returns an "unspecified error" which seems to be beyond
// ExpectedFailures' ability to catch.
return;
}
var div = document.getElementById('html');
div.innerHTML =
'<font size="-3"><br></font><table align="right" border="0" ' +
'cellpadding="0" cellspacing="0"><tbody><tr><td style="padding-left:' +
'6px;" valign="top" width="80" align="center"><a href="http://www.wash' +
'ingtonpost.com/wp-dyn/content/article/2008/11/11/AR2008111101090.htm' +
'l" + id="s-skHRvWH7ryqkcA4caGv0QQ:u-AFQjCNG3vx1HJOxKxMQPzCvYOVRE0JUDe' +
'Q:r-1-0i_1268233361_6_H0_MH20_PL60"><img src="http://news.google.com/' +
'news?imgefp=4LFiNNP62TgJ&amp;imgurl=media3.washingtonpost.com/wp-dyn/' +
'content/photo/2008/11/11/PH2008111101091.jpg" alt="" width="60" ' +
'border="1" height="80"><br><font size="-2">Washington Post</font></a>' +
'</td></tr></tbody></table><a href="http://www.nme.com/news/britney-' +
'spears/40995" id="s-xZUO-t0c1IpsVjyJj0rgxw:u-AFQjCNEZAMQCseEW6uTgXI' +
'iPvAMHe_0B4A:r-1-0_1268233361_6_H0_MH20_PL60"><b>Britney\'s son ' +
'released from hospital</b></a><br><font size="-1"><b><font color=' +
'"#6f6f6f">NME.com&nbsp;-</font> <nobr>53 minutes ago</nobr></b>' +
'</font><br><font size="-1">Britney Spears youngest son Jayden James ' +
'has been released from hospital, having been admitted on Sunday after' +
' suffering a severe reaction to something he ingested.</font><br><fon' +
'tsize="-1"><a href="http://www.celebrity-gossip.net/celebrities/holly' +
'wood/britney-and-jamie-lynn-spears-alligator-alley-208944/" id="s-nM' +
'PzHclcMG0J2WZkw9gnVQ:u-AFQjCNHal08usOQ5e5CAQsck2yGsTYeGVQ">Britney ' +
'and Jamie Lynn Spears: Alligator Alley!</a> <font size="-1" color=' +
'"#6f6f6f"><nobr>The Gossip Girls</nobr></font></font><br><font size=' +
'"-1"><a href="http://foodconsumer.org/7777/8888/Other_N_ews_51/111101' +
'362008_Allergy_incident_could_spell_custody_trouble_for_Britney_Spear' +
's.shtml" id="s-2lMNDY4joOprVvkkY_b-6A:u-AFQjCNGAeFNutMEbSg5zAvrh5reBF' +
'lqUmA">Allergy incident could spell trouble for Britney Spears</a> ' +
'<font size="-1" color="#6f6f6f"><nobr>Food Consumer</nobr></font>' +
'</font><br><font class="p" size="-1"><a href="http://www.people.com/' +
'people/article/0,,20239458,00.html" id="s-x9thwVUYVET0ZJOnkkcsjw:u-A' +
'FQjCNE99eijVIrezr9AFRjLkmo5j_Jr7A"><nobr>People Magazine</nobr></a>&nb' +
'sp;- <a href="http://www.eonline.com/uberblog/b68226_hospital_run_cou' +
'ld_cost_britney_custody.html" id="s-kYt5LHDhlDnhUL9kRLuuwA:u-AFQjCNF8' +
'8eOy2utriYuF0icNrZQPzwK8gg"><nobr>E! Online</nobr></a>&nbsp;- <a href' +
'="http://justjared.buzznet.com/2008/11/11/britney-spears-alligator-fa' +
'rm/" id="s--VDy1fyacNvaRo_aXb02Dw:u-AFQjCNEn0Rz3wg0PMwDdzKTDug-9k5W6y' +
'g"><nobr>Just Jared</nobr></a>&nbsp;- <a href="http://www.efluxmedia.' +
'com/news_Britney_Spears_Son_Released_from_Hospital_28696.html" id="s-' +
'8oX6hVDe4Qbcl1x5Rua_EA:u-AFQjCNEpn3nOHA8EB0pxJAPf6diOicMRDg"><nobr>eF' +
'luxMedia</nobr></a></font><br><font class="p" size="-1"><a class="p" ' +
'href="http://news.google.com/news?ncl=1268233361&amp;hl=en"><nobr><b>' +
'all 950 news articles&nbsp;</b></nobr></a></font>';
// Select it all.
goog.dom.Range.createFromNodeContents(div).select();
expectedFailures.expectFailureFor(WEBKIT_BEFORE_CHROME_8,
'WebKit barfs apple-style-spans all over the place, and removes links.');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Leave links and images alone, remove all other formatting.
assertHTMLEquals('<br><br><a href="http://www.washingtonpost.com/wp-dyn/' +
'content/article/2008/11/11/AR2008111101090.html"><img src="http://n' +
'ews.google.com/news?imgefp=4LFiNNP62TgJ&amp;imgurl=media3.washingto' +
'npost.com/wp-dyn/content/photo/2008/11/11/PH2008111101091.jpg"><br>' +
'Washington Post</a><br><a href="http://www.nme.com/news/britney-spe' +
'ars/40995">Britney\'s son released from hospital</a><br>NME.com - 5' +
'3 minutes ago<br>Britney Spears youngest son Jayden James has been' +
' released from hospital, having been admitted on Sunday after suffe' +
'ring a severe reaction to something he ingested.<br><a href="http:/' +
'/www.celebrity-gossip.net/celebrities/hollywood/britney-and-jamie-l' +
'ynn-spears-alligator-alley-208944/">Britney and Jamie Lynn Spears: ' +
'Alligator Alley!</a> The Gossip Girls<br><a href="http://foodconsum' +
'er.org/7777/8888/Other_N_ews_51/111101362008_Allergy_incident_could' +
'_spell_custody_trouble_for_Britney_Spears.shtml">Allergy incident c' +
'ould spell trouble for Britney Spears</a> Food Consumer<br><a href=' +
'"http://www.people.com/people/article/0,,20239458,00.html">People M' +
'agazine</a> - <a href="http://www.eonline.com/uberblog/b68226_hospi' +
'tal_run_could_cost_britney_custody.html">E! Online</a> - <a href="h' +
'ttp://justjared.buzznet.com/2008/11/11/britney-spears-alligator-far' +
'm/">Just Jared</a> - <a href="http://www.efluxmedia.com/news_Britne' +
'y_Spears_Son_Released_from_Hospital_28696.html">eFluxMedia</a><br><' +
'a href="http://news.google.com/news?ncl=1268233361&amp;hl=en">all 9' +
'50 news articles </a>' +
insertImageFontGarbage, div.innerHTML);
FIELDMOCK.$verify();
});
}
function testRangeDelimitedByRanges() {
var abcde = goog.dom.getElement('abcde').firstChild;
var start = goog.dom.Range.createFromNodes(abcde, 1, abcde, 2);
var end = goog.dom.Range.createFromNodes(abcde, 3, abcde, 4);
goog.testing.dom.assertRangeEquals(abcde, 1, abcde, 4,
goog.editor.plugins.RemoveFormatting.createRangeDelimitedByRanges_(
start, end));
}
function testGetTableAncestor() {
var div = document.getElementById('html');
div.innerHTML = 'foo<table><tr><td>foo</td></tr></table>bar';
assertTrue('Full table is in table',
!!FORMATTER.getTableAncestor_(div.childNodes[1]));
assertFalse('Outside of table',
!!FORMATTER.getTableAncestor_(div.firstChild));
assertTrue('Table cell is in table',
!!FORMATTER.getTableAncestor_(
div.childNodes[1].firstChild.firstChild.firstChild));
div.innerHTML = 'foo';
assertNull('No table inside field.',
FORMATTER.getTableAncestor_(div.childNodes[0]));
}
/**
* @bug 1272905
*/
function testHardReturnsInHeadersPreserved() {
var div = document.getElementById('html');
div.innerHTML = '<h1>abcd</h1><h2>efgh</h2><h3>ijkl</h3>';
// Select efgh.
goog.dom.Range.createFromNodeContents(div.childNodes[1]).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.IE,
'Proper behavior not yet implemented for IE.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'Proper behavior not yet implemented for WebKit.');
expectedFailures.run(function() {
assertHTMLEquals('<h1>abcd</h1><br>efgh<h3>ijkl</h3>', div.innerHTML);
});
// Select ijkl.
goog.dom.Range.createFromNodeContents(div.lastChild).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.IE,
'Proper behavior not yet implemented for IE.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'Proper behavior not yet implemented for WebKit.');
expectedFailures.run(function() {
assertHTMLEquals('<h1>abcd</h1><br>efgh<br>ijkl', div.innerHTML);
});
// Select abcd.
goog.dom.Range.createFromNodeContents(div.firstChild).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.IE,
'Proper behavior not yet implemented for IE.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'Proper behavior not yet implemented for WebKit.');
expectedFailures.run(function() {
assertHTMLEquals('<br>abcd<br>efgh<br>ijkl', div.innerHTML);
});
}
function testKeyboardShortcut_space() {
FIELDMOCK.$reset();
FIELDMOCK.execCommand(
goog.editor.plugins.RemoveFormatting.REMOVE_FORMATTING_COMMAND);
FIELDMOCK.$replay();
var e = {};
var key = ' ';
var result = FORMATTER.handleKeyboardShortcut(e, key, true);
assertTrue(result);
FIELDMOCK.$verify();
}
function testKeyboardShortcut_other() {
FIELDMOCK.$reset();
FIELDMOCK.$replay();
var e = {};
var key = 'a';
var result = FORMATTER.handleKeyboardShortcut(e, key, true);
assertFalse(result);
FIELDMOCK.$verify();
}
@@ -0,0 +1,92 @@
// 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 Editor plugin to handle tab keys not in lists to add 4 spaces.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.SpacesTabHandler');
goog.require('goog.dom.TagName');
goog.require('goog.editor.plugins.AbstractTabHandler');
goog.require('goog.editor.range');
/**
* Plugin to handle tab keys when not in lists to add 4 spaces.
* @constructor
* @extends {goog.editor.plugins.AbstractTabHandler}
* @final
*/
goog.editor.plugins.SpacesTabHandler = function() {
goog.editor.plugins.AbstractTabHandler.call(this);
};
goog.inherits(goog.editor.plugins.SpacesTabHandler,
goog.editor.plugins.AbstractTabHandler);
/** @override */
goog.editor.plugins.SpacesTabHandler.prototype.getTrogClassId = function() {
return 'SpacesTabHandler';
};
/** @override */
goog.editor.plugins.SpacesTabHandler.prototype.handleTabKey = function(e) {
var dh = this.getFieldDomHelper();
var range = this.getFieldObject().getRange();
if (!goog.editor.range.intersectsTag(range, goog.dom.TagName.LI)) {
// In the shift + tab case we don't want to insert spaces, but we don't
// want focus to move either so skip the spacing logic and just prevent
// default.
if (!e.shiftKey) {
// Not in a list but we want to insert 4 spaces.
// Stop change events while we make multiple field changes.
this.getFieldObject().stopChangeEvents(true, true);
// Inserting nodes below completely messes up the selection, doing the
// deletion here before it's messed up. Only delete if text is selected,
// otherwise we would remove the character to the right of the cursor.
if (!range.isCollapsed()) {
dh.getDocument().execCommand('delete', false, null);
// Safari 3 has some DOM exceptions if we don't reget the range here,
// doing it all the time just to be safe.
range = this.getFieldObject().getRange();
}
// Emulate tab by removing selection and inserting 4 spaces
// Two breaking spaces in a row can be collapsed by the browser into one
// space. Inserting the string below because it is guaranteed to never
// collapse to less than four spaces, regardless of what is adjacent to
// the inserted spaces. This might make line wrapping slightly
// sub-optimal around a grouping of non-breaking spaces.
var elem = dh.createDom('span', null, '\u00a0\u00a0 \u00a0');
elem = range.insertNode(elem, false);
this.getFieldObject().dispatchChange();
goog.editor.range.placeCursorNextTo(elem, false);
this.getFieldObject().dispatchSelectionChangeEvent();
}
e.preventDefault();
return true;
}
return false;
};
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<!--
@author robbyw@google.com (Robby Walker)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.SpacesTabHandler
</title>
<script src="../../base.js">
</script>
<script src="../../deps.js">
</script>
<script>
goog.require('goog.editor.plugins.SpacesTabHandlerTest');
</script>
</head>
<body>
<div id="field">
</div>
</body>
</html>
@@ -0,0 +1,174 @@
// 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.
goog.provide('goog.editor.plugins.SpacesTabHandlerTest');
goog.setTestOnly('goog.editor.plugins.SpacesTabHandlerTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.editor.plugins.SpacesTabHandler');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.KeyCodes');
goog.require('goog.functions');
goog.require('goog.testing.StrictMock');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
var field;
var editableField;
var tabHandler;
var testHelper;
function setUp() {
field = goog.dom.getElement('field');
editableField = new goog.testing.editor.FieldMock();
// Modal mode behavior tested in AbstractTabHandler.
editableField.inModalMode = goog.functions.FALSE;
testHelper = new goog.testing.editor.TestHelper(field);
testHelper.setUpEditableElement();
tabHandler = new goog.editor.plugins.SpacesTabHandler();
tabHandler.registerFieldObject(editableField);
}
function tearDown() {
editableField = null;
testHelper.tearDownEditableElement();
tabHandler.dispose();
}
function testSelectedTextIndent() {
field.innerHTML = 'Test';
var testText = field.firstChild;
testHelper.select(testText, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.stopChangeEvents(true, true);
editableField.dispatchChange();
editableField.dispatchSelectionChangeEvent();
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event marked as handled',
tabHandler.handleKeyboardShortcut(event, '', false));
var contents = field.textContent || field.innerText;
// Chrome doesn't treat \u00a0 as a space.
assertTrue('Text should be replaced with 4 spaces but was: "' +
contents + '"',
/^(\s|\u00a0){4}$/.test(contents));
editableField.$verify();
event.$verify();
}
function testCursorIndent() {
field.innerHTML = 'Test';
var testText = field.firstChild;
testHelper.select(testText, 2, testText, 2);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.stopChangeEvents(true, true);
editableField.dispatchChange();
editableField.dispatchSelectionChangeEvent();
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event marked as handled',
tabHandler.handleKeyboardShortcut(event, '', false));
var contents = field.textContent || field.innerText;
assertTrue('Expected contents "Te st" but was: "' + contents + '"',
/Te[\s|\u00a0]{4}st/.test(contents));
editableField.$verify();
event.$verify();
}
function testShiftTabNoOp() {
field.innerHTML = 'Test';
range = goog.dom.Range.createFromNodeContents(field);
range.collapse();
range.select();
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = true;
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event marked as handled',
tabHandler.handleKeyboardShortcut(event, '', false));
var contents = field.textContent || field.innerText;
assertEquals('Shift+tab should not change contents', 'Test', contents);
editableField.$verify();
event.$verify();
}
function testInListNoOp() {
field.innerHTML = '<ul><li>Test</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(testText, 2, testText, 2);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.$replay();
event.$replay();
assertFalse('Event must not be handled when selection inside list.',
tabHandler.handleKeyboardShortcut(event, '', false));
testHelper.assertHtmlMatches('<ul><li>Test</li></ul>');
editableField.$verify();
event.$verify();
}
function testContainsListNoOp() {
field.innerHTML = '<ul><li>Test</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(field.firstChild, 0, testText, 2);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.$replay();
event.$replay();
assertFalse('Event must not be handled when selection inside list.',
tabHandler.handleKeyboardShortcut(event, '', false));
testHelper.assertHtmlMatches('<ul><li>Test</li></ul>');
editableField.$verify();
event.$verify();
}
@@ -0,0 +1,475 @@
// 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 that enables table editing.
*
* @see ../../demos/editor/tableeditor.html
*/
goog.provide('goog.editor.plugins.TableEditor');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.Table');
goog.require('goog.editor.node');
goog.require('goog.editor.range');
goog.require('goog.object');
goog.require('goog.userAgent');
/**
* Plugin that adds support for table creation and editing commands.
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.TableEditor = function() {
goog.editor.plugins.TableEditor.base(this, 'constructor');
/**
* The array of functions that decide whether a table element could be
* editable by the user or not.
* @type {Array<function(Element):boolean>}
* @private
*/
this.isTableEditableFunctions_ = [];
/**
* The pre-bound function that decides whether a table element could be
* editable by the user or not overall.
* @type {function(Node):boolean}
* @private
*/
this.isUserEditableTableBound_ = goog.bind(this.isUserEditableTable_, this);
};
goog.inherits(goog.editor.plugins.TableEditor, goog.editor.Plugin);
/** @override */
// TODO(user): remove this once there's a sensible default
// implementation in the base Plugin.
goog.editor.plugins.TableEditor.prototype.getTrogClassId = function() {
return String(goog.getUid(this.constructor));
};
/**
* Commands supported by goog.editor.plugins.TableEditor.
* @enum {string}
*/
goog.editor.plugins.TableEditor.COMMAND = {
TABLE: '+table',
INSERT_ROW_AFTER: '+insertRowAfter',
INSERT_ROW_BEFORE: '+insertRowBefore',
INSERT_COLUMN_AFTER: '+insertColumnAfter',
INSERT_COLUMN_BEFORE: '+insertColumnBefore',
REMOVE_ROWS: '+removeRows',
REMOVE_COLUMNS: '+removeColumns',
SPLIT_CELL: '+splitCell',
MERGE_CELLS: '+mergeCells',
REMOVE_TABLE: '+removeTable'
};
/**
* Inverse map of execCommand strings to
* {@link goog.editor.plugins.TableEditor.COMMAND} constants. Used to
* determine whether a string corresponds to a command this plugin handles
* in O(1) time.
* @type {Object}
* @private
*/
goog.editor.plugins.TableEditor.SUPPORTED_COMMANDS_ =
goog.object.transpose(goog.editor.plugins.TableEditor.COMMAND);
/**
* Whether the string corresponds to a command this plugin handles.
* @param {string} command Command string to check.
* @return {boolean} Whether the string corresponds to a command
* this plugin handles.
* @override
*/
goog.editor.plugins.TableEditor.prototype.isSupportedCommand =
function(command) {
return command in goog.editor.plugins.TableEditor.SUPPORTED_COMMANDS_;
};
/** @override */
goog.editor.plugins.TableEditor.prototype.enable = function(fieldObject) {
goog.editor.plugins.TableEditor.base(this, 'enable', fieldObject);
// enableObjectResizing is supported only for Gecko.
// You can refer to http://qooxdoo.org/contrib/project/htmlarea/html_editing
// for a compatibility chart.
if (goog.userAgent.GECKO) {
var doc = this.getFieldDomHelper().getDocument();
doc.execCommand('enableObjectResizing', false, 'true');
}
};
/**
* Returns the currently selected table.
* @return {Element?} The table in which the current selection is
* contained, or null if there isn't such a table.
* @private
*/
goog.editor.plugins.TableEditor.prototype.getCurrentTable_ = function() {
var selectedElement = this.getFieldObject().getRange().getContainer();
return this.getAncestorTable_(selectedElement);
};
/**
* Finds the first user-editable table element in the input node's ancestors.
* @param {Node?} node The node to start with.
* @return {Element?} The table element that is closest ancestor of the node.
* @private
*/
goog.editor.plugins.TableEditor.prototype.getAncestorTable_ = function(node) {
var ancestor = goog.dom.getAncestor(node, this.isUserEditableTableBound_,
true);
if (goog.editor.node.isEditable(ancestor)) {
return /** @type {Element?} */(ancestor);
} else {
return null;
}
};
/**
* Returns the current value of a given command. Currently this plugin
* only returns a value for goog.editor.plugins.TableEditor.COMMAND.TABLE.
* @override
*/
goog.editor.plugins.TableEditor.prototype.queryCommandValue =
function(command) {
if (command == goog.editor.plugins.TableEditor.COMMAND.TABLE) {
return !!this.getCurrentTable_();
}
};
/** @override */
goog.editor.plugins.TableEditor.prototype.execCommandInternal = function(
command, opt_arg) {
var result = null;
// TD/TH in which to place the cursor, if the command destroys the current
// cursor position.
var cursorCell = null;
var range = this.getFieldObject().getRange();
if (command == goog.editor.plugins.TableEditor.COMMAND.TABLE) {
// Don't create a table if the cursor isn't in an editable region.
if (!goog.editor.range.isEditable(range)) {
return null;
}
// Create the table.
var tableProps = opt_arg || {width: 4, height: 2};
var doc = this.getFieldDomHelper().getDocument();
var table = goog.editor.Table.createDomTable(
doc, tableProps.width, tableProps.height);
range.replaceContentsWithNode(table);
// In IE, replaceContentsWithNode uses pasteHTML, so we lose our reference
// to the inserted table.
// TODO(user): use the reference to the table element returned from
// replaceContentsWithNode.
if (!goog.userAgent.IE) {
cursorCell = table.getElementsByTagName('td')[0];
}
} else {
var cellSelection = new goog.editor.plugins.TableEditor.CellSelection_(
range, goog.bind(this.getAncestorTable_, this));
var table = cellSelection.getTable();
if (!table) {
return null;
}
switch (command) {
case goog.editor.plugins.TableEditor.COMMAND.INSERT_ROW_BEFORE:
table.insertRow(cellSelection.getFirstRowIndex());
break;
case goog.editor.plugins.TableEditor.COMMAND.INSERT_ROW_AFTER:
table.insertRow(cellSelection.getLastRowIndex() + 1);
break;
case goog.editor.plugins.TableEditor.COMMAND.INSERT_COLUMN_BEFORE:
table.insertColumn(cellSelection.getFirstColumnIndex());
break;
case goog.editor.plugins.TableEditor.COMMAND.INSERT_COLUMN_AFTER:
table.insertColumn(cellSelection.getLastColumnIndex() + 1);
break;
case goog.editor.plugins.TableEditor.COMMAND.REMOVE_ROWS:
var startRow = cellSelection.getFirstRowIndex();
var endRow = cellSelection.getLastRowIndex();
if (startRow == 0 && endRow == (table.rows.length - 1)) {
// Instead of deleting all rows, delete the entire table.
return this.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_TABLE);
}
var startColumn = cellSelection.getFirstColumnIndex();
var rowCount = (endRow - startRow) + 1;
for (var i = 0; i < rowCount; i++) {
table.removeRow(startRow);
}
if (table.rows.length > 0) {
// Place cursor in the previous/first row.
var closestRow = Math.min(startRow, table.rows.length - 1);
cursorCell = table.rows[closestRow].columns[startColumn].element;
}
break;
case goog.editor.plugins.TableEditor.COMMAND.REMOVE_COLUMNS:
var startCol = cellSelection.getFirstColumnIndex();
var endCol = cellSelection.getLastColumnIndex();
if (startCol == 0 && endCol == (table.rows[0].columns.length - 1)) {
// Instead of deleting all columns, delete the entire table.
return this.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_TABLE);
}
var startRow = cellSelection.getFirstRowIndex();
var removeCount = (endCol - startCol) + 1;
for (var i = 0; i < removeCount; i++) {
table.removeColumn(startCol);
}
var currentRow = table.rows[startRow];
if (currentRow) {
// Place cursor in the previous/first column.
var closestCol = Math.min(startCol, currentRow.columns.length - 1);
cursorCell = currentRow.columns[closestCol].element;
}
break;
case goog.editor.plugins.TableEditor.COMMAND.MERGE_CELLS:
if (cellSelection.isRectangle()) {
table.mergeCells(cellSelection.getFirstRowIndex(),
cellSelection.getFirstColumnIndex(),
cellSelection.getLastRowIndex(),
cellSelection.getLastColumnIndex());
}
break;
case goog.editor.plugins.TableEditor.COMMAND.SPLIT_CELL:
if (cellSelection.containsSingleCell()) {
table.splitCell(cellSelection.getFirstRowIndex(),
cellSelection.getFirstColumnIndex());
}
break;
case goog.editor.plugins.TableEditor.COMMAND.REMOVE_TABLE:
table.element.parentNode.removeChild(table.element);
break;
default:
}
}
if (cursorCell) {
range = goog.dom.Range.createFromNodeContents(cursorCell);
range.collapse(false);
range.select();
}
return result;
};
/**
* Checks whether the element is a table editable by the user.
* @param {Node} element The element in question.
* @return {boolean} Whether the element is a table editable by the user.
* @private
*/
goog.editor.plugins.TableEditor.prototype.isUserEditableTable_ =
function(element) {
// Default implementation.
if (element.tagName != goog.dom.TagName.TABLE) {
return false;
}
// Check for extra user-editable filters.
return goog.array.every(this.isTableEditableFunctions_, function(func) {
return func(/** @type {Element} */ (element));
});
};
/**
* Adds a function to filter out non-user-editable tables.
* @param {function(Element):boolean} func A function to decide whether the
* table element could be editable by the user or not.
*/
goog.editor.plugins.TableEditor.prototype.addIsTableEditableFunction =
function(func) {
goog.array.insert(this.isTableEditableFunctions_, func);
};
/**
* Class representing the selected cell objects within a single table.
* @param {goog.dom.AbstractRange} range Selected range from which to calculate
* selected cells.
* @param {function(Element):Element?} getParentTableFunction A function that
* finds the user-editable table from a given element.
* @constructor
* @private
*/
goog.editor.plugins.TableEditor.CellSelection_ =
function(range, getParentTableFunction) {
this.cells_ = [];
// Mozilla lets users select groups of cells, with each cell showing
// up as a separate range in the selection. goog.dom.Range doesn't
// currently support this.
// TODO(user): support this case in range.js
var selectionContainer = range.getContainerElement();
var elementInSelection = function(node) {
// TODO(user): revert to the more liberal containsNode(node, true),
// which will match partially-selected cells. We're using
// containsNode(node, false) at the moment because otherwise it's
// broken in WebKit due to a closure range bug.
return selectionContainer == node ||
selectionContainer.parentNode == node ||
range.containsNode(node, false);
};
var parentTableElement = selectionContainer &&
getParentTableFunction(selectionContainer);
if (!parentTableElement) {
return;
}
var parentTable = new goog.editor.Table(parentTableElement);
// It's probably not possible to select a table with no cells, but
// do a sanity check anyway.
if (!parentTable.rows.length || !parentTable.rows[0].columns.length) {
return;
}
// Loop through cells to calculate dimensions for this CellSelection.
for (var i = 0, row; row = parentTable.rows[i]; i++) {
for (var j = 0, cell; cell = row.columns[j]; j++) {
if (elementInSelection(cell.element)) {
// Update dimensions based on cell.
if (!this.cells_.length) {
this.firstRowIndex_ = cell.startRow;
this.lastRowIndex_ = cell.endRow;
this.firstColIndex_ = cell.startCol;
this.lastColIndex_ = cell.endCol;
} else {
this.firstRowIndex_ = Math.min(this.firstRowIndex_, cell.startRow);
this.lastRowIndex_ = Math.max(this.lastRowIndex_, cell.endRow);
this.firstColIndex_ = Math.min(this.firstColIndex_, cell.startCol);
this.lastColIndex_ = Math.max(this.lastColIndex_, cell.endCol);
}
this.cells_.push(cell);
}
}
}
this.parentTable_ = parentTable;
};
/**
* Returns the EditableTable object of which this selection's cells are a
* subset.
* @return {!goog.editor.Table} the table.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getTable =
function() {
return this.parentTable_;
};
/**
* Returns the row index of the uppermost cell in this selection.
* @return {number} The row index.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getFirstRowIndex =
function() {
return this.firstRowIndex_;
};
/**
* Returns the row index of the lowermost cell in this selection.
* @return {number} The row index.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getLastRowIndex =
function() {
return this.lastRowIndex_;
};
/**
* Returns the column index of the farthest left cell in this selection.
* @return {number} The column index.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getFirstColumnIndex =
function() {
return this.firstColIndex_;
};
/**
* Returns the column index of the farthest right cell in this selection.
* @return {number} The column index.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getLastColumnIndex =
function() {
return this.lastColIndex_;
};
/**
* Returns the cells in this selection.
* @return {!Array<Element>} Cells in this selection.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getCells = function() {
return this.cells_;
};
/**
* Returns a boolean value indicating whether or not the cells in this
* selection form a rectangle.
* @return {boolean} Whether the selection forms a rectangle.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.isRectangle =
function() {
// TODO(user): check for missing cells. Right now this returns
// whether all cells in the selection are in the rectangle, but doesn't
// verify that every expected cell is present.
if (!this.cells_.length) {
return false;
}
var firstCell = this.cells_[0];
var lastCell = this.cells_[this.cells_.length - 1];
return !(this.firstRowIndex_ < firstCell.startRow ||
this.lastRowIndex_ > lastCell.endRow ||
this.firstColIndex_ < firstCell.startCol ||
this.lastColIndex_ > lastCell.endCol);
};
/**
* Returns a boolean value indicating whether or not there is exactly
* one cell in this selection. Note that this may not be the same as checking
* whether getCells().length == 1; if there is a single cell with
* rowSpan/colSpan set it will appear multiple times.
* @return {boolean} Whether there is exatly one cell in this selection.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.containsSingleCell =
function() {
var cellCount = this.cells_.length;
return cellCount > 0 &&
(this.cells_[0] == this.cells_[cellCount - 1]);
};
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<!--
Copyright 2009 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.TableEditor Tests
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.TableEditorTest');
</script>
</head>
<body>
<div id="field">
<div>
lorem ipsum
</div>
<div>
ipsum lorem
</div>
</div>
</body>
</html>
@@ -0,0 +1,303 @@
// Copyright 2009 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.
goog.provide('goog.editor.plugins.TableEditorTest');
goog.setTestOnly('goog.editor.plugins.TableEditorTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.editor.plugins.TableEditor');
goog.require('goog.object');
goog.require('goog.string');
goog.require('goog.testing.ExpectedFailures');
goog.require('goog.testing.JsUnitException');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var field;
var plugin;
var fieldMock;
var expectedFailures;
var testHelper;
function setUpPage() {
field = goog.dom.getElement('field');
expectedFailures = new goog.testing.ExpectedFailures();
}
function setUp() {
testHelper = new goog.testing.editor.TestHelper(
goog.dom.getElement('field'));
testHelper.setUpEditableElement();
field.focus();
plugin = new goog.editor.plugins.TableEditor();
fieldMock = new goog.testing.editor.FieldMock();
plugin.registerFieldObject(fieldMock);
if (goog.userAgent.IE &&
(goog.userAgent.compare(goog.userAgent.VERSION, '7.0') >= 0)) {
goog.testing.TestCase.protectedTimeout_ = window.setTimeout;
}
}
function tearDown() {
testHelper.tearDownEditableElement();
expectedFailures.handleTearDown();
}
function testEnable() {
fieldMock.$replay();
plugin.enable(fieldMock);
assertTrue('Plugin should be enabled', plugin.isEnabled(fieldMock));
if (goog.userAgent.GECKO) {
// This code path is executed only for GECKO browsers but we can't
// verify it because of a GECKO bug while reading the value of the
// command "enableObjectResizing".
// See https://bugzilla.mozilla.org/show_bug.cgi?id=506368
expectedFailures.expectFailureFor(goog.userAgent.GECKO);
try {
var doc = plugin.getFieldDomHelper().getDocument();
assertTrue('Object resizing should be enabled',
doc.queryCommandValue('enableObjectResizing'));
} catch (e) {
// We need to marshal our exception in order for it to be handled
// properly.
expectedFailures.handleException(new goog.testing.JsUnitException(e));
}
}
fieldMock.$verify();
}
function testIsSupportedCommand() {
goog.object.forEach(goog.editor.plugins.TableEditor.COMMAND,
function(command) {
assertTrue(goog.string.subs('Plugin should support %s', command),
plugin.isSupportedCommand(command));
});
assertFalse('Plugin shouldn\'t support a bogus command',
plugin.isSupportedCommand('+fable'));
}
function testCreateTable() {
fieldMock.$replay();
createTableAndSelectCell();
var table = plugin.getCurrentTable_();
assertNotNull('Table should not be null', table);
assertEquals('Table should have the default number of rows',
2,
table.rows.length);
assertEquals('Table should have the default number of cells',
8,
getCellCount(table));
fieldMock.$verify();
}
function testInsertRowBefore() {
fieldMock.$replay();
createTableAndSelectCell();
var table = plugin.getCurrentTable_();
var selectedRow = fieldMock.getRange().getContainerElement().parentNode;
assertNull('Selected row shouldn\'t have a previous sibling',
selectedRow.previousSibling);
assertEquals('Table should have two rows', 2, table.rows.length);
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.INSERT_ROW_BEFORE);
assertEquals('A row should have been inserted', 3, table.rows.length);
// Assert that we inserted a row above the currently selected row.
assertNotNull('Selected row should have a previous sibling',
selectedRow.previousSibling);
fieldMock.$verify();
}
function testInsertRowAfter() {
fieldMock.$replay();
createTableAndSelectCell({width: 2, height: 1});
var selectedRow = fieldMock.getRange().getContainerElement().parentNode;
var table = plugin.getCurrentTable_();
assertEquals('Table should have one row', 1, table.rows.length);
assertNull('Selected row shouldn\'t have a next sibling',
selectedRow.nextSibling);
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.INSERT_ROW_AFTER);
assertEquals('A row should have been inserted', 2, table.rows.length);
// Assert that we inserted a row after the currently selected row.
assertNotNull('Selected row should have a next sibling',
selectedRow.nextSibling);
fieldMock.$verify();
}
function testInsertColumnBefore() {
fieldMock.$replay();
createTableAndSelectCell({width: 1, height: 1});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
assertEquals('Table should have one cell', 1, getCellCount(table));
assertNull('Selected cell shouldn\'t have a previous sibling',
selectedCell.previousSibling);
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.INSERT_COLUMN_BEFORE);
assertEquals('A cell should have been inserted', 2, getCellCount(table));
assertNotNull('Selected cell should have a previous sibling',
selectedCell.previousSibling);
fieldMock.$verify();
}
function testInsertColumnAfter() {
fieldMock.$replay();
createTableAndSelectCell({width: 1, height: 1});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
assertEquals('Table should have one cell', 1, getCellCount(table));
assertNull('Selected cell shouldn\'t have a next sibling',
selectedCell.nextSibling);
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.INSERT_COLUMN_AFTER);
assertEquals('A cell should have been inserted', 2, getCellCount(table));
assertNotNull('Selected cell should have a next sibling',
selectedCell.nextSibling);
fieldMock.$verify();
}
function testRemoveRows() {
fieldMock.$replay();
createTableAndSelectCell({width: 1, height: 2});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
selectedCell.id = 'selected';
assertEquals('Table should have two rows', 2, table.rows.length);
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_ROWS);
assertEquals('A row should have been removed', 1, table.rows.length);
assertNull('The correct row should have been removed',
goog.dom.getElement('selected'));
// Verify that the table is removed if we don't have any rows.
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_ROWS);
assertEquals('The table should have been removed',
0,
field.getElementsByTagName('table').length);
fieldMock.$verify();
}
function testRemoveColumns() {
fieldMock.$replay();
createTableAndSelectCell({width: 2, height: 1});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
selectedCell.id = 'selected';
assertEquals('Table should have two cells', 2, getCellCount(table));
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_COLUMNS);
assertEquals('A cell should have been removed', 1, getCellCount(table));
assertNull('The correct cell should have been removed',
goog.dom.getElement('selected'));
// Verify that the table is removed if we don't have any columns.
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_COLUMNS);
assertEquals('The table should have been removed',
0,
field.getElementsByTagName('table').length);
fieldMock.$verify();
}
function testSplitCell() {
fieldMock.$replay();
createTableAndSelectCell({width: 1, height: 1});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
// Splitting is only supported if we set these attributes.
selectedCell.rowSpan = '1';
selectedCell.colSpan = '2';
selectedCell.innerHTML = 'foo';
goog.dom.Range.createFromNodeContents(selectedCell).select();
assertEquals('Table should have one cell', 1, getCellCount(table));
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.SPLIT_CELL);
assertEquals('The cell should have been split', 2, getCellCount(table));
assertEquals('The cell content should be intact',
'foo',
selectedCell.innerHTML);
assertNotNull('The new cell should be inserted before',
selectedCell.previousSibling);
fieldMock.$verify();
}
function testMergeCells() {
fieldMock.$replay();
createTableAndSelectCell({width: 2, height: 1});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
selectedCell.innerHTML = 'foo';
selectedCell.nextSibling.innerHTML = 'bar';
var range = goog.dom.Range.createFromNodeContents(
table.getElementsByTagName('tr')[0]);
range.select();
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.MERGE_CELLS);
expectedFailures.expectFailureFor(
goog.userAgent.IE &&
goog.userAgent.isVersionOrHigher('8'));
try {
// In IE8, even after explicitly setting the range to span
// multiple cells, the browser selection only contains the first TD
// which causes the merge operation to fail.
assertEquals('The cells should be merged', 1, getCellCount(table));
assertEquals('The cell should have expected colspan',
2,
selectedCell.colSpan);
assertHTMLEquals('The content should be merged',
'foo bar',
selectedCell.innerHTML);
} catch (e) {
expectedFailures.handleException(e);
}
fieldMock.$verify();
}
/**
* Helper routine which returns the number of cells in the table.
*
* @param {Element} table The table in question.
* @return {number} Number of cells.
*/
function getCellCount(table) {
return table.cells ? table.cells.length :
table.rows[0].cells.length * table.rows.length;
}
/**
* Helper method which creates a table and puts the cursor on the first TD.
* In IE, the cursor isn't positioned in the first cell (TD) and we simulate
* that behavior explicitly to be consistent across all browsers.
*
* @param {Object} op_tableProps Optional table properties.
*/
function createTableAndSelectCell(opt_tableProps) {
goog.dom.Range.createCaret(field, 1).select();
plugin.execCommandInternal(goog.editor.plugins.TableEditor.COMMAND.TABLE,
opt_tableProps);
if (goog.userAgent.IE) {
var range = goog.dom.Range.createFromNodeContents(
field.getElementsByTagName('td')[0]);
range.select();
}
}
@@ -0,0 +1,744 @@
// 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 TrogEdit plugin to handle enter keys by inserting the
* specified block level tag.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.TagOnEnterHandler');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Command');
goog.require('goog.editor.node');
goog.require('goog.editor.plugins.EnterHandler');
goog.require('goog.editor.range');
goog.require('goog.editor.style');
goog.require('goog.events.KeyCodes');
goog.require('goog.functions');
goog.require('goog.string.Unicode');
goog.require('goog.style');
goog.require('goog.userAgent');
/**
* Plugin to handle enter keys. This subclass normalizes all browsers to use
* the given block tag on enter.
* @param {goog.dom.TagName} tag The type of tag to add on enter.
* @constructor
* @extends {goog.editor.plugins.EnterHandler}
*/
goog.editor.plugins.TagOnEnterHandler = function(tag) {
this.tag = tag;
goog.editor.plugins.EnterHandler.call(this);
};
goog.inherits(goog.editor.plugins.TagOnEnterHandler,
goog.editor.plugins.EnterHandler);
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.getTrogClassId = function() {
return 'TagOnEnterHandler';
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.getNonCollapsingBlankHtml =
function() {
if (this.tag == goog.dom.TagName.P) {
return '<p>&nbsp;</p>';
} else if (this.tag == goog.dom.TagName.DIV) {
return '<div><br></div>';
}
return '<br>';
};
/**
* This plugin is active on uneditable fields so it can provide a value for
* queryCommandValue calls asking for goog.editor.Command.BLOCKQUOTE.
* @return {boolean} True.
* @override
*/
goog.editor.plugins.TagOnEnterHandler.prototype.activeOnUneditableFields =
goog.functions.TRUE;
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.isSupportedCommand = function(
command) {
return command == goog.editor.Command.DEFAULT_TAG;
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.queryCommandValue = function(
command) {
return command == goog.editor.Command.DEFAULT_TAG ? this.tag : null;
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.handleBackspaceInternal =
function(e, range) {
goog.editor.plugins.TagOnEnterHandler.superClass_.handleBackspaceInternal.
call(this, e, range);
if (goog.userAgent.GECKO) {
this.markBrToNotBeRemoved_(range, true);
}
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.processParagraphTagsInternal =
function(e, split) {
if ((goog.userAgent.OPERA || goog.userAgent.IE) &&
this.tag != goog.dom.TagName.P) {
this.ensureBlockIeOpera(this.tag);
}
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.handleDeleteGecko = function(
e) {
var range = this.getFieldObject().getRange();
var container = goog.editor.style.getContainer(
range && range.getContainerElement());
if (this.getFieldObject().getElement().lastChild == container &&
goog.editor.plugins.EnterHandler.isBrElem(container)) {
// Don't delete if it's the last node in the field and just has a BR.
e.preventDefault();
// TODO(user): I think we probably don't need to stopPropagation here
e.stopPropagation();
} else {
// Go ahead with deletion.
// Prevent an existing BR immediately following the selection being deleted
// from being removed in the keyup stage (as opposed to a BR added by FF
// after deletion, which we do remove).
this.markBrToNotBeRemoved_(range, false);
// Manually delete the selection if it's at a BR.
this.deleteBrGecko(e);
}
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.handleKeyUpInternal = function(
e) {
if (goog.userAgent.GECKO) {
if (e.keyCode == goog.events.KeyCodes.DELETE) {
this.removeBrIfNecessary_(false);
} else if (e.keyCode == goog.events.KeyCodes.BACKSPACE) {
this.removeBrIfNecessary_(true);
}
} else if ((goog.userAgent.IE || goog.userAgent.OPERA) &&
e.keyCode == goog.events.KeyCodes.ENTER) {
this.ensureBlockIeOpera(this.tag, true);
}
// Safari uses DIVs by default.
};
/**
* String that matches a single BR tag or NBSP surrounded by non-breaking
* whitespace
* @type {string}
* @private
*/
goog.editor.plugins.TagOnEnterHandler.BrOrNbspSurroundedWithWhiteSpace_ =
'[\t\n\r ]*(<br[^>]*\/?>|&nbsp;)[\t\n\r ]*';
/**
* String that matches a single BR tag or NBSP surrounded by non-breaking
* whitespace
* @type {RegExp}
* @private
*/
goog.editor.plugins.TagOnEnterHandler.emptyLiRegExp_ = new RegExp('^' +
goog.editor.plugins.TagOnEnterHandler.BrOrNbspSurroundedWithWhiteSpace_ +
'$');
/**
* Ensures the current node is wrapped in the tag.
* @param {Node} node The node to ensure gets wrapped.
* @param {Element} container Element containing the selection.
* @return {Element} Element containing the selection, after the wrapping.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.ensureNodeIsWrappedW3c_ =
function(node, container) {
if (container == this.getFieldObject().getElement()) {
// If the first block-level ancestor of cursor is the field,
// don't split the tree. Find all the text from the cursor
// to both block-level elements surrounding it (if they exist)
// and split the text into two elements.
// This is the IE contentEditable behavior.
// The easy way to do this is to wrap all the text in an element
// and then split the element as if the user had hit enter
// in the paragraph
// However, simply wrapping the text into an element creates problems
// if the text was already wrapped using some other element such as an
// anchor. For example, wrapping the text of
// <a href="">Text</a>
// would produce
// <a href=""><p>Text</p></a>
// which is not what we want. What we really want is
// <p><a href="">Text</a></p>
// So we need to search for an ancestor of position.node to be wrapped.
// We do this by iterating up the hierarchy of postiion.node until we've
// reached the node that's just under the container.
var isChildOfFn = function(child) {
return container == child.parentNode; };
var nodeToWrap = goog.dom.getAncestor(node, isChildOfFn, true);
container = goog.editor.plugins.TagOnEnterHandler.wrapInContainerW3c_(
this.tag, {node: nodeToWrap, offset: 0}, container);
}
return container;
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.handleEnterWebkitInternal =
function(e) {
if (this.tag == goog.dom.TagName.DIV) {
var range = this.getFieldObject().getRange();
var container =
goog.editor.style.getContainer(range.getContainerElement());
var position = goog.editor.range.getDeepEndPoint(range, true);
container = this.ensureNodeIsWrappedW3c_(position.node, container);
goog.dom.Range.createCaret(position.node, position.offset).select();
}
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.
handleEnterAtCursorGeckoInternal = function(e, wasCollapsed, range) {
// We use this because there are a few cases where FF default
// implementation doesn't follow IE's:
// -Inserts BRs into empty elements instead of NBSP which has nasty
// side effects w/ making/deleting selections
// -Hitting enter when your cursor is in the field itself. IE will
// create two elements. FF just inserts a BR.
// -Hitting enter inside an empty list-item doesn't create a block
// tag. It just splits the list and puts your cursor in the middle.
var li = null;
if (wasCollapsed) {
// Only break out of lists for collapsed selections.
li = goog.dom.getAncestorByTagNameAndClass(
range && range.getContainerElement(), goog.dom.TagName.LI);
}
var isEmptyLi = (li &&
li.innerHTML.match(
goog.editor.plugins.TagOnEnterHandler.emptyLiRegExp_));
var elementAfterCursor = isEmptyLi ?
this.breakOutOfEmptyListItemGecko_(li) :
this.handleRegularEnterGecko_();
// Move the cursor in front of "nodeAfterCursor", and make sure it
// is visible
this.scrollCursorIntoViewGecko_(elementAfterCursor);
// Fix for http://b/1991234 :
if (goog.editor.plugins.EnterHandler.isBrElem(elementAfterCursor)) {
// The first element in the new line is a line with just a BR and maybe some
// whitespace.
// Calling normalize() is needed because there might be empty text nodes
// before BR and empty text nodes cause the cursor position bug in Firefox.
// See http://b/5220858
elementAfterCursor.normalize();
var br = elementAfterCursor.getElementsByTagName(goog.dom.TagName.BR)[0];
if (br.previousSibling &&
br.previousSibling.nodeType == goog.dom.NodeType.TEXT) {
// If there is some whitespace before the BR, don't put the selection on
// the BR, put it in the text node that's there, otherwise when you type
// it will create adjacent text nodes.
elementAfterCursor = br.previousSibling;
}
}
goog.editor.range.selectNodeStart(elementAfterCursor);
e.preventDefault();
// TODO(user): I think we probably don't need to stopPropagation here
e.stopPropagation();
};
/**
* If The cursor is in an empty LI then break out of the list like in IE
* @param {Node} li LI to break out of.
* @return {!Element} Element to put the cursor after.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.breakOutOfEmptyListItemGecko_ =
function(li) {
// Do this as follows:
// 1. <ul>...<li>&nbsp;</li>...</ul>
// 2. <ul id='foo1'>...<li id='foo2'>&nbsp;</li>...</ul>
// 3. <ul id='foo1'>...</ul><p id='foo3'>&nbsp;</p><ul id='foo2'>...</ul>
// 4. <ul>...</ul><p>&nbsp;</p><ul>...</ul>
//
// There are a couple caveats to the above. If the UL is contained in
// a list, then the new node inserted is an LI, not a P.
// For an OL, it's all the same, except the tagname of course.
// Finally, it's possible that with the LI at the beginning or the end
// of the list that we'll end up with an empty list. So we special case
// those cases.
var listNode = li.parentNode;
var grandparent = listNode.parentNode;
var inSubList = grandparent.tagName == goog.dom.TagName.OL ||
grandparent.tagName == goog.dom.TagName.UL;
// TODO(robbyw): Should we apply the list or list item styles to the new node?
var newNode = goog.dom.getDomHelper(li).createElement(
inSubList ? goog.dom.TagName.LI : this.tag);
if (!li.previousSibling) {
goog.dom.insertSiblingBefore(newNode, listNode);
} else {
if (li.nextSibling) {
var listClone = listNode.cloneNode(false);
while (li.nextSibling) {
listClone.appendChild(li.nextSibling);
}
goog.dom.insertSiblingAfter(listClone, listNode);
}
goog.dom.insertSiblingAfter(newNode, listNode);
}
if (goog.editor.node.isEmpty(listNode)) {
goog.dom.removeNode(listNode);
}
goog.dom.removeNode(li);
newNode.innerHTML = '&nbsp;';
return newNode;
};
/**
* Wrap the text indicated by "position" in an HTML container of type
* "nodeName".
* @param {string} nodeName Type of container, e.g. "p" (paragraph).
* @param {Object} position The W3C cursor position object
* (from getCursorPositionW3c).
* @param {Node} container The field containing position.
* @return {!Element} The container element that holds the contents from
* position.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.wrapInContainerW3c_ = function(nodeName,
position, container) {
var start = position.node;
while (start.previousSibling &&
!goog.editor.style.isContainer(start.previousSibling)) {
start = start.previousSibling;
}
var end = position.node;
while (end.nextSibling &&
!goog.editor.style.isContainer(end.nextSibling)) {
end = end.nextSibling;
}
var para = container.ownerDocument.createElement(nodeName);
while (start != end) {
var newStart = start.nextSibling;
goog.dom.appendChild(para, start);
start = newStart;
}
var nextSibling = end.nextSibling;
goog.dom.appendChild(para, end);
container.insertBefore(para, nextSibling);
return para;
};
/**
* When we delete an element, FF inserts a BR. We want to strip that
* BR after the fact, but in the case where your cursor is at a character
* right before a BR and you delete that character, we don't want to
* strip it. So we detect this case on keydown and mark the BR as not needing
* removal.
* @param {goog.dom.AbstractRange} range The closure range object.
* @param {boolean} isBackspace Whether this is handling the backspace key.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.markBrToNotBeRemoved_ =
function(range, isBackspace) {
var focusNode = range.getFocusNode();
var focusOffset = range.getFocusOffset();
var newEndOffset = isBackspace ? focusOffset : focusOffset + 1;
if (goog.editor.node.getLength(focusNode) == newEndOffset) {
var sibling = focusNode.nextSibling;
if (sibling && sibling.tagName == goog.dom.TagName.BR) {
this.brToKeep_ = sibling;
}
}
};
/**
* If we hit delete/backspace to merge elements, FF inserts a BR.
* We want to strip that BR. In markBrToNotBeRemoved, we detect if
* there was already a BR there before the delete/backspace so that
* we don't accidentally remove a user-inserted BR.
* @param {boolean} isBackSpace Whether this is handling the backspace key.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.removeBrIfNecessary_ = function(
isBackSpace) {
var range = this.getFieldObject().getRange();
var focusNode = range.getFocusNode();
var focusOffset = range.getFocusOffset();
var sibling;
if (isBackSpace && focusNode.data == '') {
// nasty hack. sometimes firefox will backspace a paragraph and put
// the cursor before the BR. when it does this, the focusNode is
// an empty textnode.
sibling = focusNode.nextSibling;
} else if (isBackSpace && focusOffset == 0) {
var node = focusNode;
while (node && !node.previousSibling &&
node.parentNode != this.getFieldObject().getElement()) {
node = node.parentNode;
}
sibling = node.previousSibling;
} else if (focusNode.length == focusOffset) {
sibling = focusNode.nextSibling;
}
if (!sibling || sibling.tagName != goog.dom.TagName.BR ||
this.brToKeep_ == sibling) {
return;
}
goog.dom.removeNode(sibling);
if (focusNode.nodeType == goog.dom.NodeType.TEXT) {
// Sometimes firefox inserts extra whitespace. Do our best to deal.
// This is buggy though.
focusNode.data =
goog.editor.plugins.TagOnEnterHandler.trimTabsAndLineBreaks_(
focusNode.data);
// When we strip whitespace, make sure that our cursor is still at
// the end of the textnode.
goog.dom.Range.createCaret(focusNode,
Math.min(focusOffset, focusNode.length)).select();
}
};
/**
* Trim the tabs and line breaks from a string.
* @param {string} string String to trim.
* @return {string} Trimmed string.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.trimTabsAndLineBreaks_ = function(
string) {
return string.replace(/^[\t\n\r]|[\t\n\r]$/g, '');
};
/**
* Called in response to a normal enter keystroke. It has the action of
* splitting elements.
* @return {Element} The node that the cursor should be before.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.handleRegularEnterGecko_ =
function() {
var range = this.getFieldObject().getRange();
var container =
goog.editor.style.getContainer(range.getContainerElement());
var newNode;
if (goog.editor.plugins.EnterHandler.isBrElem(container)) {
if (container.tagName == goog.dom.TagName.BODY) {
// If the field contains only a single BR, this code ensures we don't
// try to clone the body tag.
container = this.ensureNodeIsWrappedW3c_(
container.getElementsByTagName(goog.dom.TagName.BR)[0],
container);
}
newNode = container.cloneNode(true);
goog.dom.insertSiblingAfter(newNode, container);
} else {
if (!container.firstChild) {
container.innerHTML = '&nbsp;';
}
var position = goog.editor.range.getDeepEndPoint(range, true);
container = this.ensureNodeIsWrappedW3c_(position.node, container);
newNode = goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_(
position.node, position.offset, container);
// If the left half and right half of the splitted node are anchors then
// that means the user pressed enter while the caret was inside
// an anchor tag and split it. The left half is the first anchor
// found while traversing the right branch of container. The right half
// is the first anchor found while traversing the left branch of newNode.
var leftAnchor =
goog.editor.plugins.TagOnEnterHandler.findAnchorInTraversal_(
container);
var rightAnchor =
goog.editor.plugins.TagOnEnterHandler.findAnchorInTraversal_(
newNode, true);
if (leftAnchor && rightAnchor &&
leftAnchor.tagName == goog.dom.TagName.A &&
rightAnchor.tagName == goog.dom.TagName.A) {
// If the original anchor (left anchor) is now empty, that means
// the user pressed [Enter] at the beginning of the anchor,
// in which case we we
// want to replace that anchor with its child nodes
// Otherwise, we take the second half of the splitted text and break
// it out of the anchor.
var anchorToRemove = goog.editor.node.isEmpty(leftAnchor, false) ?
leftAnchor : rightAnchor;
goog.dom.flattenElement(/** @type {!Element} */ (anchorToRemove));
}
}
return /** @type {!Element} */ (newNode);
};
/**
* Scroll the cursor into view, resulting from splitting the paragraph/adding
* a br. It behaves differently than scrollIntoView
* @param {Element} element The element immediately following the cursor. Will
* be used to determine how to scroll in order to make the cursor visible.
* CANNOT be a BR, as they do not have offsetHeight/offsetTop.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.scrollCursorIntoViewGecko_ =
function(element) {
if (!this.getFieldObject().isFixedHeight()) {
return; // Only need to scroll fixed height fields.
}
var field = this.getFieldObject().getElement();
// Get the y position of the element we want to scroll to
var elementY = goog.style.getPageOffsetTop(element);
// Determine the height of that element, since we want the bottom of the
// element to be in view.
var bottomOfNode = elementY + element.offsetHeight;
var dom = this.getFieldDomHelper();
var win = this.getFieldDomHelper().getWindow();
var scrollY = dom.getDocumentScroll().y;
var viewportHeight = goog.dom.getViewportSize(win).height;
// If the botom of the element is outside the viewport, move it into view
if (bottomOfNode > viewportHeight + scrollY) {
// In standards mode, use the html element and not the body
if (field.tagName == goog.dom.TagName.BODY &&
goog.editor.node.isStandardsMode(field)) {
field = field.parentNode;
}
field.scrollTop = bottomOfNode - viewportHeight;
}
};
/**
* Splits the DOM tree around the given node and returns the node
* containing the second half of the tree. The first half of the tree
* is modified, but not removed from the DOM.
* @param {Node} positionNode Node to split at.
* @param {number} positionOffset Offset into positionNode to split at. If
* positionNode is a text node, this offset is an offset in to the text
* content of that node. Otherwise, positionOffset is an offset in to
* the childNodes array. All elements with child index of positionOffset
* or greater will be moved to the second half. If positionNode is an
* empty element, the dom will be split at that element, with positionNode
* ending up in the second half. positionOffset must be 0 in this case.
* @param {Node=} opt_root Node at which to stop splitting the dom (the root
* is also split).
* @return {!Node} The node containing the second half of the tree.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.splitDom_ = function(
positionNode, positionOffset, opt_root) {
if (!opt_root) opt_root = positionNode.ownerDocument.body;
// Split the node.
var textSplit = positionNode.nodeType == goog.dom.NodeType.TEXT;
var secondHalfOfSplitNode;
if (textSplit) {
if (goog.userAgent.IE &&
positionOffset == positionNode.nodeValue.length) {
// Since splitText fails in IE at the end of a node, we split it manually.
secondHalfOfSplitNode = goog.dom.getDomHelper(positionNode).
createTextNode('');
goog.dom.insertSiblingAfter(secondHalfOfSplitNode, positionNode);
} else {
secondHalfOfSplitNode = positionNode.splitText(positionOffset);
}
} else {
// Here we ensure positionNode is the last node in the first half of the
// resulting tree.
if (positionOffset) {
// Use offset as an index in to childNodes.
positionNode = positionNode.childNodes[positionOffset - 1];
} else {
// In this case, positionNode would be the last node in the first half
// of the tree, but we actually want to move it to the second half.
// Therefore we set secondHalfOfSplitNode to the same node.
positionNode = secondHalfOfSplitNode = positionNode.firstChild ||
positionNode;
}
}
// Create second half of the tree.
var secondHalf = goog.editor.node.splitDomTreeAt(
positionNode, secondHalfOfSplitNode, opt_root);
if (textSplit) {
// Join secondHalfOfSplitNode and its right text siblings together and
// then replace leading NonNbspWhiteSpace with a Nbsp. If
// secondHalfOfSplitNode has a right sibling that isn't a text node,
// then we can leave secondHalfOfSplitNode empty.
secondHalfOfSplitNode =
goog.editor.plugins.TagOnEnterHandler.joinTextNodes_(
secondHalfOfSplitNode, true);
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
secondHalfOfSplitNode, true, !!secondHalfOfSplitNode.nextSibling);
// Join positionNode and its left text siblings together and then replace
// trailing NonNbspWhiteSpace with a Nbsp.
var firstHalf = goog.editor.plugins.TagOnEnterHandler.joinTextNodes_(
positionNode, false);
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
firstHalf, false, false);
}
return secondHalf;
};
/**
* Splits the DOM tree around the given node and returns the node containing
* second half of the tree, which is appended after the old node. The first
* half of the tree is modified, but not removed from the DOM.
* @param {Node} positionNode Node to split at.
* @param {number} positionOffset Offset into positionNode to split at. If
* positionNode is a text node, this offset is an offset in to the text
* content of that node. Otherwise, positionOffset is an offset in to
* the childNodes array. All elements with child index of positionOffset
* or greater will be moved to the second half. If positionNode is an
* empty element, the dom will be split at that element, with positionNode
* ending up in the second half. positionOffset must be 0 in this case.
* @param {Node} node Node to split.
* @return {!Node} The node containing the second half of the tree.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_ = function(
positionNode, positionOffset, node) {
var newNode = goog.editor.plugins.TagOnEnterHandler.splitDom_(
positionNode, positionOffset, node);
goog.dom.insertSiblingAfter(newNode, node);
return newNode;
};
/**
* Joins node and its adjacent text nodes together.
* @param {Node} node The node to start joining.
* @param {boolean} moveForward Determines whether to join left siblings (false)
* or right siblings (true).
* @return {Node} The joined text node.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.joinTextNodes_ = function(node,
moveForward) {
if (node && node.nodeName == '#text') {
var nextNodeFn = moveForward ? 'nextSibling' : 'previousSibling';
var prevNodeFn = moveForward ? 'previousSibling' : 'nextSibling';
var nodeValues = [node.nodeValue];
while (node[nextNodeFn] &&
node[nextNodeFn].nodeType == goog.dom.NodeType.TEXT) {
node = node[nextNodeFn];
nodeValues.push(node.nodeValue);
goog.dom.removeNode(node[prevNodeFn]);
}
if (!moveForward) {
nodeValues.reverse();
}
node.nodeValue = nodeValues.join('');
}
return node;
};
/**
* Replaces leading or trailing spaces of a text node to a single Nbsp.
* @param {Node} textNode The text node to search and replace white spaces.
* @param {boolean} fromStart Set to true to replace leading spaces, false to
* replace trailing spaces.
* @param {boolean} isLeaveEmpty Set to true to leave the node empty if the
* text node was empty in the first place, otherwise put a Nbsp into the
* text node.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_ = function(
textNode, fromStart, isLeaveEmpty) {
var regExp = fromStart ? /^[ \t\r\n]+/ : /[ \t\r\n]+$/;
textNode.nodeValue = textNode.nodeValue.replace(regExp,
goog.string.Unicode.NBSP);
if (!isLeaveEmpty && textNode.nodeValue == '') {
textNode.nodeValue = goog.string.Unicode.NBSP;
}
};
/**
* Finds the first A element in a traversal from the input node. The input
* node itself is not included in the search.
* @param {Node} node The node to start searching from.
* @param {boolean=} opt_useFirstChild Whether to traverse along the first child
* (true) or last child (false).
* @return {Node} The first anchor node found in the search, or null if none
* was found.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.findAnchorInTraversal_ = function(node,
opt_useFirstChild) {
while ((node = opt_useFirstChild ? node.firstChild : node.lastChild) &&
node.tagName != goog.dom.TagName.A) {
// Do nothing - advancement is handled in the condition.
}
return node;
};
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<!--
Tests for goog.editor.plugins.TagOnEnterHandler
@author marcosalmeida@google.com
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.TagOnEnterHandler jsunit tests
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.TagOnEnterHandlerTest');
</script>
</head>
<body>
<div id="root">
<div id="field1" class="tr-field">
</div>
</div>
</body>
</html>
@@ -0,0 +1,546 @@
// 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.
goog.provide('goog.editor.plugins.TagOnEnterHandlerTest');
goog.setTestOnly('goog.editor.plugins.TagOnEnterHandlerTest');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Field');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.plugins.TagOnEnterHandler');
goog.require('goog.events.KeyCodes');
goog.require('goog.string.Unicode');
goog.require('goog.testing.dom');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var savedHtml;
var editor;
var field1;
function setUp() {
field1 = makeField('field1');
field1.makeEditable();
}
/**
* Tests that deleting a BR that comes right before a block element works.
* @bug 1471096
*/
function testDeleteBrBeforeBlock() {
// This test only works on Gecko, because it's testing for manual deletion of
// BR tags, which is done only for Gecko. For other browsers we fall through
// and let the browser do the delete, which can only be tested with a robot
// test (see javascript/apps/editor/tests/delete_br_robot.html).
if (goog.userAgent.GECKO) {
field1.setHtml(false, 'one<br><br><div>two</div>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
helper.select(field1.getElement(), 2); // Between the two BR's.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted exactly one <br>',
'one<br><div>two</div>',
field1.getElement().innerHTML);
} // End if GECKO
}
/**
* Tests that deleting a BR is working normally (that the workaround for the
* bug is not causing double deletes).
* @bug 1471096
*/
function testDeleteBrNormal() {
// This test only works on Gecko, because it's testing for manual deletion of
// BR tags, which is done only for Gecko. For other browsers we fall through
// and let the browser do the delete, which can only be tested with a robot
// test (see javascript/apps/editor/tests/delete_br_robot.html).
if (goog.userAgent.GECKO) {
field1.setHtml(false, 'one<br><br><br>two');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
helper.select(field1.getElement(), 2); // Between the first and second BR's.
field1.getElement().focus();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted exactly one <br>',
'one<br><br>two',
field1.getElement().innerHTML);
} // End if GECKO
}
/**
* Regression test for http://b/1991234 . Tests that when you hit enter and it
* creates a blank line with whitespace and a BR, the cursor is placed in the
* whitespace text node instead of the BR, otherwise continuing to type will
* create adjacent text nodes, which causes browsers to mess up some
* execcommands. Fix is in a Gecko-only codepath, thus test runs only for Gecko.
* A full test for the entire sequence that reproed the bug is in
* javascript/apps/editor/tests/ponenter_robot.html .
*/
function testEnterCreatesBlankLine() {
if (goog.userAgent.GECKO) {
field1.setHtml(false, '<p>one <br></p>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
// Place caret after 'one' but keeping a space and a BR as FF does.
helper.select('one ', 3);
field1.getElement().focus();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var range = field1.getRange();
assertFalse('Selection should not be in BR tag',
range.getStartNode().nodeType == goog.dom.NodeType.ELEMENT &&
range.getStartNode().tagName == goog.dom.TagName.BR);
assertEquals('Selection should be in text node to avoid creating adjacent' +
' text nodes',
goog.dom.NodeType.TEXT, range.getStartNode().nodeType);
var rangeStartNode =
goog.dom.Range.createFromNodeContents(range.getStartNode());
assertHTMLEquals('The value of selected text node should be replaced with' +
'&nbsp;',
'&nbsp;', rangeStartNode.getHtmlFragment());
}
}
/**
* Regression test for http://b/3051179 . Tests that when you hit enter and it
* creates a blank line with a BR and the cursor is placed in P.
* Splitting DOM causes to make an empty text node. Then if the cursor is placed
* at the text node the cursor is shown at wrong location.
* Therefore this test checks that the cursor is not placed at an empty node.
* Fix is in a Gecko-only codepath, thus test runs only for Gecko.
*/
function testEnterNormalizeNodes() {
if (goog.userAgent.GECKO) {
field1.setHtml(false, '<p>one<br></p>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
// Place caret after 'one' but keeping a BR as FF does.
helper.select('one', 3);
field1.getElement().focus();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var range = field1.getRange();
assertTrue('Selection should be in P tag',
range.getStartNode().nodeType == goog.dom.NodeType.ELEMENT &&
range.getStartNode().tagName == goog.dom.TagName.P);
assertTrue('Selection should be at the head and collapsed',
range.getStartOffset() == 0 && range.isCollapsed());
}
}
/**
* Verifies
* goog.editor.plugins.TagOnEnterHandler.prototype.handleRegularEnterGecko_
* when we explicitly split anchor elements. This test runs only for Gecko
* since this is a Gecko-only codepath.
*/
function testEnterAtBeginningOfLink() {
if (goog.userAgent.GECKO) {
field1.setHtml(false, '<a href="/">b<br></a>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
field1.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<p>&nbsp;</p><p><a href="/">b<br></a></p>');
}
}
/**
* Verifies correct handling of pressing enter in an empty list item.
*/
function testEnterInEmptyListItemInEmptyList() {
if (goog.userAgent.GECKO) {
field1.setHtml(false, '<ul><li>&nbsp;</li></ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[0];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches('<p>&nbsp;</p>');
}
}
function testEnterInEmptyListItemAtBeginningOfList() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul style="font-weight: bold">' +
'<li>&nbsp;</li>' +
'<li>1</li>' +
'<li>2</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[0];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<p>&nbsp;</p><ul style="font-weight: bold"><li>1</li><li>2</li></ul>');
}
}
function testEnterInEmptyListItemAtEndOfList() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul style="font-weight: bold">' +
'<li>1</li>' +
'<li>2</li>' +
'<li>&nbsp;</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[2];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<ul style="font-weight: bold"><li>1</li><li>2</li></ul><p>&nbsp;</p>');
}
}
function testEnterInEmptyListItemInMiddleOfList() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul style="font-weight: bold">' +
'<li>1</li>' +
'<li>&nbsp;</li>' +
'<li>2</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[1];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<ul style="font-weight: bold"><li>1</li></ul>' +
'<p>&nbsp;</p>' +
'<ul style="font-weight: bold"><li>2</li></ul>');
}
}
function testEnterInEmptyListItemInSublist() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul>' +
'<li>A</li>' +
'<ul style="font-weight: bold">' +
'<li>1</li>' +
'<li>&nbsp;</li>' +
'<li>2</li>' +
'</ul>' +
'<li>B</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[2];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<ul>' +
'<li>A</li>' +
'<ul style="font-weight: bold"><li>1</li></ul>' +
'<li>&nbsp;</li>' +
'<ul style="font-weight: bold"><li>2</li></ul>' +
'<li>B</li>' +
'</ul>');
}
}
function testEnterInEmptyListItemAtBeginningOfSublist() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul>' +
'<li>A</li>' +
'<ul style="font-weight: bold">' +
'<li>&nbsp;</li>' +
'<li>1</li>' +
'<li>2</li>' +
'</ul>' +
'<li>B</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[1];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<ul>' +
'<li>A</li>' +
'<li>&nbsp;</li>' +
'<ul style="font-weight: bold"><li>1</li><li>2</li></ul>' +
'<li>B</li>' +
'</ul>');
}
}
function testEnterInEmptyListItemAtEndOfSublist() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul>' +
'<li>A</li>' +
'<ul style="font-weight: bold">' +
'<li>1</li>' +
'<li>2</li>' +
'<li>&nbsp;</li>' +
'</ul>' +
'<li>B</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[3];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<ul>' +
'<li>A</li>' +
'<ul style="font-weight: bold"><li>1</li><li>2</li></ul>' +
'<li>&nbsp;</li>' +
'<li>B</li>' +
'</ul>');
}
}
function testPrepareContentForPOnEnter() {
assertPreparedContents('hi', 'hi');
assertPreparedContents(
goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES ? '<p>&nbsp;</p>' : '',
' ');
}
function testPrepareContentForDivOnEnter() {
assertPreparedContents('hi', 'hi', goog.dom.TagName.DIV);
assertPreparedContents(
goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES ? '<div><br></div>' : '',
' ',
goog.dom.TagName.DIV);
}
/**
* Assert that the prepared contents matches the expected.
*/
function assertPreparedContents(expected, original, opt_tag) {
var field = makeField('field1', opt_tag);
field.makeEditable();
assertEquals(expected,
field.reduceOp_(
goog.editor.Plugin.Op.PREPARE_CONTENTS_HTML, original));
}
/**
* Selects the node at the given id, and simulates an ENTER keypress.
* @param {googe.editor.Field} field The field with the node.
* @param {string} id A DOM id.
* @return {boolean} Whether preventDefault was called on the event.
*/
function selectNodeAndHitEnter(field, id) {
var cursor = field.getEditableDomHelper().getElement(id);
goog.dom.Range.createFromNodeContents(cursor).select();
return goog.testing.events.fireKeySequence(
cursor, goog.events.KeyCodes.ENTER);
}
/**
* Creates a field with only the enter handler plugged in, for testing.
* @param {string} id A DOM id.
* @param {boolean=} opt_tag The block tag to use. Defaults to P.
* @return {goog.editor.Field} A field.
*/
function makeField(id, opt_tag) {
var field = new goog.editor.Field(id);
field.registerPlugin(
new goog.editor.plugins.TagOnEnterHandler(opt_tag || goog.dom.TagName.P));
return field;
}
/**
* Runs a test for splitting the dom.
* @param {number} offset Index into the text node to split.
* @param {string} firstHalfString What the html of the first half of the DOM
* should be.
* @param {string} secondHalfString What the html of the 2nd half of the DOM
* should be.
* @param {boolean} isAppend True if the second half should be appended to the
* DOM.
* @param {boolean=} opt_goToRoot True if the root argument for splitDom should
* be excluded.
*/
function helpTestSplit_(offset, firstHalfString, secondHalfString, isAppend,
opt_goToBody) {
var node = document.createElement('div');
node.innerHTML = '<b>begin bold<i>italic</i>end bold</b>';
document.body.appendChild(node);
var italic = node.getElementsByTagName('i')[0].firstChild;
var splitFn = isAppend ?
goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_ :
goog.editor.plugins.TagOnEnterHandler.splitDom_;
var secondHalf = splitFn(italic, offset, opt_goToBody ? undefined : node);
if (opt_goToBody) {
secondHalfString = '<div>' + secondHalfString + '</div>';
}
assertEquals('original node should have first half of the html',
firstHalfString,
node.innerHTML.toLowerCase().
replace(goog.string.Unicode.NBSP, '&nbsp;'));
assertEquals('new node should have second half of the html',
secondHalfString,
secondHalf.innerHTML.toLowerCase().
replace(goog.string.Unicode.NBSP, '&nbsp;'));
if (isAppend) {
assertTrue('second half of dom should be the original node\'s next' +
'sibling', node.nextSibling == secondHalf);
goog.dom.removeNode(secondHalf);
}
goog.dom.removeNode(node);
}
/**
* Runs different cases of splitting the DOM.
* @param {function(number, string, string)} testFn Function that takes an
* offset, firstHalfString and secondHalfString as parameters.
*/
function splitDomCases_(testFn) {
testFn(3, '<b>begin bold<i>ita</i></b>', '<b><i>lic</i>end bold</b>');
testFn(0, '<b>begin bold<i>&nbsp;</i></b>', '<b><i>italic</i>end bold</b>');
testFn(6, '<b>begin bold<i>italic</i></b>', '<b><i>&nbsp;</i>end bold</b>');
}
function testSplitDom() {
splitDomCases_(function(offset, firstHalfString, secondHalfString) {
helpTestSplit_(offset, firstHalfString, secondHalfString, false, true);
helpTestSplit_(offset, firstHalfString, secondHalfString, false, false);
});
}
function testSplitDomAndAppend() {
splitDomCases_(function(offset, firstHalfString, secondHalfString) {
helpTestSplit_(offset, firstHalfString, secondHalfString, true, false);
});
}
function testSplitDomAtElement() {
var node = document.createElement('div');
node.innerHTML = '<div>abc<br>def</div>';
document.body.appendChild(node);
goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_(node.firstChild, 1,
node.firstChild);
goog.testing.dom.assertHtmlContentsMatch('<div>abc</div><div><br>def</div>',
node);
goog.dom.removeNode(node);
}
function testSplitDomAtElementStart() {
var node = document.createElement('div');
node.innerHTML = '<div>abc<br>def</div>';
document.body.appendChild(node);
goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_(node.firstChild, 0,
node.firstChild);
goog.testing.dom.assertHtmlContentsMatch('<div></div><div>abc<br>def</div>',
node);
goog.dom.removeNode(node);
}
function testSplitDomAtChildlessElement() {
var node = document.createElement('div');
node.innerHTML = '<div>abc<br>def</div>';
document.body.appendChild(node);
var br = node.getElementsByTagName(goog.dom.TagName.BR)[0];
goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_(
br, 0, node.firstChild);
goog.testing.dom.assertHtmlContentsMatch('<div>abc</div><div><br>def</div>',
node);
goog.dom.removeNode(node);
}
function testReplaceWhiteSpaceWithNbsp() {
var node = document.createElement('div');
var textNode = document.createTextNode('');
node.appendChild(textNode);
textNode.nodeValue = ' test ';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, true, false);
assertHTMLEquals('&nbsp;test ', node.innerHTML);
textNode.nodeValue = ' test ';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, true, false);
assertHTMLEquals('&nbsp;test ', node.innerHTML);
textNode.nodeValue = ' test ';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, false, false);
assertHTMLEquals(' test&nbsp;', node.innerHTML);
textNode.nodeValue = ' test ';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, false, false);
assertHTMLEquals(' test&nbsp;', node.innerHTML);
textNode.nodeValue = '';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, false, false);
assertHTMLEquals('&nbsp;', node.innerHTML);
textNode.nodeValue = '';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, false, true);
assertHTMLEquals('', node.innerHTML);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
goog.editor.plugins
<!DOCTYPE html>
<!--
All Rights Reserved.
@author ajp@google.com (Andy Perelson)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Trogedit Unit Tests - goog.editor.plugins.UndoRedo
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.UndoRedoTest');
</script>
</head>
<body>
<div id="testField">
</div>
</body>
</html>
@@ -0,0 +1,516 @@
// 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.
goog.provide('goog.editor.plugins.UndoRedoTest');
goog.setTestOnly('goog.editor.plugins.UndoRedoTest');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.browserrange');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.LoremIpsum');
goog.require('goog.editor.plugins.UndoRedo');
goog.require('goog.events');
goog.require('goog.functions');
goog.require('goog.testing.MockClock');
goog.require('goog.testing.PropertyReplacer');
goog.require('goog.testing.StrictMock');
goog.require('goog.testing.jsunit');
var mockEditableField;
var editableField;
var fieldHashCode;
var undoPlugin;
var state;
var mockState;
var commands;
var clock;
var stubs = new goog.testing.PropertyReplacer();
function setUp() {
mockEditableField = new goog.testing.StrictMock(goog.editor.Field);
// Update the arg list verifier for dispatchCommandValueChange to
// correctly compare arguments that are arrays (or other complex objects).
mockEditableField.$registerArgumentListVerifier('dispatchEvent',
function(expected, args) {
return goog.array.equals(expected, args,
function(a, b) { assertObjectEquals(a, b); return true; });
});
mockEditableField.getHashCode = function() {
return 'fieldId';
};
undoPlugin = new goog.editor.plugins.UndoRedo();
undoPlugin.registerFieldObject(mockEditableField);
mockState = new goog.testing.StrictMock(
goog.editor.plugins.UndoRedo.UndoState_);
mockState.fieldHashCode = 'fieldId';
mockState.isAsynchronous = function() {
return false;
};
// Don't bother mocking the inherited event target pieces of the state.
// If we don't do this, then mocked asynchronous undos are a lot harder and
// that behavior is tested as part of the UndoRedoManager tests.
mockState.addEventListener = goog.nullFunction;
commands = [
goog.editor.plugins.UndoRedo.COMMAND.REDO,
goog.editor.plugins.UndoRedo.COMMAND.UNDO
];
state = new goog.editor.plugins.UndoRedo.UndoState_('1', '', null,
goog.nullFunction);
clock = new goog.testing.MockClock(true);
editableField = new goog.editor.Field('testField');
fieldHashCode = editableField.getHashCode();
}
function tearDown() {
// Reset field so any attempted access during disposes don't cause errors.
mockEditableField.$reset();
clock.dispose();
undoPlugin.dispose();
// NOTE(nicksantos): I think IE is blowing up on this call because
// it is lame. It manifests its lameness by throwing an exception.
// Kudos to XT for helping me to figure this out.
try {
} catch (e) {}
if (!editableField.isUneditable()) {
editableField.makeUneditable();
}
editableField.dispose();
goog.dom.getElement('testField').innerHTML = '';
stubs.reset();
}
// undo-redo plugin tests
function testQueryCommandValue() {
assertFalse('Must return false for empty undo stack.',
undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.UNDO));
assertFalse('Must return false for empty redo stack.',
undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.REDO));
undoPlugin.undoManager_.addState(mockState);
assertTrue('Must return true for a non-empty undo stack.',
undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.UNDO));
}
function testExecCommand() {
undoPlugin.undoManager_.addState(mockState);
mockState.undo();
mockState.$replay();
undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.UNDO);
// Second undo should do nothing since only one item on stack.
undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.UNDO);
mockState.$verify();
mockState.$reset();
mockState.redo();
mockState.$replay();
undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
// Second redo should do nothing since only one item on stack.
undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
mockState.$verify();
}
function testHandleKeyboardShortcut_TrogStates() {
undoPlugin.undoManager_.addState(mockState);
undoPlugin.undoManager_.addState(state);
undoPlugin.undoManager_.undo();
mockEditableField.$reset();
var stubUndoEvent = {ctrlKey: true, altKey: false, shiftKey: false};
var stubRedoEvent = {ctrlKey: true, altKey: false, shiftKey: true};
var stubRedoEvent2 = {ctrlKey: true, altKey: false, shiftKey: false};
var result;
// Test handling Trogedit undos. Should always call EditableField's
// execCommand. Since EditableField is mocked, this will not result in a call
// to the mockState's undo and redo methods.
mockEditableField.execCommand(goog.editor.plugins.UndoRedo.COMMAND.UNDO);
mockEditableField.$replay();
result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'z', true);
assertTrue('Plugin must return true when it handles shortcut.', result);
mockEditableField.$verify();
mockEditableField.$reset();
mockEditableField.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
mockEditableField.$replay();
result = undoPlugin.handleKeyboardShortcut(stubRedoEvent, 'z', true);
assertTrue('Plugin must return true when it handles shortcut.', result);
mockEditableField.$verify();
mockEditableField.$reset();
mockEditableField.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
mockEditableField.$replay();
result = undoPlugin.handleKeyboardShortcut(stubRedoEvent2, 'y', true);
assertTrue('Plugin must return true when it handles shortcut.', result);
mockEditableField.$verify();
mockEditableField.$reset();
mockEditableField.$replay();
result = undoPlugin.handleKeyboardShortcut(stubRedoEvent2, 'y', false);
assertFalse('Plugin must return false when modifier is not pressed.', result);
mockEditableField.$verify();
mockEditableField.$reset();
mockEditableField.$replay();
result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'f', true);
assertFalse('Plugin must return false when it doesn\'t handle shortcut.',
result);
mockEditableField.$verify();
}
function testHandleKeyboardShortcut_NotTrogStates() {
var stubUndoEvent = {ctrlKey: true, altKey: false, shiftKey: false};
// Trogedit undo states all have a fieldHashCode, nulling that out makes this
// state be treated as a non-Trogedit undo-redo state.
state.fieldHashCode = null;
undoPlugin.undoManager_.addState(state);
mockEditableField.$reset();
// Non-trog state shouldn't go through EditableField.execCommand, however,
// we still exect command value change dispatch since undo-redo plugin
// redispatches those anytime manager's state changes.
mockEditableField.dispatchEvent({
type: goog.editor.Field.EventType.COMMAND_VALUE_CHANGE,
commands: commands});
mockEditableField.$replay();
var result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'z', true);
assertTrue('Plugin must return true when it handles shortcut.' , result);
mockEditableField.$verify();
}
function testEnable() {
assertFalse('Plugin must start disabled.',
undoPlugin.isEnabled(editableField));
editableField.makeEditable(editableField);
editableField.setHtml(false, '<div>a</div>');
undoPlugin.enable(editableField);
assertTrue(undoPlugin.isEnabled(editableField));
assertNotNull('Must have an event handler for enabled field.',
undoPlugin.eventHandlers_[fieldHashCode]);
var currentState = undoPlugin.currentStates_[fieldHashCode];
assertNotNull('Enabled plugin must have a current state.', currentState);
assertEquals('After enable, undo content must match the field content.',
editableField.getElement().innerHTML, currentState.undoContent_);
assertTrue('After enable, undo cursorPosition must match the field cursor' +
'position.', cursorPositionsEqual(getCurrentCursorPosition(),
currentState.undoCursorPosition_));
assertUndefined('Current state must never have redo content.',
currentState.redoContent_);
assertUndefined('Current state must never have redo cursor position.',
currentState.redoCursorPosition_);
}
function testDisable() {
editableField.makeEditable(editableField);
undoPlugin.enable(editableField);
assertTrue('Plugin must be enabled so we can test disabling.',
undoPlugin.isEnabled(editableField));
var delayedChangeFired = false;
goog.events.listenOnce(editableField,
goog.editor.Field.EventType.DELAYEDCHANGE,
function(e) {
delayedChangeFired = true;
});
editableField.setHtml(false, 'foo');
undoPlugin.disable(editableField);
assertTrue('disable must fire pending delayed changes.', delayedChangeFired);
assertEquals('disable must add undo state from pending change.',
1, undoPlugin.undoManager_.undoStack_.length);
assertFalse(undoPlugin.isEnabled(editableField));
assertUndefined('Disabled plugin must not have current state.',
undoPlugin.eventHandlers_[fieldHashCode]);
assertUndefined('Disabled plugin must not have event handlers.',
undoPlugin.eventHandlers_[fieldHashCode]);
}
function testUpdateCurrentState_() {
editableField.registerPlugin(new goog.editor.plugins.LoremIpsum('LOREM'));
editableField.makeEditable(editableField);
editableField.getPluginByClassId('LoremIpsum').usingLorem_ = true;
undoPlugin.updateCurrentState_(editableField);
var currentState = undoPlugin.currentStates_[fieldHashCode];
assertNotUndefined('Must create empty states for field using lorem ipsum.',
undoPlugin.currentStates_[fieldHashCode]);
assertEquals('', currentState.undoContent_);
assertNull(currentState.undoCursorPosition_);
editableField.getPluginByClassId('LoremIpsum').usingLorem_ = false;
// Pretend foo is the default contents to test '' == default contents
// behavior.
editableField.getInjectableContents = function(contents, styles) {
return contents == '' ? 'foo' : contents;
};
editableField.setHtml(false, 'foo');
undoPlugin.updateCurrentState_(editableField);
assertEquals(currentState, undoPlugin.currentStates_[fieldHashCode]);
// NOTE(user): Because there is already a current state, this setHtml will add
// a state to the undo stack.
editableField.setHtml(false, '<div>a</div>');
// Select some text so we have a valid selection that gets saved in the
// UndoState.
goog.dom.browserrange.createRangeFromNodeContents(
editableField.getElement()).select();
undoPlugin.updateCurrentState_(editableField);
currentState = undoPlugin.currentStates_[fieldHashCode];
assertNotNull('Must create state for field not using lorem ipsum',
currentState);
assertEquals(fieldHashCode, currentState.fieldHashCode);
var content = editableField.getElement().innerHTML;
var cursorPosition = getCurrentCursorPosition();
assertEquals(content, currentState.undoContent_);
assertTrue(cursorPositionsEqual(
cursorPosition, currentState.undoCursorPosition_));
assertUndefined(currentState.redoContent_);
assertUndefined(currentState.redoCursorPosition_);
undoPlugin.updateCurrentState_(editableField);
assertEquals('Updating state when state has not changed must not add undo ' +
'state to stack.', 1, undoPlugin.undoManager_.undoStack_.length);
assertEquals('Updating state when state has not changed must not create ' +
'a new state.', currentState, undoPlugin.currentStates_[fieldHashCode]);
assertUndefined('Updating state when state has not changed must not add ' +
'redo content.', currentState.redoContent_);
assertUndefined('Updating state when state has not changed must not add ' +
'redo cursor position.', currentState.redoCursorPosition_);
editableField.setHtml(false, '<div>b</div>');
undoPlugin.updateCurrentState_(editableField);
currentState = undoPlugin.currentStates_[fieldHashCode];
assertNotNull('Must create state for field not using lorem ipsum',
currentState);
assertEquals(fieldHashCode, currentState.fieldHashCode);
var newContent = editableField.getElement().innerHTML;
var newCursorPosition = getCurrentCursorPosition();
assertEquals(newContent, currentState.undoContent_);
assertTrue(cursorPositionsEqual(
newCursorPosition, currentState.undoCursorPosition_));
assertUndefined(currentState.redoContent_);
assertUndefined(currentState.redoCursorPosition_);
var undoState = goog.array.peek(undoPlugin.undoManager_.undoStack_);
assertNotNull('Must create state for field not using lorem ipsum',
currentState);
assertEquals(fieldHashCode, currentState.fieldHashCode);
assertEquals(content, undoState.undoContent_);
assertTrue(cursorPositionsEqual(
cursorPosition, undoState.undoCursorPosition_));
assertEquals(newContent, undoState.redoContent_);
assertTrue(cursorPositionsEqual(
newCursorPosition, undoState.redoCursorPosition_));
}
/**
* Tests that change events get restarted properly after an undo call despite
* an exception being thrown in the process (see bug/1991234).
*/
function testUndoRestartsChangeEvents() {
undoPlugin.registerFieldObject(editableField);
editableField.makeEditable(editableField);
editableField.setHtml(false, '<div>a</div>');
clock.tick(1000);
undoPlugin.enable(editableField);
// Change content so we can undo it.
editableField.setHtml(false, '<div>b</div>');
clock.tick(1000);
var currentState = undoPlugin.currentStates_[fieldHashCode];
stubs.set(editableField, 'setCursorPosition',
goog.functions.error('Faking exception during setCursorPosition()'));
try {
currentState.undo();
} catch (e) {
fail('Exception should not have been thrown during undo()');
}
assertEquals('Change events should be on', 0,
editableField.stoppedEvents_[goog.editor.Field.EventType.CHANGE]);
assertEquals('Delayed change events should be on', 0,
editableField.stoppedEvents_[goog.editor.Field.EventType.DELAYEDCHANGE]);
}
function testRefreshCurrentState() {
editableField.makeEditable(editableField);
editableField.setHtml(false, '<div>a</div>');
clock.tick(1000);
undoPlugin.enable(editableField);
// Create current state and verify it.
var currentState = undoPlugin.currentStates_[fieldHashCode];
assertEquals(fieldHashCode, currentState.fieldHashCode);
var content = editableField.getElement().innerHTML;
var cursorPosition = getCurrentCursorPosition();
assertEquals(content, currentState.undoContent_);
assertTrue(cursorPositionsEqual(
cursorPosition, currentState.undoCursorPosition_));
// Update the field w/o dispatching delayed change, and verify that the
// current state hasn't changed to reflect new values.
editableField.setHtml(false, '<div>b</div>', true);
clock.tick(1000);
currentState = undoPlugin.currentStates_[fieldHashCode];
assertEquals('Content must match old state.',
content, currentState.undoContent_);
assertTrue('Cursor position must match old state.',
cursorPositionsEqual(
cursorPosition, currentState.undoCursorPosition_));
undoPlugin.refreshCurrentState(editableField);
assertFalse('Refresh must not cause states to go on the undo-redo stack.',
undoPlugin.undoManager_.hasUndoState());
currentState = undoPlugin.currentStates_[fieldHashCode];
content = editableField.getElement().innerHTML;
cursorPosition = getCurrentCursorPosition();
assertEquals('Content must match current field state.',
content, currentState.undoContent_);
assertTrue('Cursor position must match current field state.',
cursorPositionsEqual(cursorPosition, currentState.undoCursorPosition_));
undoPlugin.disable(editableField);
assertUndefined(undoPlugin.currentStates_[fieldHashCode]);
undoPlugin.refreshCurrentState(editableField);
assertUndefined('Must not refresh current state of fields that do not have ' +
'undo-redo enabled.', undoPlugin.currentStates_[fieldHashCode]);
}
/**
* Returns the CursorPosition for the selection currently in the Field.
* @return {goog.editor.plugins.UndoRedo.CursorPosition_}
*/
function getCurrentCursorPosition() {
return undoPlugin.getCursorPosition_(editableField);
}
/**
* Compares two cursor positions and returns whether they are equal.
* @param {goog.editor.plugins.UndoRedo.CursorPosition_} a
* A cursor position.
* @param {goog.editor.plugins.UndoRedo.CursorPosition_} b
* A cursor position.
* @return {boolean} Whether the positions are equal.
*/
function cursorPositionsEqual(a, b) {
if (!a && !b) {
return true;
} else if (a && b) {
return a.toString() == b.toString();
}
// Only one cursor position is an object, can't be equal.
return false;
}
// Undo state tests
function testSetUndoState() {
state.setUndoState('content', 'position');
assertEquals('Undo content incorrectly set', 'content', state.undoContent_);
assertEquals('Undo cursor position incorrectly set', 'position',
state.undoCursorPosition_);
}
function testSetRedoState() {
state.setRedoState('content', 'position');
assertEquals('Redo content incorrectly set', 'content', state.redoContent_);
assertEquals('Redo cursor position incorrectly set', 'position',
state.redoCursorPosition_);
}
function testEquals() {
assertTrue('A state must equal itself', state.equals(state));
var state2 = new goog.editor.plugins.UndoRedo.UndoState_('1', '', null);
assertTrue('A state must equal a state with the same hash code and content.',
state.equals(state2));
state2 = new goog.editor.plugins.UndoRedo.UndoState_('1', '', 'foo');
assertTrue('States with different cursor positions must be equal',
state.equals(state2));
state2.setRedoState('bar', null);
assertFalse('States with different redo content must not be equal',
state.equals(state2));
state2 = new goog.editor.plugins.UndoRedo.UndoState_('3', '', null);
assertFalse('States with different field hash codes must not be equal',
state.equals(state2));
state2 = new goog.editor.plugins.UndoRedo.UndoState_('1', 'baz', null);
assertFalse('States with different undoContent must not be equal',
state.equals(state2));
}
/** @bug 1359214 */
function testClearUndoHistory() {
var undoRedoPlugin = new goog.editor.plugins.UndoRedo();
editableField.registerPlugin(undoRedoPlugin);
editableField.makeEditable(editableField);
editableField.dispatchChange();
clock.tick(10000);
editableField.getElement().innerHTML = 'y';
editableField.dispatchChange();
assertFalse(undoRedoPlugin.undoManager_.hasUndoState());
clock.tick(10000);
assertTrue(undoRedoPlugin.undoManager_.hasUndoState());
editableField.getElement().innerHTML = 'z';
editableField.dispatchChange();
var numCalls = 0;
goog.events.listen(editableField, goog.editor.Field.EventType.DELAYEDCHANGE,
function() {
numCalls++;
});
undoRedoPlugin.clearHistory();
// 1 call from stopChangeEvents(). 0 calls from startChangeEvents().
assertEquals('clearHistory must not cause delayed change when none pending',
1, numCalls);
clock.tick(10000);
assertFalse(undoRedoPlugin.undoManager_.hasUndoState());
}
@@ -0,0 +1,338 @@
// 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 Code for managing series of undo-redo actions in the form of
* {@link goog.editor.plugins.UndoRedoState}s.
*
*/
goog.provide('goog.editor.plugins.UndoRedoManager');
goog.provide('goog.editor.plugins.UndoRedoManager.EventType');
goog.require('goog.editor.plugins.UndoRedoState');
goog.require('goog.events');
goog.require('goog.events.EventTarget');
/**
* Manages undo and redo operations through a series of {@code UndoRedoState}s
* maintained on undo and redo stacks.
*
* @constructor
* @extends {goog.events.EventTarget}
*/
goog.editor.plugins.UndoRedoManager = function() {
goog.events.EventTarget.call(this);
/**
* The maximum number of states on the undo stack at any time. Used to limit
* the memory footprint of the undo-redo stack.
* TODO(user) have a separate memory size based limit.
* @type {number}
* @private
*/
this.maxUndoDepth_ = 100;
/**
* The undo stack.
* @type {Array<goog.editor.plugins.UndoRedoState>}
* @private
*/
this.undoStack_ = [];
/**
* The redo stack.
* @type {Array<goog.editor.plugins.UndoRedoState>}
* @private
*/
this.redoStack_ = [];
/**
* A queue of pending undo or redo actions. Stored as objects with two
* properties: func and state. The func property stores the undo or redo
* function to be called, the state property stores the state that method
* came from.
* @type {Array<Object>}
* @private
*/
this.pendingActions_ = [];
};
goog.inherits(goog.editor.plugins.UndoRedoManager, goog.events.EventTarget);
/**
* Event types for the events dispatched by undo-redo manager.
* @enum {string}
*/
goog.editor.plugins.UndoRedoManager.EventType = {
/**
* Signifies that he undo or redo stack transitioned between 0 and 1 states,
* meaning that the ability to peform undo or redo operations has changed.
*/
STATE_CHANGE: 'state_change',
/**
* Signifies that a state was just added to the undo stack. Events of this
* type will have a {@code state} property whose value is the state that
* was just added.
*/
STATE_ADDED: 'state_added',
/**
* Signifies that the undo method of a state is about to be called.
* Events of this type will have a {@code state} property whose value is the
* state whose undo action is about to be performed. If the event is cancelled
* the action does not proceed, but the state will still transition between
* stacks.
*/
BEFORE_UNDO: 'before_undo',
/**
* Signifies that the redo method of a state is about to be called.
* Events of this type will have a {@code state} property whose value is the
* state whose redo action is about to be performed. If the event is cancelled
* the action does not proceed, but the state will still transition between
* stacks.
*/
BEFORE_REDO: 'before_redo'
};
/**
* The key for the listener for the completion of the asynchronous state whose
* undo or redo action is in progress. Null if no action is in progress.
* @type {goog.events.Key}
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.inProgressActionKey_ = null;
/**
* Set the max undo stack depth (not the real memory usage).
* @param {number} depth Depth of the stack.
*/
goog.editor.plugins.UndoRedoManager.prototype.setMaxUndoDepth =
function(depth) {
this.maxUndoDepth_ = depth;
};
/**
* Add state to the undo stack. This clears the redo stack.
*
* @param {goog.editor.plugins.UndoRedoState} state The state to add to the undo
* stack.
*/
goog.editor.plugins.UndoRedoManager.prototype.addState = function(state) {
// TODO: is the state.equals check necessary?
if (this.undoStack_.length == 0 ||
!state.equals(this.undoStack_[this.undoStack_.length - 1])) {
this.undoStack_.push(state);
if (this.undoStack_.length > this.maxUndoDepth_) {
this.undoStack_.shift();
}
// Clobber the redo stack.
var redoLength = this.redoStack_.length;
this.redoStack_.length = 0;
this.dispatchEvent({
type: goog.editor.plugins.UndoRedoManager.EventType.STATE_ADDED,
state: state
});
// If the redo state had states on it, then clobbering the redo stack above
// has caused a state change.
if (this.undoStack_.length == 1 || redoLength) {
this.dispatchStateChange_();
}
}
};
/**
* Dispatches a STATE_CHANGE event with this manager as the target.
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.dispatchStateChange_ =
function() {
this.dispatchEvent(
goog.editor.plugins.UndoRedoManager.EventType.STATE_CHANGE);
};
/**
* Performs the undo operation of the state at the top of the undo stack, moving
* that state to the top of the redo stack. If the undo stack is empty, does
* nothing.
*/
goog.editor.plugins.UndoRedoManager.prototype.undo = function() {
this.shiftState_(this.undoStack_, this.redoStack_);
};
/**
* Performs the redo operation of the state at the top of the redo stack, moving
* that state to the top of the undo stack. If redo undo stack is empty, does
* nothing.
*/
goog.editor.plugins.UndoRedoManager.prototype.redo = function() {
this.shiftState_(this.redoStack_, this.undoStack_);
};
/**
* @return {boolean} Wether the undo stack has items on it, i.e., if it is
* possible to perform an undo operation.
*/
goog.editor.plugins.UndoRedoManager.prototype.hasUndoState = function() {
return this.undoStack_.length > 0;
};
/**
* @return {boolean} Wether the redo stack has items on it, i.e., if it is
* possible to perform a redo operation.
*/
goog.editor.plugins.UndoRedoManager.prototype.hasRedoState = function() {
return this.redoStack_.length > 0;
};
/**
* Move a state from one stack to the other, performing the appropriate undo
* or redo action.
*
* @param {Array<goog.editor.plugins.UndoRedoState>} fromStack Stack to move
* the state from.
* @param {Array<goog.editor.plugins.UndoRedoState>} toStack Stack to move
* the state to.
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.shiftState_ = function(
fromStack, toStack) {
if (fromStack.length) {
var state = fromStack.pop();
// Push the current state into the redo stack.
toStack.push(state);
this.addAction_({
type: fromStack == this.undoStack_ ?
goog.editor.plugins.UndoRedoManager.EventType.BEFORE_UNDO :
goog.editor.plugins.UndoRedoManager.EventType.BEFORE_REDO,
func: fromStack == this.undoStack_ ? state.undo : state.redo,
state: state
});
// If either stack transitioned between 0 and 1 in size then the ability
// to do an undo or redo has changed and we must dispatch a state change.
if (fromStack.length == 0 || toStack.length == 1) {
this.dispatchStateChange_();
}
}
};
/**
* Adds an action to the queue of pending undo or redo actions. If no actions
* are pending, immediately performs the action.
*
* @param {Object} action An undo or redo action. Stored as an object with two
* properties: func and state. The func property stores the undo or redo
* function to be called, the state property stores the state that method
* came from.
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.addAction_ = function(action) {
this.pendingActions_.push(action);
if (this.pendingActions_.length == 1) {
this.doAction_();
}
};
/**
* Executes the action at the front of the pending actions queue. If an action
* is already in progress or the queue is empty, does nothing.
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.doAction_ = function() {
if (this.inProgressActionKey_ || this.pendingActions_.length == 0) {
return;
}
var action = this.pendingActions_.shift();
var e = {
type: action.type,
state: action.state
};
if (this.dispatchEvent(e)) {
if (action.state.isAsynchronous()) {
this.inProgressActionKey_ = goog.events.listen(action.state,
goog.editor.plugins.UndoRedoState.ACTION_COMPLETED,
this.finishAction_, false, this);
action.func.call(action.state);
} else {
action.func.call(action.state);
this.doAction_();
}
}
};
/**
* Finishes processing the current in progress action, starting the next queued
* action if one exists.
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.finishAction_ = function() {
goog.events.unlistenByKey(/** @type {number} */ (this.inProgressActionKey_));
this.inProgressActionKey_ = null;
this.doAction_();
};
/**
* Clears the undo and redo stacks.
*/
goog.editor.plugins.UndoRedoManager.prototype.clearHistory = function() {
if (this.undoStack_.length > 0 || this.redoStack_.length > 0) {
this.undoStack_.length = 0;
this.redoStack_.length = 0;
this.dispatchStateChange_();
}
};
/**
* @return {goog.editor.plugins.UndoRedoState|undefined} The state at the top of
* the undo stack without removing it from the stack.
*/
goog.editor.plugins.UndoRedoManager.prototype.undoPeek = function() {
return this.undoStack_[this.undoStack_.length - 1];
};
/**
* @return {goog.editor.plugins.UndoRedoState|undefined} The state at the top of
* the redo stack without removing it from the stack.
*/
goog.editor.plugins.UndoRedoManager.prototype.redoPeek = function() {
return this.redoStack_[this.redoStack_.length - 1];
};
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
@author ajp@google.com (Andy Perelson)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Trogedit Unit Tests - goog.editor.plugins.UndoRedoManager
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.UndoRedoManagerTest');
</script>
</head>
<body>
</body>
</html>
@@ -0,0 +1,387 @@
// 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.
goog.provide('goog.editor.plugins.UndoRedoManagerTest');
goog.setTestOnly('goog.editor.plugins.UndoRedoManagerTest');
goog.require('goog.editor.plugins.UndoRedoManager');
goog.require('goog.editor.plugins.UndoRedoState');
goog.require('goog.events');
goog.require('goog.testing.StrictMock');
goog.require('goog.testing.jsunit');
var mockState1;
var mockState2;
var mockState3;
var states;
var manager;
var stateChangeCount;
var beforeUndoCount;
var beforeRedoCount;
var preventDefault;
function setUp() {
manager = new goog.editor.plugins.UndoRedoManager();
stateChangeCount = 0;
goog.events.listen(manager,
goog.editor.plugins.UndoRedoManager.EventType.STATE_CHANGE,
function() {
stateChangeCount++;
});
beforeUndoCount = 0;
preventDefault = false;
goog.events.listen(manager,
goog.editor.plugins.UndoRedoManager.EventType.BEFORE_UNDO,
function(e) {
beforeUndoCount++;
if (preventDefault) {
e.preventDefault();
}
});
beforeRedoCount = 0;
goog.events.listen(manager,
goog.editor.plugins.UndoRedoManager.EventType.BEFORE_REDO,
function(e) {
beforeRedoCount++;
if (preventDefault) {
e.preventDefault();
}
});
mockState1 = new goog.testing.StrictMock(goog.editor.plugins.UndoRedoState);
mockState2 = new goog.testing.StrictMock(goog.editor.plugins.UndoRedoState);
mockState3 = new goog.testing.StrictMock(goog.editor.plugins.UndoRedoState);
states = [mockState1, mockState2, mockState3];
mockState1.equals = mockState2.equals = mockState3.equals = function(state) {
return this == state;
};
mockState1.isAsynchronous = mockState2.isAsynchronous =
mockState3.isAsynchronous = function() {
return false;
};
}
function tearDown() {
goog.events.removeAll(manager);
manager.dispose();
}
/**
* Adds all the mock states to the undo-redo manager.
*/
function addStatesToManager() {
manager.addState(states[0]);
for (var i = 1; i < states.length; i++) {
var state = states[i];
manager.addState(state);
}
stateChangeCount = 0;
}
/**
* Resets all mock states so that they are ready for testing.
*/
function resetStates() {
for (var i = 0; i < states.length; i++) {
states[i].$reset();
}
}
function testSetMaxUndoDepth() {
manager.setMaxUndoDepth(2);
addStatesToManager();
assertArrayEquals('Undo stack must contain only the two most recent states.',
[mockState2, mockState3], manager.undoStack_);
}
function testAddState() {
var stateAddedCount = 0;
goog.events.listen(manager,
goog.editor.plugins.UndoRedoManager.EventType.STATE_ADDED,
function() {
stateAddedCount++;
});
manager.addState(mockState1);
assertArrayEquals('Undo stack must contain added state.',
[mockState1], manager.undoStack_);
assertEquals('Manager must dispatch one state change event on ' +
'undo stack 0->1 transition.', 1, stateChangeCount);
assertEquals('State added must have dispatched once.', 1, stateAddedCount);
mockState1.$reset();
// Test adding same state twice.
manager.addState(mockState1);
assertArrayEquals('Undo stack must not contain two equal, sequential states.',
[mockState1], manager.undoStack_);
assertEquals('Manager must not dispatch state change event when nothing is ' +
'added to the stack.', 1, stateChangeCount);
assertEquals('State added must have dispatched once.', 1, stateAddedCount);
// Test adding a second state.
manager.addState(mockState2);
assertArrayEquals('Undo stack must contain both states.',
[mockState1, mockState2], manager.undoStack_);
assertEquals('Manager must not dispatch state change event when second ' +
'state is added to the stack.', 1, stateChangeCount);
assertEquals('State added must have dispatched twice.', 2, stateAddedCount);
// Test adding a state when there is state on the redo stack.
manager.undo();
assertEquals('Manager must dispatch state change when redo stack goes to 1.',
2, stateChangeCount);
manager.addState(mockState3);
assertArrayEquals('Undo stack must contain states 1 and 3.',
[mockState1, mockState3], manager.undoStack_);
assertEquals('Manager must dispatch state change event when redo stack ' +
'goes to zero.', 3, stateChangeCount);
assertEquals('State added must have dispatched three times.',
3, stateAddedCount);
}
function testHasState() {
assertFalse('New manager must have no undo state.', manager.hasUndoState());
assertFalse('New manager must have no redo state.', manager.hasRedoState());
manager.addState(mockState1);
assertTrue('Manager must have only undo state.', manager.hasUndoState());
assertFalse('Manager must have no redo state.', manager.hasRedoState());
manager.undo();
assertFalse('Manager must have no undo state.', manager.hasUndoState());
assertTrue('Manager must have only redo state.', manager.hasRedoState());
}
function testClearHistory() {
addStatesToManager();
manager.undo();
stateChangeCount = 0;
manager.clearHistory();
assertFalse('Undo stack must be empty.', manager.hasUndoState());
assertFalse('Redo stack must be empty.', manager.hasRedoState());
assertEquals('State change count must be 1 after clear history.',
1, stateChangeCount);
manager.clearHistory();
assertEquals('Repeated clearHistory must not change state change count.',
1, stateChangeCount);
}
function testUndo() {
addStatesToManager();
mockState3.undo();
mockState3.$replay();
manager.undo();
assertEquals('Adding first item to redo stack must dispatch state change.',
1, stateChangeCount);
assertEquals('Undo must cause before action to dispatch',
1, beforeUndoCount);
mockState3.$verify();
preventDefault = true;
mockState2.$replay();
manager.undo();
assertEquals('No stack transitions between 0 and 1, must not dispatch ' +
'state change.', 1, stateChangeCount);
assertEquals('Undo must cause before action to dispatch',
2, beforeUndoCount);
mockState2.$verify(); // Verify that undo was prevented.
preventDefault = false;
mockState1.undo();
mockState1.$replay();
manager.undo();
assertEquals('Doing last undo operation must dispatch state change.',
2, stateChangeCount);
assertEquals('Undo must cause before action to dispatch',
3, beforeUndoCount);
mockState1.$verify();
}
function testUndo_Asynchronous() {
// Using a stub instead of a mock here so that the state can behave as an
// EventTarget and dispatch events.
var stubState = new goog.editor.plugins.UndoRedoState(true);
var undoCalled = false;
stubState.undo = function() {
undoCalled = true;
};
stubState.redo = goog.nullFunction;
stubState.equals = function() {
return false;
};
manager.addState(mockState2);
manager.addState(mockState1);
manager.addState(stubState);
manager.undo();
assertTrue('undoCalled must be true (undo must be called).', undoCalled);
assertEquals('Undo must cause before action to dispatch',
1, beforeUndoCount);
// Calling undo shouldn't actually undo since the first async undo hasn't
// fired an event yet.
mockState1.$replay();
manager.undo();
mockState1.$verify();
assertEquals('Before action must not dispatch for pending undo.',
1, beforeUndoCount);
// Dispatching undo completed on first undo, should cause the second pending
// undo to happen.
mockState1.$reset();
mockState1.undo();
mockState1.$replay();
mockState2.$replay(); // Nothing should happen to mockState2.
stubState.dispatchEvent(goog.editor.plugins.UndoRedoState.ACTION_COMPLETED);
mockState1.$verify();
mockState2.$verify();
assertEquals('Second undo must cause before action to dispatch',
2, beforeUndoCount);
// Test last undo.
mockState2.$reset();
mockState2.undo();
mockState2.$replay();
manager.undo();
mockState2.$verify();
assertEquals('Third undo must cause before action to dispatch',
3, beforeUndoCount);
}
function testRedo() {
addStatesToManager();
manager.undo();
manager.undo();
manager.undo();
resetStates();
stateChangeCount = 0;
mockState1.redo();
mockState1.$replay();
manager.redo();
assertEquals('Pushing first item onto undo stack during redo must dispatch ' +
'state change.', 1, stateChangeCount);
assertEquals('First redo must cause before action to dispatch',
1, beforeRedoCount);
mockState1.$verify();
preventDefault = true;
mockState2.$replay();
manager.redo();
assertEquals('No stack transitions between 0 and 1, must not dispatch ' +
'state change.', 1, stateChangeCount);
assertEquals('Second redo must cause before action to dispatch',
2, beforeRedoCount);
mockState2.$verify(); // Verify that redo was prevented.
preventDefault = false;
mockState3.redo();
mockState3.$replay();
manager.redo();
assertEquals('Removing last item from redo stack must dispatch state change.',
2, stateChangeCount);
assertEquals('Third redo must cause before action to dispatch',
3, beforeRedoCount);
mockState3.$verify();
mockState3.$reset();
mockState3.undo();
mockState3.$replay();
manager.undo();
assertEquals('Putting item on redo stack must dispatch state change.',
3, stateChangeCount);
assertEquals('Undo must cause before action to dispatch',
4, beforeUndoCount);
mockState3.$verify();
}
function testRedo_Asynchronous() {
var stubState = new goog.editor.plugins.UndoRedoState(true);
var redoCalled = false;
stubState.redo = function() {
redoCalled = true;
};
stubState.undo = goog.nullFunction;
stubState.equals = function() {
return false;
};
manager.addState(stubState);
manager.addState(mockState1);
manager.addState(mockState2);
manager.undo();
manager.undo();
manager.undo();
stubState.dispatchEvent(goog.editor.plugins.UndoRedoState.ACTION_COMPLETED);
resetStates();
manager.redo();
assertTrue('redoCalled must be true (redo must be called).', redoCalled);
// Calling redo shouldn't actually redo since the first async redo hasn't
// fired an event yet.
mockState1.$replay();
manager.redo();
mockState1.$verify();
// Dispatching redo completed on first redo, should cause the second pending
// redo to happen.
mockState1.$reset();
mockState1.redo();
mockState1.$replay();
mockState2.$replay(); // Nothing should happen to mockState1.
stubState.dispatchEvent(goog.editor.plugins.UndoRedoState.ACTION_COMPLETED);
mockState1.$verify();
mockState2.$verify();
// Test last redo.
mockState2.$reset();
mockState2.redo();
mockState2.$replay();
manager.redo();
mockState2.$verify();
}
function testUndoAndRedoPeek() {
addStatesToManager();
manager.undo();
assertEquals('redoPeek must return the top of the redo stack.',
manager.redoStack_[manager.redoStack_.length - 1], manager.redoPeek());
assertEquals('undoPeek must return the top of the undo stack.',
manager.undoStack_[manager.undoStack_.length - 1], manager.undoPeek());
}
@@ -0,0 +1,86 @@
// 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 Code for an UndoRedoState interface representing an undo and
* redo action for a particular state change. To be used by
* {@link goog.editor.plugins.UndoRedoManager}.
*
*/
goog.provide('goog.editor.plugins.UndoRedoState');
goog.require('goog.events.EventTarget');
/**
* Represents an undo and redo action for a particular state transition.
*
* @param {boolean} asynchronous Whether the undo or redo actions for this
* state complete asynchronously. If true, then this state must fire
* an ACTION_COMPLETED event when undo or redo is complete.
* @constructor
* @extends {goog.events.EventTarget}
*/
goog.editor.plugins.UndoRedoState = function(asynchronous) {
goog.editor.plugins.UndoRedoState.base(this, 'constructor');
/**
* Indicates if the undo or redo actions for this state complete
* asynchronously.
* @type {boolean}
* @private
*/
this.asynchronous_ = asynchronous;
};
goog.inherits(goog.editor.plugins.UndoRedoState, goog.events.EventTarget);
/**
* Event type for events indicating that this state has completed an undo or
* redo operation.
*/
goog.editor.plugins.UndoRedoState.ACTION_COMPLETED = 'action_completed';
/**
* @return {boolean} Whether or not the undo and redo actions of this state
* complete asynchronously. If true, the state will fire an ACTION_COMPLETED
* event when an undo or redo action is complete.
*/
goog.editor.plugins.UndoRedoState.prototype.isAsynchronous = function() {
return this.asynchronous_;
};
/**
* Undoes the action represented by this state.
*/
goog.editor.plugins.UndoRedoState.prototype.undo = goog.abstractMethod;
/**
* Redoes the action represented by this state.
*/
goog.editor.plugins.UndoRedoState.prototype.redo = goog.abstractMethod;
/**
* Checks if two undo-redo states are the same.
* @param {goog.editor.plugins.UndoRedoState} state The state to compare.
* @return {boolean} Wether the two states are equal.
*/
goog.editor.plugins.UndoRedoState.prototype.equals = goog.abstractMethod;
@@ -0,0 +1,28 @@
goog.editor.plugins
<!DOCTYPE html>
<!--
All Rights Reserved.
@author ajp@google.com (Andy Perelson)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Trogedit Unit Tests - goog.editor.plugins.UndoRedoState
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.UndoRedoStateTest');
</script>
</head>
<body>
</body>
</html>
@@ -0,0 +1,34 @@
// 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.
goog.provide('goog.editor.plugins.UndoRedoStateTest');
goog.setTestOnly('goog.editor.plugins.UndoRedoStateTest');
goog.require('goog.editor.plugins.UndoRedoState');
goog.require('goog.testing.jsunit');
var asyncState;
var syncState;
function setUp() {
asyncState = new goog.editor.plugins.UndoRedoState(true);
syncState = new goog.editor.plugins.UndoRedoState(false);
}
function testIsAsynchronous() {
assertTrue('Must return true for asynchronous state',
asyncState.isAsynchronous());
assertFalse('Must return false for synchronous state',
syncState.isAsynchronous());
}