Adding mapbox-gl branch
This commit is contained in:
@@ -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 = ' '; // 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 = ' ';
|
||||
|
||||
// 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> </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 ? ' ' : '') +
|
||||
'</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 ? ' ' : '') +
|
||||
'</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 ? ' ' : '') +
|
||||
'</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 ? ' ' : '') +
|
||||
'</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 = ' ';
|
||||
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 ',
|
||||
-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 ',
|
||||
-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> </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> </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> </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> </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> </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, shows up as its unicode character in
|
||||
// innerHTML under some circumstances.
|
||||
return (goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('528')) ||
|
||||
goog.userAgent.OPERA ? '\u00a0' : ' ';
|
||||
}
|
||||
|
||||
|
||||
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> </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. <div>) or a whole list (e.g. <ul>).
|
||||
*
|
||||
* 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"> </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"> </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> English</div>' +
|
||||
'<div id="text2">2></div>' +
|
||||
'<div id="text3">3></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> עברית</div>' +
|
||||
'<div dir="rtl" id="text2">2></div>' +
|
||||
'<div dir="rtl" id="text3">3></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> English</div>' +
|
||||
'<ul id="list">' +
|
||||
'<li>10></li>' +
|
||||
'<li id="li2"></li>' +
|
||||
'<li>30</li>' +
|
||||
'</ul>' +
|
||||
'<div id="text3">3></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> English</div>' +
|
||||
'<ul dir="rtl" id="list">' +
|
||||
'<li>10></li>' +
|
||||
'<li id="li2"></li>' +
|
||||
'<li>30</li>' +
|
||||
'</ul>' +
|
||||
'<div dir="rtl" id="text3">3></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></li>' +
|
||||
'<li id="li2"></li>' +
|
||||
'<li>30<b>3<i>Hidden English</i>32</b></li>' +
|
||||
'</ul>' +
|
||||
'<div id="text3">3></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> English</div>' +
|
||||
'<ul dir="rtl" id="list">' +
|
||||
'<li>10></li>' +
|
||||
'<li id="li2"></li>' +
|
||||
'<li>30<b>3<i>עברית סמויה</i>32</b></li>' +
|
||||
'</ul>' +
|
||||
'<div dir="rtl" id="text3">3></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, ' ', 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, ' ', 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, shows up as its unicode character in
|
||||
// innerHTML under some circumstances.
|
||||
return (goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('528')) ||
|
||||
goog.userAgent.OPERA ? '\u00a0' : ' ';
|
||||
}
|
||||
@@ -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 .
|
||||
var nbspRegExp =
|
||||
goog.userAgent.isVersionOrHigher('528') ? / /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 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&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 -</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> - <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> - <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&hl=en"><nobr><b>' +
|
||||
'all 950 news articles �</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&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&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> </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[^>]*\/?>| )[\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> </li>...</ul>
|
||||
// 2. <ul id='foo1'>...<li id='foo2'> </li>...</ul>
|
||||
// 3. <ul id='foo1'>...</ul><p id='foo3'> </p><ul id='foo2'>...</ul>
|
||||
// 4. <ul>...</ul><p> </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 = ' ';
|
||||
|
||||
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 = ' ';
|
||||
}
|
||||
|
||||
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' +
|
||||
' ',
|
||||
' ', 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> </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> </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> </p>');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function testEnterInEmptyListItemAtBeginningOfList() {
|
||||
if (goog.userAgent.GECKO) {
|
||||
field1.setHtml(false,
|
||||
'<ul style="font-weight: bold">' +
|
||||
'<li> </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> </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> </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> </p>');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function testEnterInEmptyListItemInMiddleOfList() {
|
||||
if (goog.userAgent.GECKO) {
|
||||
field1.setHtml(false,
|
||||
'<ul style="font-weight: bold">' +
|
||||
'<li>1</li>' +
|
||||
'<li> </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> </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> </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> </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> </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> </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> </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> </li>' +
|
||||
'<li>B</li>' +
|
||||
'</ul>');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function testPrepareContentForPOnEnter() {
|
||||
assertPreparedContents('hi', 'hi');
|
||||
assertPreparedContents(
|
||||
goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES ? '<p> </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, ' '));
|
||||
assertEquals('new node should have second half of the html',
|
||||
secondHalfString,
|
||||
secondHalf.innerHTML.toLowerCase().
|
||||
replace(goog.string.Unicode.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> </i></b>', '<b><i>italic</i>end bold</b>');
|
||||
testFn(6, '<b>begin bold<i>italic</i></b>', '<b><i> </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(' test ', node.innerHTML);
|
||||
|
||||
textNode.nodeValue = ' test ';
|
||||
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
|
||||
node.firstChild, true, false);
|
||||
assertHTMLEquals(' test ', node.innerHTML);
|
||||
|
||||
textNode.nodeValue = ' test ';
|
||||
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
|
||||
node.firstChild, false, false);
|
||||
assertHTMLEquals(' test ', node.innerHTML);
|
||||
|
||||
textNode.nodeValue = ' test ';
|
||||
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
|
||||
node.firstChild, false, false);
|
||||
assertHTMLEquals(' test ', node.innerHTML);
|
||||
|
||||
textNode.nodeValue = '';
|
||||
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
|
||||
node.firstChild, false, false);
|
||||
assertHTMLEquals(' ', 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());
|
||||
}
|
||||
Reference in New Issue
Block a user