Adding mapbox-gl branch

This commit is contained in:
Andreas Hocevar
2015-03-16 18:50:27 +01:00
parent 7985f030fa
commit 57ee7f52fd
3109 changed files with 943365 additions and 0 deletions
@@ -0,0 +1,273 @@
// 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 Trogedit constants for browser features and quirks that should
* be used by the rich text editor.
*/
goog.provide('goog.editor.BrowserFeature');
goog.require('goog.editor.defines');
goog.require('goog.userAgent');
goog.require('goog.userAgent.product');
goog.require('goog.userAgent.product.isVersion');
/**
* Maps browser quirks to boolean values, detailing what the current
* browser supports.
* @const
*/
goog.editor.BrowserFeature = {
// Whether this browser uses the IE TextRange object.
HAS_IE_RANGES: goog.userAgent.IE && !goog.userAgent.isDocumentModeOrHigher(9),
// Whether this browser uses the W3C standard Range object.
// Assumes IE higher versions will be compliance with W3C standard.
HAS_W3C_RANGES: goog.userAgent.GECKO || goog.userAgent.WEBKIT ||
goog.userAgent.OPERA ||
(goog.userAgent.IE && goog.userAgent.isDocumentModeOrHigher(9)),
// Has the contentEditable attribute, which makes nodes editable.
//
// NOTE(nicksantos): FF3 has contentEditable, but there are 3 major reasons
// why we don't use it:
// 1) In FF3, we listen for key events on the document, and we'd have to
// filter them properly. See TR_Browser.USE_DOCUMENT_FOR_KEY_EVENTS.
// 2) In FF3, we listen for focus/blur events on the document, which
// simply doesn't make sense in contentEditable. focus/blur
// on contentEditable elements still has some quirks, which we're
// talking to Firefox-team about.
// 3) We currently use Mutation events in FF3 to detect changes,
// and these are dispatched on the document only.
// If we ever hope to support FF3/contentEditable, all 3 of these issues
// will need answers. Most just involve refactoring at our end.
HAS_CONTENT_EDITABLE: goog.userAgent.IE || goog.userAgent.WEBKIT ||
goog.userAgent.OPERA ||
(goog.editor.defines.USE_CONTENTEDITABLE_IN_FIREFOX_3 &&
goog.userAgent.GECKO && goog.userAgent.isVersionOrHigher('1.9')),
// Whether to use mutation event types to detect changes
// in the field contents.
USE_MUTATION_EVENTS: goog.userAgent.GECKO,
// Whether the browser has a functional DOMSubtreeModified event.
// TODO(user): Enable for all FF3 once we're confident this event fires
// reliably. Currently it's only enabled if using contentEditable in FF as
// we have no other choice in that case but to use this event.
HAS_DOM_SUBTREE_MODIFIED_EVENT: goog.userAgent.WEBKIT ||
(goog.editor.defines.USE_CONTENTEDITABLE_IN_FIREFOX_3 &&
goog.userAgent.GECKO && goog.userAgent.isVersionOrHigher('1.9')),
// Whether nodes can be copied from one document to another
HAS_DOCUMENT_INDEPENDENT_NODES: goog.userAgent.GECKO,
// Whether the cursor goes before or inside the first block element on
// focus, e.g., <body><p>foo</p></body>. FF will put the cursor before the
// paragraph on focus, which is wrong.
PUTS_CURSOR_BEFORE_FIRST_BLOCK_ELEMENT_ON_FOCUS: goog.userAgent.GECKO,
// Whether the selection of one frame is cleared when another frame
// is focused.
CLEARS_SELECTION_WHEN_FOCUS_LEAVES:
goog.userAgent.IE || goog.userAgent.WEBKIT || goog.userAgent.OPERA,
// Whether "unselectable" is supported as an element style.
HAS_UNSELECTABLE_STYLE: goog.userAgent.GECKO || goog.userAgent.WEBKIT,
// Whether this browser's "FormatBlock" command does not suck.
FORMAT_BLOCK_WORKS_FOR_BLOCKQUOTES: goog.userAgent.GECKO ||
goog.userAgent.WEBKIT || goog.userAgent.OPERA,
// Whether this browser's "FormatBlock" command may create multiple
// blockquotes.
CREATES_MULTIPLE_BLOCKQUOTES:
(goog.userAgent.WEBKIT &&
!goog.userAgent.isVersionOrHigher('534.16')) ||
goog.userAgent.OPERA,
// Whether this browser's "FormatBlock" command will wrap blockquotes
// inside of divs, instead of replacing divs with blockquotes.
WRAPS_BLOCKQUOTE_IN_DIVS: goog.userAgent.OPERA,
// Whether the readystatechange event is more reliable than load.
PREFERS_READY_STATE_CHANGE_EVENT: goog.userAgent.IE,
// Whether hitting the tab key will fire a keypress event.
// see http://www.quirksmode.org/js/keys.html
TAB_FIRES_KEYPRESS: !goog.userAgent.IE,
// Has a standards mode quirk where width=100% doesn't do the right thing,
// but width=99% does.
// TODO(user|user): This should be fixable by less hacky means
NEEDS_99_WIDTH_IN_STANDARDS_MODE: goog.userAgent.IE,
// Whether keyboard events only reliably fire on the document.
// On Gecko without contentEditable, keyboard events only fire reliably on the
// document element. With contentEditable, the field itself is focusable,
// which means that it will fire key events. This does not apply if
// application is using ContentEditableField or otherwise overriding Field
// not to use an iframe.
USE_DOCUMENT_FOR_KEY_EVENTS: goog.userAgent.GECKO &&
!goog.editor.defines.USE_CONTENTEDITABLE_IN_FIREFOX_3,
// Whether this browser shows non-standard attributes in innerHTML.
SHOWS_CUSTOM_ATTRS_IN_INNER_HTML: goog.userAgent.IE,
// Whether this browser shrinks empty nodes away to nothing.
// (If so, we need to insert some space characters into nodes that
// shouldn't be collapsed)
COLLAPSES_EMPTY_NODES:
goog.userAgent.GECKO || goog.userAgent.WEBKIT || goog.userAgent.OPERA,
// Whether we must convert <strong> and <em> tags to <b>, <i>.
CONVERT_TO_B_AND_I_TAGS: goog.userAgent.GECKO || goog.userAgent.OPERA,
// Whether this browser likes to tab through images in contentEditable mode,
// and we like to disable this feature.
TABS_THROUGH_IMAGES: goog.userAgent.IE,
// Whether this browser unescapes urls when you extract it from the href tag.
UNESCAPES_URLS_WITHOUT_ASKING: goog.userAgent.IE &&
!goog.userAgent.isVersionOrHigher('7.0'),
// Whether this browser supports execCommand("styleWithCSS") to toggle between
// inserting html tags or inline styling for things like bold, italic, etc.
HAS_STYLE_WITH_CSS:
goog.userAgent.GECKO && goog.userAgent.isVersionOrHigher('1.8') ||
goog.userAgent.WEBKIT || goog.userAgent.OPERA,
// Whether clicking on an editable link will take you to that site.
FOLLOWS_EDITABLE_LINKS: goog.userAgent.WEBKIT ||
goog.userAgent.IE && goog.userAgent.isVersionOrHigher('9'),
// Whether this browser has document.activeElement available.
HAS_ACTIVE_ELEMENT:
goog.userAgent.IE || goog.userAgent.OPERA ||
goog.userAgent.GECKO && goog.userAgent.isVersionOrHigher('1.9'),
// Whether this browser supports the setCapture method on DOM elements.
HAS_SET_CAPTURE: goog.userAgent.IE,
// Whether this browser can't set background color when the selection
// is collapsed.
EATS_EMPTY_BACKGROUND_COLOR: goog.userAgent.GECKO ||
goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('527'),
// Whether this browser supports the "focusin" or "DOMFocusIn" event
// consistently.
// NOTE(nicksantos): FF supports DOMFocusIn, but doesn't seem to do so
// consistently.
SUPPORTS_FOCUSIN: goog.userAgent.IE || goog.userAgent.OPERA,
// Whether clicking on an image will cause the selection to move to the image.
// Note: Gecko moves the selection, but it won't always go to the image.
// For example, if the image is wrapped in a div, and you click on the img,
// anchorNode = focusNode = div, anchorOffset = 0, focusOffset = 1, so this
// is another way of "selecting" the image, but there are too many special
// cases like this so we will do the work manually.
SELECTS_IMAGES_ON_CLICK: goog.userAgent.IE || goog.userAgent.OPERA,
// Whether this browser moves <style> tags into new <head> elements.
MOVES_STYLE_TO_HEAD: goog.userAgent.WEBKIT,
// Whether this browser collapses the selection in a contenteditable when the
// mouse is pressed in a non-editable portion of the same frame, even if
// Event.preventDefault is called. This field is deprecated and unused -- only
// old versions of Opera have this bug.
COLLAPSES_SELECTION_ONMOUSEDOWN: false,
// Whether the user can actually create a selection in this browser with the
// caret in the MIDDLE of the selection by double-clicking.
CARET_INSIDE_SELECTION: goog.userAgent.OPERA,
// Whether the browser focuses <body contenteditable> automatically when
// the user clicks on <html>. This field is deprecated and unused -- only old
// versions of Opera don't have this behavior.
FOCUSES_EDITABLE_BODY_ON_HTML_CLICK: true,
// Whether to use keydown for key listening (uses keypress otherwise). Taken
// from goog.events.KeyHandler.
USES_KEYDOWN: goog.userAgent.IE ||
goog.userAgent.WEBKIT && goog.userAgent.isVersionOrHigher('525'),
// Whether this browser converts spaces to non-breaking spaces when calling
// execCommand's RemoveFormat.
// See: https://bugs.webkit.org/show_bug.cgi?id=14062
ADDS_NBSPS_IN_REMOVE_FORMAT:
goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('531'),
// Whether the browser will get stuck inside a link. That is, if your cursor
// is after a link and you type, does your text go inside the link tag.
// Bug: http://bugs.webkit.org/show_bug.cgi?id=17697
GETS_STUCK_IN_LINKS:
goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('528'),
// Whether the browser corrupts empty text nodes in Node#normalize,
// removing them from the Document instead of merging them.
NORMALIZE_CORRUPTS_EMPTY_TEXT_NODES: goog.userAgent.GECKO &&
goog.userAgent.isVersionOrHigher('1.9') || goog.userAgent.IE ||
goog.userAgent.OPERA ||
goog.userAgent.WEBKIT && goog.userAgent.isVersionOrHigher('531'),
// Whether the browser corrupts all text nodes in Node#normalize,
// removing them from the Document instead of merging them.
NORMALIZE_CORRUPTS_ALL_TEXT_NODES: goog.userAgent.IE,
// Browsers where executing subscript then superscript (or vv) will cause both
// to be applied in a nested fashion instead of the first being overwritten by
// the second.
NESTS_SUBSCRIPT_SUPERSCRIPT: goog.userAgent.IE || goog.userAgent.GECKO ||
goog.userAgent.OPERA,
// Whether this browser can place a cursor in an empty element natively.
CAN_SELECT_EMPTY_ELEMENT: !goog.userAgent.IE && !goog.userAgent.WEBKIT,
FORGETS_FORMATTING_WHEN_LISTIFYING: goog.userAgent.GECKO ||
goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('526'),
LEAVES_P_WHEN_REMOVING_LISTS: goog.userAgent.IE || goog.userAgent.OPERA,
CAN_LISTIFY_BR: !goog.userAgent.IE && !goog.userAgent.OPERA,
// See bug 1286408. When somewhere inside your selection there is an element
// with a style attribute that sets the font size, if you change the font
// size, the browser creates a font tag, but the font size in the style attr
// overrides the font tag. Only webkit removes that font size from the style
// attr.
DOESNT_OVERRIDE_FONT_SIZE_IN_STYLE_ATTR: !goog.userAgent.WEBKIT,
// Implements this spec about dragging files from the filesystem to the
// browser: http://www.whatwg/org/specs/web-apps/current-work/#dnd
SUPPORTS_HTML5_FILE_DRAGGING: (goog.userAgent.product.CHROME &&
goog.userAgent.product.isVersion('4')) ||
(goog.userAgent.product.SAFARI &&
goog.userAgent.isVersionOrHigher('533')) ||
(goog.userAgent.GECKO &&
goog.userAgent.isVersionOrHigher('2.0')) ||
(goog.userAgent.IE &&
goog.userAgent.isVersionOrHigher('10')),
// Version of Opera that supports the opera-defaultBlock execCommand to change
// the default block inserted when [return] is pressed. Note that this only is
// used if the caret is not already in a block that can be repeated.
// TODO(user): Link to public documentation of this feature if Opera puts
// something up about it.
SUPPORTS_OPERA_DEFAULTBLOCK_COMMAND:
goog.userAgent.OPERA && goog.userAgent.isVersionOrHigher('11.10'),
SUPPORTS_FILE_PASTING: goog.userAgent.product.CHROME &&
goog.userAgent.product.isVersion('12')
};
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<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.
-->
<!--
Ensures that goog.editor.BrowserFeature is up-to-date with the latest
bug fixes from browser manufacturers.
@author nicksantos@google.com (Nick Santos)
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.BrowserFeature
</title>
<script src="../base.js">
</script>
<script>
goog.require('goog.editor.BrowserFeatureTest');
</script>
</head>
<body>
<div id="root" contentEditable></div>
<div id="testNormalizeCorruption">text</div>
</body>
</html>
@@ -0,0 +1,112 @@
// 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.BrowserFeatureTest');
goog.setTestOnly('goog.editor.BrowserFeatureTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.testing.ExpectedFailures');
goog.require('goog.testing.jsunit');
var expectedFailures;
function setUpPage() {
expectedFailures = new goog.testing.ExpectedFailures();
}
function tearDown() {
var root = goog.dom.getElement('root');
goog.dom.removeChildren(root);
expectedFailures.handleTearDown();
}
function testEmptyNodeNormalization() {
var root = goog.dom.getElement('root');
goog.dom.appendChild(root, goog.dom.createTextNode('text'));
var textNode = root.firstChild;
textNode.splitText(0);
root.normalize();
assertEquals(
'NORMALIZE_CORRUPTS_EMPTY_TEXT_NODES incorrect for ' +
navigator.userAgent,
goog.editor.BrowserFeature.NORMALIZE_CORRUPTS_EMPTY_TEXT_NODES,
textNode.parentNode == null);
}
function testLeavesPWhenRemovingLists() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
return;
}
var root = goog.dom.getElement('root');
goog.dom.removeChildren(root);
root.innerHTML = '<div>foo</div>';
goog.dom.Range.createFromNodeContents(root.firstChild.firstChild).select();
document.execCommand('insertorderedlist', false, true);
document.execCommand('insertorderedlist', false, true);
assertEquals(
'LEAVES_P_WHEN_REMOVING_LISTS incorrect for ' +
navigator.userAgent,
goog.editor.BrowserFeature.LEAVES_P_WHEN_REMOVING_LISTS,
!!root.getElementsByTagName('p').length);
}
function testActiveElement() {
var root = goog.dom.getElement('root');
var div = goog.dom.createElement('div');
root.appendChild(div);
div.tabIndex = 0;
div.focus();
expectedFailures.expectFailureFor(
!goog.editor.BrowserFeature.HAS_ACTIVE_ELEMENT);
try {
assertEquals('document.activeElement should be the created div',
div, document.activeElement);
} catch (e) {
expectedFailures.handleException(e);
}
}
function testNormalizeCorruption() {
var root = goog.dom.getElement('testNormalizeCorruption');
var textNode = root.firstChild;
textNode.splitText(0);
var secondTextNode = textNode.nextSibling;
root.normalize();
expectedFailures.expectFailureFor(
goog.editor.BrowserFeature.NORMALIZE_CORRUPTS_EMPTY_TEXT_NODES);
try {
assertEquals('text node should not be corrupted',
textNode, root.firstChild);
} catch (e) {
expectedFailures.handleException(e);
expectedFailures.expectFailureFor(
goog.editor.BrowserFeature.NORMALIZE_CORRUPTS_ALL_TEXT_NODES);
try {
assertEquals(
'first text node should be corrupted and replaced by sibling',
secondTextNode, root.firstChild);
} catch (e) {
expectedFailures.handleException(e);
}
}
}
@@ -0,0 +1,434 @@
// Copyright 2007 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 wrapper around a goog.editor.Field
* that listens to mouse events on the specified un-editable field, and makes
* the field editable if the user clicks on it. Clients are still responsible
* for determining when to make the field un-editable again.
*
* Clients can still determine when the field has loaded by listening to
* field's load event.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.ClickToEditWrapper');
goog.require('goog.Disposable');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Command');
goog.require('goog.editor.Field');
goog.require('goog.editor.range');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.EventHandler');
goog.require('goog.events.EventType');
goog.require('goog.log');
/**
* Initialize the wrapper, and begin listening to mouse events immediately.
* @param {goog.editor.Field} fieldObj The editable field being wrapped.
* @constructor
* @extends {goog.Disposable}
*/
goog.editor.ClickToEditWrapper = function(fieldObj) {
goog.Disposable.call(this);
/**
* The field this wrapper interacts with.
* @type {goog.editor.Field}
* @private
*/
this.fieldObj_ = fieldObj;
/**
* DOM helper for the field's original element.
* @type {goog.dom.DomHelper}
* @private
*/
this.originalDomHelper_ = goog.dom.getDomHelper(
fieldObj.getOriginalElement());
/**
* @type {goog.dom.SavedCaretRange}
* @private
*/
this.savedCaretRange_ = null;
/**
* Event handler for field related events.
* @type {!goog.events.EventHandler<!goog.editor.ClickToEditWrapper>}
* @private
*/
this.fieldEventHandler_ = new goog.events.EventHandler(this);
/**
* Bound version of the finishMouseUp method.
* @type {Function}
* @private
*/
this.finishMouseUpBound_ = goog.bind(this.finishMouseUp_, this);
/**
* Event handler for mouse events.
* @type {!goog.events.EventHandler<!goog.editor.ClickToEditWrapper>}
* @private
*/
this.mouseEventHandler_ = new goog.events.EventHandler(this);
// Start listening to mouse events immediately if necessary.
if (!this.fieldObj_.isLoaded()) {
this.enterDocument();
}
this.fieldEventHandler_.
// Whenever the field is made editable, we need to check if there
// are any carets in it, and if so, use them to render the selection.
listen(
this.fieldObj_, goog.editor.Field.EventType.LOAD,
this.renderSelection_).
// Whenever the field is made uneditable, we need to set up
// the click-to-edit listeners.
listen(
this.fieldObj_, goog.editor.Field.EventType.UNLOAD,
this.enterDocument);
};
goog.inherits(goog.editor.ClickToEditWrapper, goog.Disposable);
/**
* The logger for this class.
* @type {goog.log.Logger}
* @private
*/
goog.editor.ClickToEditWrapper.prototype.logger_ =
goog.log.getLogger('goog.editor.ClickToEditWrapper');
/** @return {goog.editor.Field} The field. */
goog.editor.ClickToEditWrapper.prototype.getFieldObject = function() {
return this.fieldObj_;
};
/** @return {goog.dom.DomHelper} The dom helper of the uneditable element. */
goog.editor.ClickToEditWrapper.prototype.getOriginalDomHelper = function() {
return this.originalDomHelper_;
};
/** @override */
goog.editor.ClickToEditWrapper.prototype.disposeInternal = function() {
goog.editor.ClickToEditWrapper.base(this, 'disposeInternal');
this.exitDocument();
if (this.savedCaretRange_) {
this.savedCaretRange_.dispose();
}
this.fieldEventHandler_.dispose();
this.mouseEventHandler_.dispose();
this.savedCaretRange_ = null;
delete this.fieldEventHandler_;
delete this.mouseEventHandler_;
};
/**
* Initialize listeners when the uneditable field is added to the document.
* Also sets up lorem ipsum text.
*/
goog.editor.ClickToEditWrapper.prototype.enterDocument = function() {
if (this.isInDocument_) {
return;
}
this.isInDocument_ = true;
this.mouseEventTriggeredLoad_ = false;
var field = this.fieldObj_.getOriginalElement();
// To do artificial selection preservation, we have to listen to mouseup,
// get the current selection, and re-select the same text in the iframe.
//
// NOTE(nicksantos): Artificial selection preservation is needed in all cases
// where we set the field contents by setting innerHTML. There are a few
// rare cases where we don't need it. But these cases are highly
// implementation-specific, and computationally hard to detect (bidi
// and ig modules both set innerHTML), so we just do it in all cases.
this.savedAnchorClicked_ = null;
this.mouseEventHandler_.
listen(field, goog.events.EventType.MOUSEUP, this.handleMouseUp_).
listen(field, goog.events.EventType.CLICK, this.handleClick_);
// manage lorem ipsum text, if necessary
this.fieldObj_.execCommand(goog.editor.Command.UPDATE_LOREM);
};
/**
* Destroy listeners when the field is removed from the document.
*/
goog.editor.ClickToEditWrapper.prototype.exitDocument = function() {
this.mouseEventHandler_.removeAll();
this.isInDocument_ = false;
};
/**
* Returns the uneditable field element if the field is not yet editable
* (equivalent to EditableField.getOriginalElement()), and the editable DOM
* element if the field is currently editable (equivalent to
* EditableField.getElement()).
* @return {Element} The element containing the editable field contents.
*/
goog.editor.ClickToEditWrapper.prototype.getElement = function() {
return this.fieldObj_.isLoaded() ?
this.fieldObj_.getElement() : this.fieldObj_.getOriginalElement();
};
/**
* True if a mouse event should be handled, false if it should be ignored.
* @param {goog.events.BrowserEvent} e The mouse event.
* @return {boolean} Wether or not this mouse event should be handled.
* @private
*/
goog.editor.ClickToEditWrapper.prototype.shouldHandleMouseEvent_ = function(e) {
return e.isButton(goog.events.BrowserEvent.MouseButton.LEFT) &&
!(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey);
};
/**
* Handle mouse click events on the field.
* @param {goog.events.BrowserEvent} e The click event.
* @private
*/
goog.editor.ClickToEditWrapper.prototype.handleClick_ = function(e) {
// If the user clicked on a link in an uneditable field,
// we want to cancel the click.
var anchorAncestor = goog.dom.getAncestorByTagNameAndClass(
/** @type {Node} */ (e.target),
goog.dom.TagName.A);
if (anchorAncestor) {
e.preventDefault();
if (!goog.editor.BrowserFeature.HAS_ACTIVE_ELEMENT) {
this.savedAnchorClicked_ = anchorAncestor;
}
}
};
/**
* Handle a mouse up event on the field.
* @param {goog.events.BrowserEvent} e The mouseup event.
* @private
*/
goog.editor.ClickToEditWrapper.prototype.handleMouseUp_ = function(e) {
// Only respond to the left mouse button.
if (this.shouldHandleMouseEvent_(e)) {
// We need to get the selection when the user mouses up, but the
// selection doesn't actually change until after the mouseup event has
// propagated. So we need to do this asynchronously.
this.originalDomHelper_.getWindow().setTimeout(this.finishMouseUpBound_, 0);
}
};
/**
* A helper function for handleMouseUp_ -- does the actual work
* when the event is finished propagating.
* @private
*/
goog.editor.ClickToEditWrapper.prototype.finishMouseUp_ = function() {
// Make sure that the field is still not editable.
if (!this.fieldObj_.isLoaded()) {
if (this.savedCaretRange_) {
this.savedCaretRange_.dispose();
this.savedCaretRange_ = null;
}
if (!this.fieldObj_.queryCommandValue(goog.editor.Command.USING_LOREM)) {
// We need carets (blank span nodes) to maintain the selection when
// the html is copied into an iframe. However, because our code
// clears the selection to make the behavior consistent, we need to do
// this even when we're not using an iframe.
this.insertCarets_();
}
this.ensureFieldEditable_();
}
this.exitDocument();
this.savedAnchorClicked_ = null;
};
/**
* Ensure that the field is editable. If the field is not editable,
* make it so, and record the fact that it was done by a user mouse event.
* @private
*/
goog.editor.ClickToEditWrapper.prototype.ensureFieldEditable_ = function() {
if (!this.fieldObj_.isLoaded()) {
this.mouseEventTriggeredLoad_ = true;
this.makeFieldEditable(this.fieldObj_);
}
};
/**
* Once the field has loaded in an iframe, re-create the selection
* as marked by the carets.
* @private
*/
goog.editor.ClickToEditWrapper.prototype.renderSelection_ = function() {
if (this.savedCaretRange_) {
// Make sure that the restoration document is inside the iframe
// if we're using one.
this.savedCaretRange_.setRestorationDocument(
this.fieldObj_.getEditableDomHelper().getDocument());
var startCaret = this.savedCaretRange_.getCaret(true);
var endCaret = this.savedCaretRange_.getCaret(false);
var hasCarets = startCaret && endCaret;
}
// There are two reasons why we might want to focus the field:
// 1) makeFieldEditable was triggered by the click-to-edit wrapper.
// In this case, the mouse event should have triggered a focus, but
// the editor might have taken the focus away to create lorem ipsum
// text or create an iframe for the field. So we make sure the focus
// is restored.
// 2) somebody placed carets, and we need to select those carets. The field
// needs focus to ensure that the selection appears.
if (this.mouseEventTriggeredLoad_ || hasCarets) {
this.focusOnFieldObj(this.fieldObj_);
}
if (hasCarets) {
var startCaretParent = startCaret.parentNode;
var endCaretParent = endCaret.parentNode;
this.savedCaretRange_.restore();
this.fieldObj_.dispatchSelectionChangeEvent();
// NOTE(nicksantos): Bubbles aren't actually enabled until the end
// if the load sequence, so if the user clicked on a link, the bubble
// will not pop up.
}
if (this.savedCaretRange_) {
this.savedCaretRange_.dispose();
this.savedCaretRange_ = null;
}
this.mouseEventTriggeredLoad_ = false;
};
/**
* Focus on the field object.
* @param {goog.editor.Field} field The field to focus.
* @protected
*/
goog.editor.ClickToEditWrapper.prototype.focusOnFieldObj = function(field) {
field.focusAndPlaceCursorAtStart();
};
/**
* Make the field object editable.
* @param {goog.editor.Field} field The field to make editable.
* @protected
*/
goog.editor.ClickToEditWrapper.prototype.makeFieldEditable = function(field) {
field.makeEditable();
};
//================================================================
// Caret-handling methods
/**
* Gets a saved caret range for the given range.
* @param {goog.dom.AbstractRange} range A range wrapper.
* @return {goog.dom.SavedCaretRange} The range, saved with carets, or null
* if the range wrapper was null.
* @private
*/
goog.editor.ClickToEditWrapper.createCaretRange_ = function(range) {
return range && goog.editor.range.saveUsingNormalizedCarets(range);
};
/**
* Inserts the carets, given the current selection.
*
* Note that for all practical purposes, a cursor position is just
* a selection with the start and end at the same point.
* @private
*/
goog.editor.ClickToEditWrapper.prototype.insertCarets_ = function() {
var fieldElement = this.fieldObj_.getOriginalElement();
this.savedCaretRange_ = null;
var originalWindow = this.originalDomHelper_.getWindow();
if (goog.dom.Range.hasSelection(originalWindow)) {
var range = goog.dom.Range.createFromWindow(originalWindow);
range = range && goog.editor.range.narrow(range, fieldElement);
this.savedCaretRange_ =
goog.editor.ClickToEditWrapper.createCaretRange_(range);
}
if (!this.savedCaretRange_) {
// We couldn't figure out where to put the carets.
// But in FF2/IE6+, this could mean that the user clicked on a
// 'special' node, (e.g., a link or an unselectable item). So the
// selection appears to be null or the full page, even though the user did
// click on something. In IE, we can determine the real selection via
// document.activeElement. In FF, we have to be more hacky.
var specialNodeClicked;
if (goog.editor.BrowserFeature.HAS_ACTIVE_ELEMENT) {
specialNodeClicked = goog.dom.getActiveElement(
this.originalDomHelper_.getDocument());
} else {
specialNodeClicked = this.savedAnchorClicked_;
}
var isFieldElement = function(node) {
return node == fieldElement;
};
if (specialNodeClicked &&
goog.dom.getAncestor(specialNodeClicked, isFieldElement, true)) {
// Insert the cursor at the beginning of the active element to be
// consistent with the behavior in FF1.5, where clicking on a
// link makes the current selection equal to the cursor position
// directly before that link.
//
// TODO(nicksantos): Is there a way to more accurately place the cursor?
this.savedCaretRange_ = goog.editor.ClickToEditWrapper.createCaretRange_(
goog.dom.Range.createFromNodes(
specialNodeClicked, 0, specialNodeClicked, 0));
}
}
};
@@ -0,0 +1,27 @@
<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.
@author nicksantos@google.com (Nick Santos)
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.ClickToEditWrapper Unit Tests
</title>
<script src="../base.js">
</script>
<script>
goog.require('goog.editor.ClickToEditWrapperTest');
</script>
</head>
<body>
<div id="root">
<div id="testField">I am text with a
<a id="testAnchor" href="http://www.google.com">link</a>.</div>
</div>
</body>
</html>
@@ -0,0 +1,133 @@
// 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.ClickToEditWrapperTest');
goog.setTestOnly('goog.editor.ClickToEditWrapperTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.editor.ClickToEditWrapper');
goog.require('goog.editor.SeamlessField');
goog.require('goog.testing.MockClock');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
goog.require('goog.userAgent.product');
var FIELD;
var CLOCK;
var HTML;
function setUp() {
HTML = goog.dom.getElement('root').innerHTML;
CLOCK = new goog.testing.MockClock(true);
// The following 3 lines are to get around an IE bug where it says
// 'Incompatible markup pointers for this operation'.
// Must be done in the setup, not teardown, or else it won't take effect for
// the first test that is run, or any test that runs immediately after a
// "breaking async" message from the jsunit framework.
goog.dom.Range.clearSelection();
window.blur();
window.focus();
}
function setUpField(opt_isBlended) {
FIELD = opt_isBlended ? new goog.editor.SeamlessField('testField') :
new goog.editor.SeamlessField('testField');
(new goog.editor.ClickToEditWrapper(FIELD));
goog.dom.Range.clearSelection();
}
function tearDown() {
if (FIELD) {
FIELD.dispose();
}
CLOCK.dispose();
goog.dom.getElement('root').innerHTML = HTML;
}
function testClickToEdit(opt_isBlended) {
setUpField(opt_isBlended);
var text = goog.dom.getElement('testField').firstChild;
goog.dom.Range.createFromNodes(text, 4, text, 8).select();
goog.testing.events.fireClickSequence(text.parentNode);
assertFalse('Field should not be made editable immediately after clicking',
FIELD.isLoaded());
CLOCK.tick(1);
assertTrue('Field should be editable', FIELD.isLoaded());
var dom = FIELD.getEditableDomHelper();
var selection = goog.dom.Range.createFromWindow(dom.getWindow());
var body = FIELD.getElement();
text = body.firstChild;
assertEquals('Wrong start node', text, selection.getStartNode());
assertEquals('Wrong end node', text, selection.getEndNode());
assertEquals('Wrong start offset', 4, selection.getStartOffset());
assertEquals('Wrong end offset', 8, selection.getEndOffset());
}
function testBlendedClickToEdit() {
testClickToEdit(true);
}
function testClickToEditWithAnchor(opt_isBlended) {
// We bail out if we are running on chrome+winxp because of flaky selenium
// issues. TODO(user): Remove this assertion once we start running on the
// JsUnit farm.
if (goog.userAgent.product.CHROME && goog.userAgent.WINDOWS) {
return;
}
setUpField(opt_isBlended);
goog.dom.getElement('testAnchor').focus();
goog.testing.events.fireClickSequence(goog.dom.getElement('testAnchor'));
CLOCK.tick(1);
assertTrue('Field should be editable', FIELD.isLoaded());
var dom = FIELD.getEditableDomHelper();
var selection = goog.dom.Range.createFromWindow(dom.getWindow());
// TODO(brndn): the location of the cursor is not yet specified by the W3C
// Editing APIs (https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html).
// See b/15678403.
var body = FIELD.getElement();
var text = body.firstChild;
var link = dom.getElementsByTagNameAndClass('A', null, body)[0].firstChild;
var isIELessThan9OrWebkit = goog.userAgent.WEBKIT ||
(goog.userAgent.IE && !goog.userAgent.isVersionOrHigher(9));
assertEquals('Wrong start node',
isIELessThan9OrWebkit ? text : link, selection.getStartNode());
assertEquals('Wrong start offset',
isIELessThan9OrWebkit ? 17 : 0, selection.getStartOffset());
assertEquals('Wrong end node',
isIELessThan9OrWebkit ? text : link, selection.getEndNode());
assertEquals('Wrong end offset',
isIELessThan9OrWebkit ? 17 : 0, selection.getEndOffset());
}
function testBlendedClickToEditWithAnchor() {
testClickToEditWithAnchor(true);
}
@@ -0,0 +1,76 @@
// 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.
/**
* @fileoverview Commands that the editor can execute.
* @see ../demos/editor/editor.html
*/
goog.provide('goog.editor.Command');
/**
* Commands that the editor can excute via execCommand or queryCommandValue.
* @enum {string}
*/
goog.editor.Command = {
// Prepend all the strings of built in execCommands with a plus to ensure
// that there's no conflict if a client wants to use the
// browser's execCommand.
UNDO: '+undo',
REDO: '+redo',
LINK: '+link',
FORMAT_BLOCK: '+formatBlock',
INDENT: '+indent',
OUTDENT: '+outdent',
REMOVE_FORMAT: '+removeFormat',
STRIKE_THROUGH: '+strikeThrough',
HORIZONTAL_RULE: '+insertHorizontalRule',
SUBSCRIPT: '+subscript',
SUPERSCRIPT: '+superscript',
UNDERLINE: '+underline',
BOLD: '+bold',
ITALIC: '+italic',
FONT_SIZE: '+fontSize',
FONT_FACE: '+fontName',
FONT_COLOR: '+foreColor',
EMOTICON: '+emoticon',
EQUATION: '+equation',
BACKGROUND_COLOR: '+backColor',
ORDERED_LIST: '+insertOrderedList',
UNORDERED_LIST: '+insertUnorderedList',
TABLE: '+table',
JUSTIFY_CENTER: '+justifyCenter',
JUSTIFY_FULL: '+justifyFull',
JUSTIFY_RIGHT: '+justifyRight',
JUSTIFY_LEFT: '+justifyLeft',
BLOCKQUOTE: '+BLOCKQUOTE', // This is a nodename. Should be all caps.
DIR_LTR: 'ltr', // should be exactly 'ltr' as it becomes dir attribute value
DIR_RTL: 'rtl', // same here
IMAGE: 'image',
EDIT_HTML: 'editHtml',
UPDATE_LINK_BUBBLE: 'updateLinkBubble',
// queryCommandValue only: returns the default tag name used in the field.
// DIV should be considered the default if no plugin responds.
DEFAULT_TAG: '+defaultTag',
// TODO(nicksantos): Try to give clients an API so that they don't need
// these execCommands.
CLEAR_LOREM: 'clearlorem',
UPDATE_LOREM: 'updatelorem',
USING_LOREM: 'usinglorem',
// Modal editor commands (usually dialogs).
MODAL_LINK_EDITOR: 'link'
};
@@ -0,0 +1,108 @@
// 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 Class to encapsulate an editable field that blends into the
* style of the page and never uses an iframe. The field's height can be
* controlled by CSS styles like min-height, max-height, and overflow. This is
* a goog.editor.Field, but overrides everything iframe related to use
* contentEditable divs. This is essentially a much lighter alternative to
* goog.editor.SeamlessField, but only works in Firefox 3+, and only works
* *well* in Firefox 12+ due to
* https://bugzilla.mozilla.org/show_bug.cgi?id=669026.
*
* @author gboyer@google.com (Garrett Boyer)
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.ContentEditableField');
goog.require('goog.asserts');
goog.require('goog.editor.Field');
goog.require('goog.log');
/**
* This class encapsulates an editable field that is just a contentEditable
* div.
*
* To see events fired by this object, please see the base class.
*
* @param {string} id An identifer for the field. This is used to find the
* field and the element associated with this field.
* @param {Document=} opt_doc The document that the element with the given
* id can be found in.
* @constructor
* @extends {goog.editor.Field}
*/
goog.editor.ContentEditableField = function(id, opt_doc) {
goog.editor.Field.call(this, id, opt_doc);
};
goog.inherits(goog.editor.ContentEditableField, goog.editor.Field);
/**
* @override
*/
goog.editor.ContentEditableField.prototype.logger =
goog.log.getLogger('goog.editor.ContentEditableField');
/** @override */
goog.editor.ContentEditableField.prototype.usesIframe = function() {
// Never uses an iframe in any browser.
return false;
};
// Overridden to improve dead code elimination only.
/** @override */
goog.editor.ContentEditableField.prototype.turnOnDesignModeGecko =
goog.nullFunction;
/** @override */
goog.editor.ContentEditableField.prototype.installStyles = function() {
goog.asserts.assert(!this.cssStyles, 'ContentEditableField does not support' +
' CSS styles; instead just write plain old CSS on the main page.');
};
/** @override */
goog.editor.ContentEditableField.prototype.makeEditableInternal = function(
opt_iframeSrc) {
var field = this.getOriginalElement();
if (field) {
this.setupFieldObject(field);
// TODO(gboyer): Allow clients/plugins to override with 'plaintext-only'
// for WebKit.
field.contentEditable = true;
this.injectContents(field.innerHTML, field);
this.handleFieldLoad();
}
};
/**
* @override
*
* ContentEditableField does not make any changes to the DOM when it is made
* editable other than setting contentEditable to true.
*/
goog.editor.ContentEditableField.prototype.restoreDom =
goog.nullFunction;
@@ -0,0 +1,29 @@
<!--
All Rights Reserved.
Tests for goog.editor.ContentEditableField
@author gboyer@google.com (Garrett Boyer)
-->
<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.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Editor Unit Tests - goog.editor.ContentEditableField
</title>
<script src="../base.js">
</script>
<script>
goog.require('goog.editor.ContentEditableFieldTest');
</script>
</head>
<body>
<div id="parent"></div>
</body>
</html>
@@ -0,0 +1,54 @@
// 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.ContentEditableFieldTest');
goog.setTestOnly('goog.editor.ContentEditableFieldTest');
goog.require('goog.dom');
goog.require('goog.editor.ContentEditableField');
/** @suppress {extraRequire} needed for test setup */
goog.require('goog.editor.field_test');
goog.require('goog.testing.jsunit');
FieldConstructor = goog.editor.ContentEditableField;
function testNoIframeAndSameElement() {
var field = new goog.editor.ContentEditableField('testField');
field.makeEditable();
assertFalse(field.usesIframe());
assertEquals('Original element should equal field element',
field.getOriginalElement(), field.getElement());
assertEquals('Sanity check on original element',
'testField', field.getOriginalElement().id);
assertEquals('Editable document should be same as main document',
document, field.getEditableDomHelper().getDocument());
field.dispose();
}
function testMakeEditableAndUnEditable() {
var elem = goog.dom.getElement('testField');
goog.dom.setTextContent(elem, 'Hello world');
var field = new goog.editor.ContentEditableField('testField');
field.makeEditable();
assertEquals('true', String(elem.contentEditable));
assertEquals('Hello world', goog.dom.getTextContent(elem));
field.setHtml(false /* addParas */, 'Goodbye world');
assertEquals('Goodbye world', goog.dom.getTextContent(elem));
field.makeUneditable();
assertNotEquals('true', String(elem.contentEditable));
assertEquals('Goodbye world', goog.dom.getTextContent(elem));
field.dispose();
}
@@ -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.
/**
* @fileoverview Text editor constants for compile time feature selection.
*
*/
goog.provide('goog.editor.defines');
/**
* @define {boolean} Use contentEditable in FF.
* There are a number of known bugs when the only content in your field is
* inline (e.g. just text, no block elements):
* -indent is a noop and then DOMSubtreeModified events stop firing until
* the structure of the DOM is changed (e.g. make something bold).
* -inserting lists inserts just a NBSP, no list!
* Once those two are fixed, we should have one client guinea pig it and put
* it through a QA run. If we can file the bugs with Mozilla, there's a chance
* they'll fix them for a dot release of Firefox 3.
*/
goog.define('goog.editor.defines.USE_CONTENTEDITABLE_IN_FIREFOX_3', false);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
<!--
All Rights Reserved.
Tests for goog.editor.Field
@author nicksantos@google.com (Nick Santos)
@author jparent@google.com (Julie Parent)
--><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>Editor Unit Tests - goog.editor.Field</title>
<script src='../base.js'></script>
<script>
goog.require('goog.editor.Field');
goog.require('goog.editor.field_test');
goog.require('goog.testing.jsunit');
</script>
</head>
<body>
<div id="parent"></div>
<script>
FieldConstructor = goog.editor.Field;
</script>
</body>
</html>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
// 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.
/**
* @fileoverview Utilties to handle focusing related to rich text editing.
*
*/
goog.provide('goog.editor.focus');
goog.require('goog.dom.selection');
/**
* Change focus to the given input field and set cursor to end of current text.
* @param {Element} inputElem Input DOM element.
*/
goog.editor.focus.focusInputField = function(inputElem) {
inputElem.focus();
goog.dom.selection.setCursorPosition(inputElem, inputElem.value.length);
};
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<!--
@author marcosalmeida@google.com (Marcos Almeida)
-->
<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.focus
</title>
<script src="../base.js">
</script>
<script>
goog.require('goog.editor.focusTest');
</script>
</head>
<body>
<div>
<input type="text" id="myInput" value="my value" />
<a href="" id="dummyLink">
dummy
</a>
</div>
</body>
</html>
@@ -0,0 +1,52 @@
// 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.focusTest');
goog.setTestOnly('goog.editor.focusTest');
goog.require('goog.dom.selection');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.focus');
goog.require('goog.testing.jsunit');
function setUp() {
// Make sure focus is not in the input to begin with.
var dummy = document.getElementById('dummyLink');
dummy.focus();
}
/**
* Tests that focusInputField() puts focus in the input field and sets the
* cursor to the end of the text cointained inside.
*/
function testFocusInputField() {
var input = document.getElementById('myInput');
assertNotEquals('Input should not be focused initially',
input,
document.activeElement);
goog.editor.focus.focusInputField(input);
if (goog.editor.BrowserFeature.HAS_ACTIVE_ELEMENT) {
assertEquals('Input should be focused after call to focusInputField',
input,
document.activeElement);
}
assertEquals('Selection should start at the end of the input text',
input.value.length,
goog.dom.selection.getStart(input));
assertEquals('Selection should end at the end of the input text',
input.value.length,
goog.dom.selection.getEnd(input));
}
@@ -0,0 +1,300 @@
// Copyright 2007 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 Static functions for writing the contents of an iframe-based
* editable field. These vary significantly from browser to browser. Uses
* strings and document.write instead of DOM manipulation, because
* iframe-loading is a performance bottleneck.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.icontent');
goog.provide('goog.editor.icontent.FieldFormatInfo');
goog.provide('goog.editor.icontent.FieldStyleInfo');
goog.require('goog.dom');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.style');
goog.require('goog.userAgent');
/**
* A data structure for storing simple rendering info about a field.
*
* @param {string} fieldId The id of the field.
* @param {boolean} standards Whether the field should be rendered in
* standards mode.
* @param {boolean} blended Whether the field is in blended mode.
* @param {boolean} fixedHeight Whether the field is in fixedHeight mode.
* @param {Object=} opt_extraStyles Other style attributes for the field,
* represented as a map of strings.
* @constructor
* @final
*/
goog.editor.icontent.FieldFormatInfo = function(fieldId, standards, blended,
fixedHeight, opt_extraStyles) {
this.fieldId_ = fieldId;
this.standards_ = standards;
this.blended_ = blended;
this.fixedHeight_ = fixedHeight;
this.extraStyles_ = opt_extraStyles || {};
};
/**
* A data structure for storing simple info about the styles of a field.
* Only needed in Firefox/Blended mode.
* @param {Element} wrapper The wrapper div around a field.
* @param {string} css The css for a field.
* @constructor
* @final
*/
goog.editor.icontent.FieldStyleInfo = function(wrapper, css) {
this.wrapper_ = wrapper;
this.css_ = css;
};
/**
* Whether to always use standards-mode iframes.
* @type {boolean}
* @private
*/
goog.editor.icontent.useStandardsModeIframes_ = false;
/**
* Sets up goog.editor.icontent to always use standards-mode iframes.
*/
goog.editor.icontent.forceStandardsModeIframes = function() {
goog.editor.icontent.useStandardsModeIframes_ = true;
};
/**
* Generate the initial iframe content.
* @param {goog.editor.icontent.FieldFormatInfo} info Formatting info about
* the field.
* @param {string} bodyHtml The HTML to insert as the iframe body.
* @param {goog.editor.icontent.FieldStyleInfo?} style Style info about
* the field, if needed.
* @return {string} The initial IFRAME content HTML.
* @private
*/
goog.editor.icontent.getInitialIframeContent_ =
function(info, bodyHtml, style) {
var html = [];
if (info.blended_ && info.standards_ ||
goog.editor.icontent.useStandardsModeIframes_) {
html.push('<!DOCTYPE HTML>');
}
// <HTML>
// NOTE(user): Override min-widths that may be set for all
// HTML/BODY nodes. A similar workaround is below for the <body> tag. This
// can happen if the host page includes a rule like this in its CSS:
//
// html, body {min-width: 500px}
//
// In this case, the iframe's <html> and/or <body> may be affected. This was
// part of the problem observed in http://b/5674613. (The other part of that
// problem had to do with the presence of a spurious horizontal scrollbar,
// which caused the editor height to be computed incorrectly.)
html.push('<html style="background:none transparent;min-width:0;');
// Make sure that the HTML element's height has the
// correct value as the body element's percentage height is made relative
// to the HTML element's height.
// For fixed-height it should be 100% since we want the body to fill the
// whole height. For growing fields it should be auto since we want the
// body to size to its content.
if (info.blended_) {
html.push('height:', info.fixedHeight_ ? '100%' : 'auto');
}
html.push('">');
// <HEAD><STYLE>
// IE/Safari whitebox need styles set only iff the client specifically
// requested them.
html.push('<head><style>');
if (style && style.css_) {
html.push(style.css_);
}
// Firefox blended needs to inherit all the css from the original page.
// Firefox standards mode needs to set extra style for images.
if (goog.userAgent.GECKO && info.standards_) {
// Standards mode will collapse broken images. This means that they
// can never be removed from the field. This style forces the images
// to render as a broken image icon, sized based on the width and height
// of the image.
// TODO(user): Make sure we move this into a contentEditable code
// path if there ever is one for FF.
html.push(' img {-moz-force-broken-image-icon: 1;}');
}
html.push('</style></head>');
// <BODY>
// Hidefocus is needed to ensure that IE7 doesn't show the dotted, focus
// border when you tab into the field.
html.push('<body g_editable="true" hidefocus="true" ');
if (goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
html.push('contentEditable ');
}
html.push('class="editable ');
// TODO: put the field's original ID on the body and stop using ID as a
// way of getting the pointer to the field in the iframe now that it's
// always the body.
html.push('" id="', info.fieldId_, '" style="min-width:0;');
if (goog.userAgent.GECKO && info.blended_) {
// IMPORTANT: Apply the css from the body then all of the clearing
// CSS to make sure the clearing CSS overrides (e.g. if the body
// has a 3px margin, we want to make sure to override it with 0px.
html.push(
// margin should not be applied to blended mode because the margin is
// outside the iframe
// In whitebox mode, we want to leave the margin to the default so
// there is a nice margin around the text.
';width:100%;border:0;margin:0;background:none transparent;',
// In standards-mode, height 100% makes the body size to its
// parent html element, but in quirks mode, we want auto because
// 100% makes it size to the containing window even if the html
// element is smaller.
// TODO: Fixed height, standards mode, CSS_WRITING, with margins on the
// paragraphs has a scrollbar when it doesn't need it. Putting the
// height to auto seems to fix it. Figure out if we should always
// just use auto?
';height:', info.standards_ ? '100%' : 'auto');
// Only do this for mozilla. IE6 standards mode has a rendering bug when
// there are scrollbars and the body's overflow property is auto
if (info.fixedHeight_) {
html.push(';overflow:auto');
} else {
html.push(';overflow-y:hidden;overflow-x:auto');
}
}
// Hide the native focus rect in Opera.
if (goog.userAgent.OPERA) {
html.push(';outline:hidden');
}
for (var key in info.extraStyles_) {
html.push(';' + key + ':' + info.extraStyles_[key]);
}
html.push('">', bodyHtml, '</body></html>');
return html.join('');
};
/**
* Write the initial iframe content in normal mode.
* @param {goog.editor.icontent.FieldFormatInfo} info Formatting info about
* the field.
* @param {string} bodyHtml The HTML to insert as the iframe body.
* @param {goog.editor.icontent.FieldStyleInfo?} style Style info about
* the field, if needed.
* @param {HTMLIFrameElement} iframe The iframe.
*/
goog.editor.icontent.writeNormalInitialBlendedIframe =
function(info, bodyHtml, style, iframe) {
// Firefox blended needs to inherit all the css from the original page.
// Firefox standards mode needs to set extra style for images.
if (info.blended_) {
var field = style.wrapper_;
// If there is padding on the original field, then the iFrame will be
// positioned inside the padding by default. We don't want this, as it
// causes the contents to appear to shift, and also causes the
// scrollbars to appear inside the padding.
//
// To compensate, we set the iframe margins to offset the padding.
var paddingBox = goog.style.getPaddingBox(field);
if (paddingBox.top || paddingBox.left ||
paddingBox.right || paddingBox.bottom) {
goog.style.setStyle(iframe, 'margin',
(-paddingBox.top) + 'px ' +
(-paddingBox.right) + 'px ' +
(-paddingBox.bottom) + 'px ' +
(-paddingBox.left) + 'px');
}
}
goog.editor.icontent.writeNormalInitialIframe(
info, bodyHtml, style, iframe);
};
/**
* Write the initial iframe content in normal mode.
* @param {goog.editor.icontent.FieldFormatInfo} info Formatting info about
* the field.
* @param {string} bodyHtml The HTML to insert as the iframe body.
* @param {goog.editor.icontent.FieldStyleInfo?} style Style info about
* the field, if needed.
* @param {HTMLIFrameElement} iframe The iframe.
*/
goog.editor.icontent.writeNormalInitialIframe =
function(info, bodyHtml, style, iframe) {
var html = goog.editor.icontent.getInitialIframeContent_(
info, bodyHtml, style);
var doc = goog.dom.getFrameContentDocument(iframe);
doc.open();
doc.write(html);
doc.close();
};
/**
* Write the initial iframe content in IE/HTTPS mode.
* @param {goog.editor.icontent.FieldFormatInfo} info Formatting info about
* the field.
* @param {Document} doc The iframe document.
* @param {string} bodyHtml The HTML to insert as the iframe body.
*/
goog.editor.icontent.writeHttpsInitialIframe = function(info, doc, bodyHtml) {
var body = doc.body;
// For HTTPS we already have a document with a doc type and a body element
// and don't want to create a new history entry which can cause data loss if
// the user clicks the back button.
if (goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
body.contentEditable = true;
}
body.className = 'editable';
body.setAttribute('g_editable', true);
body.hideFocus = true;
body.id = info.fieldId_;
goog.style.setStyle(body, info.extraStyles_);
body.innerHTML = bodyHtml;
};
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
@author nicksantos@google.com (Nick Santos)
-->
<html>
<!--
Copyright 2007 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.icontent
</title>
<script src="../base.js">
</script>
<script>
goog.require('goog.editor.icontentTest');
</script>
<!--
NOTE(user): This style is set to test the bugfix for http://b/5674613.
See the comments in icontent.js for details about the workaround.
-->
<style type="text/css">
html, body {
min-width: 700px;
}
</style>
</head>
<body>
</body>
</html>
@@ -0,0 +1,216 @@
// Copyright 2007 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.icontentTest');
goog.setTestOnly('goog.editor.icontentTest');
goog.require('goog.dom');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.icontent');
goog.require('goog.editor.icontent.FieldFormatInfo');
goog.require('goog.editor.icontent.FieldStyleInfo');
goog.require('goog.testing.PropertyReplacer');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var wrapperDiv;
var realIframe;
var realIframeDoc;
var propertyReplacer;
function setUp() {
wrapperDiv = goog.dom.createDom('div', null,
realIframe = goog.dom.createDom('iframe'));
goog.dom.appendChild(document.body, wrapperDiv);
realIframeDoc = realIframe.contentWindow.document;
propertyReplacer = new goog.testing.PropertyReplacer();
}
function tearDown() {
goog.dom.removeNode(wrapperDiv);
propertyReplacer.reset();
}
function testWriteHttpsInitialIframeContent() {
// This is not a particularly useful test; it's just a sanity check to make
// sure nothing explodes
var info =
new goog.editor.icontent.FieldFormatInfo('id', false, false, false);
var doc = createMockDocument();
goog.editor.icontent.writeHttpsInitialIframe(info, doc, 'some html');
assertBodyCorrect(doc.body, 'id', 'some html');
}
function testWriteHttpsInitialIframeContentRtl() {
var info = new goog.editor.icontent.FieldFormatInfo('id', false, false, true);
var doc = createMockDocument();
goog.editor.icontent.writeHttpsInitialIframe(info, doc, 'some html');
assertBodyCorrect(doc.body, 'id', 'some html', true);
}
function testWriteInitialIframeContentBlendedStandardsGrowing() {
if (goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
return; // only executes when using an iframe
}
var info = new goog.editor.icontent.FieldFormatInfo('id', true, true, false);
var styleInfo = new goog.editor.icontent.FieldStyleInfo(wrapperDiv,
'.MyClass { position: absolute; top: 500px; }');
var doc = realIframeDoc;
var html = '<div class="MyClass">Some Html</div>';
goog.editor.icontent.writeNormalInitialBlendedIframe(info, html,
styleInfo, realIframe);
assertBodyCorrect(doc.body, 'id', html);
assertEquals('CSS1Compat', doc.compatMode); // standards
assertEquals('auto', doc.documentElement.style.height); // growing
assertEquals('100%', doc.body.style.height); // standards
assertEquals('hidden', doc.body.style.overflowY); // growing
assertEquals('', realIframe.style.position); // no padding on wrapper
assertEquals(500, doc.body.firstChild.offsetTop);
assert(doc.getElementsByTagName('style')[0].innerHTML.indexOf(
'-moz-force-broken-image-icon') != -1); // standards
}
function testWriteInitialIframeContentBlendedQuirksFixedRtl() {
if (goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
return; // only executes when using an iframe
}
var info = new goog.editor.icontent.FieldFormatInfo('id', false, true, true);
var styleInfo = new goog.editor.icontent.FieldStyleInfo(wrapperDiv, '');
wrapperDiv.style.padding = '2px 5px';
var doc = realIframeDoc;
var html = 'Some Html';
goog.editor.icontent.writeNormalInitialBlendedIframe(info, html,
styleInfo, realIframe);
assertBodyCorrect(doc.body, 'id', html, true);
assertEquals('BackCompat', doc.compatMode); // quirks
assertEquals('100%', doc.documentElement.style.height); // fixed height
assertEquals('auto', doc.body.style.height); // quirks
assertEquals('auto', doc.body.style.overflow); // fixed height
assertEquals('-2px', realIframe.style.marginTop);
assertEquals('-5px', realIframe.style.marginLeft);
assert(doc.getElementsByTagName('style')[0].innerHTML.indexOf(
'-moz-force-broken-image-icon') == -1); // quirks
}
function testWhiteboxStandardsFixedRtl() {
var info = new goog.editor.icontent.FieldFormatInfo('id', true, false, true);
var styleInfo = null;
var doc = realIframeDoc;
var html = 'Some Html';
goog.editor.icontent.writeNormalInitialBlendedIframe(info, html,
styleInfo, realIframe);
assertBodyCorrect(doc.body, 'id', html, true);
// TODO(nicksantos): on Safari, there's a bug where all written iframes
// are CSS1Compat. It's fixed in the nightlies as of 3/31/08, so remove
// this guard when the latest version of Safari is on the farm.
if (!goog.userAgent.WEBKIT) {
assertEquals('BackCompat', doc.compatMode); // always use quirks in whitebox
}
}
function testGetInitialIframeContent() {
var info = new goog.editor.icontent.FieldFormatInfo(
'id', true, false, false);
var styleInfo = null;
var html = 'Some Html';
propertyReplacer.set(goog.editor.BrowserFeature,
'HAS_CONTENT_EDITABLE', false);
var htmlOut = goog.editor.icontent.getInitialIframeContent_(
info, html, styleInfo);
assertEquals(/contentEditable/i.test(htmlOut), false);
propertyReplacer.set(goog.editor.BrowserFeature,
'HAS_CONTENT_EDITABLE', true);
htmlOut = goog.editor.icontent.getInitialIframeContent_(
info, html, styleInfo);
assertEquals(/<body[^>]+?contentEditable/i.test(htmlOut), true);
assertEquals(/<html[^>]+?style="[^>"]*min-width:\s*0/i.test(htmlOut), true);
assertEquals(/<body[^>]+?style="[^>"]*min-width:\s*0/i.test(htmlOut), true);
}
function testIframeMinWidthOverride() {
if (goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
return; // only executes when using an iframe
}
var info = new goog.editor.icontent.FieldFormatInfo('id', true, true, false);
var styleInfo = new goog.editor.icontent.FieldStyleInfo(wrapperDiv,
'.MyClass { position: absolute; top: 500px; }');
var doc = realIframeDoc;
var html = '<div class="MyClass">Some Html</div>';
goog.editor.icontent.writeNormalInitialBlendedIframe(info, html,
styleInfo, realIframe);
// Make sure that the minimum width isn't being inherited from the parent
// document's style.
assertTrue(doc.body.offsetWidth < 700);
}
function testBlendedStandardsGrowingMatchesComparisonDiv() {
// TODO(nicksantos): If we ever move
// TR_EditableUtil.prototype.makeIframeField_
// into goog.editor.icontent (and I think we should), we could actually run
// functional tests to ensure that the iframed field matches the dimensions
// of the equivalent uneditable div. Functional tests would help a lot here.
}
/**
* Check a given body for the most basic properties that all iframes must have.
*
* @param {Element} body The actual body element
* @param {string} id The expected id
* @param {string} bodyHTML The expected innerHTML
* @param {boolean=} opt_rtl If true, expect RTL directionality
*/
function assertBodyCorrect(body, id, bodyHTML, opt_rtl) {
assertEquals(bodyHTML, body.innerHTML);
// We can't just check
// assert(HAS_CONTENTE_EDITABLE, !!body.contentEditable) since in
// FF 3 we don't currently use contentEditable, but body.contentEditable
// = 'inherit' and !!'inherit' = true.
if (goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
assertEquals('true', String(body.contentEditable));
} else {
assertNotEquals('true', String(body.contentEditable));
}
assertContains('editable', body.className.match(/\S+/g));
assertEquals('true', String(body.getAttribute('g_editable')));
assertEquals('true',
// IE has bugs with getAttribute('hideFocus'), and
// Webkit has bugs with normal .hideFocus access.
String(goog.userAgent.IE ? body.hideFocus :
body.getAttribute('hideFocus')));
assertEquals(id, body.id);
}
/**
* @return {Object} A mock document
*/
function createMockDocument() {
return {
body: {
setAttribute: function(key, val) { this[key] = val; },
getAttribute: function(key) { return this[key]; },
style: { direction: '' }
}
};
}
@@ -0,0 +1,390 @@
// Copyright 2007 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 utility class for managing editable links.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.Link');
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.editor.BrowserFeature');
goog.require('goog.editor.Command');
goog.require('goog.editor.node');
goog.require('goog.editor.range');
goog.require('goog.string');
goog.require('goog.string.Unicode');
goog.require('goog.uri.utils');
goog.require('goog.uri.utils.ComponentIndex');
/**
* Wrap an editable link.
* @param {HTMLAnchorElement} anchor The anchor element.
* @param {boolean} isNew Whether this is a new link.
* @constructor
* @final
*/
goog.editor.Link = function(anchor, isNew) {
/**
* The link DOM element.
* @type {HTMLAnchorElement}
* @private
*/
this.anchor_ = anchor;
/**
* Whether this link represents a link just added to the document.
* @type {boolean}
* @private
*/
this.isNew_ = isNew;
/**
* Any extra anchors created by the browser from a selection in the same
* operation that created the primary link
* @type {!Array<HTMLAnchorElement>}
* @private
*/
this.extraAnchors_ = [];
};
/**
* @return {HTMLAnchorElement} The anchor element.
*/
goog.editor.Link.prototype.getAnchor = function() {
return this.anchor_;
};
/**
* @return {!Array<HTMLAnchorElement>} The extra anchor elements, if any,
* created by the browser from a selection.
*/
goog.editor.Link.prototype.getExtraAnchors = function() {
return this.extraAnchors_;
};
/**
* @return {string} The inner text for the anchor.
*/
goog.editor.Link.prototype.getCurrentText = function() {
if (!this.currentText_) {
var anchor = this.getAnchor();
var leaf = goog.editor.node.getLeftMostLeaf(anchor);
if (leaf.tagName && leaf.tagName == goog.dom.TagName.IMG) {
this.currentText_ = leaf.getAttribute('alt');
} else {
this.currentText_ = goog.dom.getRawTextContent(this.getAnchor());
}
}
return this.currentText_;
};
/**
* @return {boolean} Whether the link is new.
*/
goog.editor.Link.prototype.isNew = function() {
return this.isNew_;
};
/**
* Set the url without affecting the isNew() status of the link.
* @param {string} url A URL.
*/
goog.editor.Link.prototype.initializeUrl = function(url) {
this.getAnchor().href = url;
};
/**
* Removes the link, leaving its contents in the document. Note that this
* object will no longer be usable/useful after this call.
*/
goog.editor.Link.prototype.removeLink = function() {
goog.dom.flattenElement(this.anchor_);
this.anchor_ = null;
while (this.extraAnchors_.length) {
goog.dom.flattenElement(/** @type {Element} */(this.extraAnchors_.pop()));
}
};
/**
* Change the link.
* @param {string} newText New text for the link. If the link contains all its
* text in one descendent, newText will only replace the text in that
* one node. Otherwise, we'll change the innerHTML of the whole
* link to newText.
* @param {string} newUrl A new URL.
*/
goog.editor.Link.prototype.setTextAndUrl = function(newText, newUrl) {
var anchor = this.getAnchor();
anchor.href = newUrl;
// If the text did not change, don't update link text.
var currentText = this.getCurrentText();
if (newText != currentText) {
var leaf = goog.editor.node.getLeftMostLeaf(anchor);
if (leaf.tagName && leaf.tagName == goog.dom.TagName.IMG) {
leaf.setAttribute('alt', newText ? newText : '');
} else {
if (leaf.nodeType == goog.dom.NodeType.TEXT) {
leaf = leaf.parentNode;
}
if (goog.dom.getRawTextContent(leaf) != currentText) {
leaf = anchor;
}
goog.dom.removeChildren(leaf);
var domHelper = goog.dom.getDomHelper(leaf);
goog.dom.appendChild(leaf, domHelper.createTextNode(newText));
}
// The text changed, so force getCurrentText to recompute.
this.currentText_ = null;
}
this.isNew_ = false;
};
/**
* Places the cursor to the right of the anchor.
* Note that this is different from goog.editor.range's placeCursorNextTo
* in that it specifically handles the placement of a cursor in browsers
* that trap you in links, by adding a space when necessary and placing the
* cursor after that space.
*/
goog.editor.Link.prototype.placeCursorRightOf = function() {
var anchor = this.getAnchor();
// If the browser gets stuck in a link if we place the cursor next to it,
// we'll place the cursor after a space instead.
if (goog.editor.BrowserFeature.GETS_STUCK_IN_LINKS) {
var spaceNode;
var nextSibling = anchor.nextSibling;
// Check if there is already a space after the link. Only handle the
// simple case - the next node is a text node that starts with a space.
if (nextSibling &&
nextSibling.nodeType == goog.dom.NodeType.TEXT &&
(goog.string.startsWith(nextSibling.data, goog.string.Unicode.NBSP) ||
goog.string.startsWith(nextSibling.data, ' '))) {
spaceNode = nextSibling;
} else {
// If there isn't an obvious space to use, create one after the link.
var dh = goog.dom.getDomHelper(anchor);
spaceNode = dh.createTextNode(goog.string.Unicode.NBSP);
goog.dom.insertSiblingAfter(spaceNode, anchor);
}
// Move the selection after the space.
var range = goog.dom.Range.createCaret(spaceNode, 1);
range.select();
} else {
goog.editor.range.placeCursorNextTo(anchor, false);
}
};
/**
* Updates the cursor position and link bubble for this link.
* @param {goog.editor.Field} field The field in which the link is created.
* @param {string} url The link url.
* @private
*/
goog.editor.Link.prototype.updateLinkDisplay_ = function(field, url) {
this.initializeUrl(url);
this.placeCursorRightOf();
field.execCommand(goog.editor.Command.UPDATE_LINK_BUBBLE);
};
/**
* @return {string?} The modified string for the link if the link
* text appears to be a valid link. Returns null if this is not
* a valid link address.
*/
goog.editor.Link.prototype.getValidLinkFromText = function() {
var text = goog.string.trim(this.getCurrentText());
if (goog.editor.Link.isLikelyUrl(text)) {
if (text.search(/:/) < 0) {
return 'http://' + goog.string.trimLeft(text);
}
return text;
} else if (goog.editor.Link.isLikelyEmailAddress(text)) {
return 'mailto:' + text;
}
return null;
};
/**
* After link creation, finish creating the link depending on the type
* of link being created.
* @param {goog.editor.Field} field The field where this link is being created.
*/
goog.editor.Link.prototype.finishLinkCreation = function(field) {
var linkFromText = this.getValidLinkFromText();
if (linkFromText) {
this.updateLinkDisplay_(field, linkFromText);
} else {
field.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, this);
}
};
/**
* Initialize a new link.
* @param {HTMLAnchorElement} anchor The anchor element.
* @param {string} url The initial URL.
* @param {string=} opt_target The target.
* @param {Array<HTMLAnchorElement>=} opt_extraAnchors Extra anchors created
* by the browser when parsing a selection.
* @return {!goog.editor.Link} The link.
*/
goog.editor.Link.createNewLink = function(anchor, url, opt_target,
opt_extraAnchors) {
var link = new goog.editor.Link(anchor, true);
link.initializeUrl(url);
if (opt_target) {
anchor.target = opt_target;
}
if (opt_extraAnchors) {
link.extraAnchors_ = opt_extraAnchors;
}
return link;
};
/**
* Initialize a new link using text in anchor, or empty string if there is no
* likely url in the anchor.
* @param {HTMLAnchorElement} anchor The anchor element with likely url content.
* @param {string=} opt_target The target.
* @return {!goog.editor.Link} The link.
*/
goog.editor.Link.createNewLinkFromText = function(anchor, opt_target) {
var link = new goog.editor.Link(anchor, true);
var text = link.getValidLinkFromText();
link.initializeUrl(text ? text : '');
if (opt_target) {
anchor.target = opt_target;
}
return link;
};
/**
* Returns true if str could be a URL, false otherwise
*
* Ex: TR_Util.isLikelyUrl_("http://www.google.com") == true
* TR_Util.isLikelyUrl_("www.google.com") == true
*
* @param {string} str String to check if it looks like a URL.
* @return {boolean} Whether str could be a URL.
*/
goog.editor.Link.isLikelyUrl = function(str) {
// Whitespace means this isn't a domain.
if (/\s/.test(str)) {
return false;
}
if (goog.editor.Link.isLikelyEmailAddress(str)) {
return false;
}
// Add a scheme if the url doesn't have one - this helps the parser.
var addedScheme = false;
if (!/^[^:\/?#.]+:/.test(str)) {
str = 'http://' + str;
addedScheme = true;
}
// Parse the domain.
var parts = goog.uri.utils.split(str);
// Relax the rules for special schemes.
var scheme = parts[goog.uri.utils.ComponentIndex.SCHEME];
if (goog.array.indexOf(['mailto', 'aim'], scheme) != -1) {
return true;
}
// Require domains to contain a '.', unless the domain is fully qualified and
// forbids domains from containing invalid characters.
var domain = parts[goog.uri.utils.ComponentIndex.DOMAIN];
if (!domain || (addedScheme && domain.indexOf('.') == -1) ||
(/[^\w\d\-\u0100-\uffff.%]/.test(domain))) {
return false;
}
// Require http and ftp paths to start with '/'.
var path = parts[goog.uri.utils.ComponentIndex.PATH];
return !path || path.indexOf('/') == 0;
};
/**
* Regular expression that matches strings that could be an email address.
* @type {RegExp}
* @private
*/
goog.editor.Link.LIKELY_EMAIL_ADDRESS_ = new RegExp(
'^' + // Test from start of string
'[\\w-]+(\\.[\\w-]+)*' + // Dot-delimited alphanumerics and dashes (name)
'\\@' + // @
'([\\w-]+\\.)+' + // Alphanumerics, dashes and dots (domain)
'(\\d+|\\w\\w+)$', // Domain ends in at least one number or 2 letters
'i');
/**
* Returns true if str could be an email address, false otherwise
*
* Ex: goog.editor.Link.isLikelyEmailAddress_("some word") == false
* goog.editor.Link.isLikelyEmailAddress_("foo@foo.com") == true
*
* @param {string} str String to test for being email address.
* @return {boolean} Whether "str" looks like an email address.
*/
goog.editor.Link.isLikelyEmailAddress = function(str) {
return goog.editor.Link.LIKELY_EMAIL_ADDRESS_.test(str);
};
/**
* Determines whether or not a url is an email link.
* @param {string} url A url.
* @return {boolean} Whether the url is a mailto link.
*/
goog.editor.Link.isMailto = function(url) {
return !!url && goog.string.startsWith(url, 'mailto:');
};
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
@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>
Editor Unit Tests - goog.editor.Link
</title>
<script src="../base.js">
</script>
<script>
goog.require('goog.editor.LinkTest');
</script>
</head>
<body>
</body>
</html>
@@ -0,0 +1,290 @@
// 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.LinkTest');
goog.setTestOnly('goog.editor.LinkTest');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Link');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var anchor;
function setUp() {
anchor = goog.dom.createDom('A');
document.body.appendChild(anchor);
}
function tearDown() {
goog.dom.removeNode(anchor);
}
function testCreateNew() {
var link = new goog.editor.Link(anchor, true);
assertNotNull('Should have created object', link);
assertTrue('Should be new', link.isNew());
assertEquals('Should have correct anchor', anchor, link.getAnchor());
assertEquals('Should be empty', '', link.getCurrentText());
}
function testCreateNotNew() {
var link = new goog.editor.Link(anchor, false);
assertNotNull('Should have created object', link);
assertFalse('Should not be new', link.isNew());
assertEquals('Should have correct anchor', anchor, link.getAnchor());
assertEquals('Should be empty', '', link.getCurrentText());
}
function testCreateNewLinkFromText() {
var url = 'http://www.google.com/';
anchor.innerHTML = url;
var link = goog.editor.Link.createNewLinkFromText(anchor);
assertNotNull('Should have created object', link);
assertEquals('Should have url in anchor', url, anchor.href);
}
function testCreateNewLinkFromTextLeadingTrailingWhitespace() {
var url = 'http://www.google.com/';
var urlWithSpaces = ' ' + url + ' ';
anchor.innerHTML = urlWithSpaces;
var urlWithSpacesUpdatedByBrowser = anchor.innerHTML;
var link = goog.editor.Link.createNewLinkFromText(anchor);
assertNotNull('Should have created object', link);
assertEquals('Should have url in anchor', url, anchor.href);
assertEquals('The text should still have spaces',
urlWithSpacesUpdatedByBrowser, link.getCurrentText());
}
function testCreateNewLinkFromTextWithAnchor() {
var url = 'https://www.google.com/';
anchor.innerHTML = url;
var link = goog.editor.Link.createNewLinkFromText(anchor, '_blank');
assertNotNull('Should have created object', link);
assertEquals('Should have url in anchor', url, anchor.href);
assertEquals('Should have _blank target', '_blank', anchor.target);
}
function testInitialize() {
var link = goog.editor.Link.createNewLink(anchor, 'http://www.google.com');
assertNotNull('Should have created object', link);
assertTrue('Should be new', link.isNew());
assertEquals('Should have correct anchor', anchor, link.getAnchor());
assertEquals('Should be empty', '', link.getCurrentText());
}
function testInitializeWithTarget() {
var link = goog.editor.Link.createNewLink(anchor, 'http://www.google.com',
'_blank');
assertNotNull('Should have created object', link);
assertTrue('Should be new', link.isNew());
assertEquals('Should have correct anchor', anchor, link.getAnchor());
assertEquals('Should be empty', '', link.getCurrentText());
assertEquals('Should have _blank target', '_blank', anchor.target);
}
function testSetText() {
var link = goog.editor.Link.createNewLink(anchor, 'http://www.google.com',
'_blank');
assertEquals('Should be empty', '', link.getCurrentText());
link.setTextAndUrl('Text', 'http://docs.google.com/');
assertEquals('Should point to http://docs.google.com/',
'http://docs.google.com/', anchor.href);
assertEquals('Should have correct text', 'Text', link.getCurrentText());
}
function testSetBoldText() {
anchor.innerHTML = '<b></b>';
var link = goog.editor.Link.createNewLink(anchor, 'http://www.google.com',
'_blank');
assertEquals('Should be empty', '', link.getCurrentText());
link.setTextAndUrl('Text', 'http://docs.google.com/');
assertEquals('Should point to http://docs.google.com/',
'http://docs.google.com/', anchor.href);
assertEquals('Should have correct text', 'Text', link.getCurrentText());
assertEquals('Should still be bold', goog.dom.TagName.B,
anchor.firstChild.tagName);
}
function testLinkImgTag() {
anchor.innerHTML = '<img src="www.google.com" alt="alt_txt">';
var link = goog.editor.Link.createNewLink(anchor, 'http://www.google.com',
'_blank');
assertEquals('Test getCurrentText', 'alt_txt', link.getCurrentText());
link.setTextAndUrl('newText', 'http://docs.google.com/');
assertEquals('Test getCurrentText', 'newText', link.getCurrentText());
assertEquals('Should point to http://docs.google.com/',
'http://docs.google.com/', anchor.href);
assertEquals('Should still have img tag', goog.dom.TagName.IMG,
anchor.firstChild.tagName);
assertEquals('Alt should equal "newText"', 'newText',
anchor.firstChild.getAttribute('alt'));
}
function testSetMixed() {
anchor.innerHTML = '<b>A</b>B';
var link = goog.editor.Link.createNewLink(anchor, 'http://www.google.com',
'_blank');
assertEquals('Should have text: AB', 'AB', link.getCurrentText());
link.setTextAndUrl('Text', 'http://docs.google.com/');
assertEquals('Should point to http://docs.google.com/',
'http://docs.google.com/', anchor.href);
assertEquals('Should have correct text', 'Text', link.getCurrentText());
assertEquals('Should not be bold', goog.dom.NodeType.TEXT,
anchor.firstChild.nodeType);
}
function testPlaceCursorRightOf() {
// IE can only do selections properly if the region is editable.
var ed = goog.dom.createDom('div');
goog.dom.replaceNode(ed, anchor);
ed.contentEditable = true;
ed.appendChild(anchor);
// In order to test the cursor placement properly, we need to have
// link text. See more details in the test below.
anchor.innerHTML = 'I am text';
var link = goog.editor.Link.createNewLink(anchor, 'http://www.google.com');
link.placeCursorRightOf();
var range = goog.dom.Range.createFromWindow();
assertTrue('Range should be collapsed', range.isCollapsed());
var startNode = range.getStartNode();
if (goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('528')) {
assertEquals('Selection should be to the right of the anchor',
anchor, startNode.previousSibling);
} else {
// Check that the selection is the "right" place.
//
// If you query the selection, it is actually still inside the anchor,
// but if you type, it types outside the anchor.
//
// Best we can do is test that it is at the end of the anchor text.
assertEquals('Selection should be in anchor text',
anchor.firstChild, startNode);
assertEquals('Selection should be at the end of the text',
anchor.firstChild.length, range.getStartOffset());
}
if (ed) {
goog.dom.removeNode(ed);
}
}
function testIsLikelyUrl() {
var good = [
// Proper URLs
'http://google.com', 'http://google.com/', 'http://192.168.1.103',
'http://www.google.com:8083', 'https://antoine', 'https://foo.foo.net',
'ftp://google.com:22/', 'http://user@site.com',
'ftp://user:pass@ftp.site.com', 'http://google.com/search?q=laser%20cats',
'aim:goim?screenname=en2es', 'mailto:x@y.com',
// Bad URLs a browser will accept
'www.google.com', 'www.amazon.co.uk', 'amazon.co.uk', 'foo2.foo3.com',
'pandora.tv', 'marketing.us', 'del.icio.us', 'bridge-line.com',
'www.frigid.net:80', 'www.google.com?q=foo', 'www.foo.com/j%20.txt',
'foodtv.net', 'google.com', 'slashdot.org', '192.168.1.1',
'justin.edu?kumar&nbsp;something', 'google.com/search?q=hot%20pockets',
// Due to TLD explosion, these could be URLs either now or soon.
'ww.jester', 'juicer.fake', 'abs.nonsense.something', 'filename.txt'
];
for (var i = 0; i < good.length; i++) {
assertTrue(good[i] + ' should be good',
goog.editor.Link.isLikelyUrl(good[i]));
}
var bad = [
// Definitely not URLs
'bananas', 'http google com', '<img>', 'Sad :/', '*garbage!.123',
'ftp', 'http', '/', 'https', 'this is', '*!&.banana!*&!',
'www.jester is gone.com', 'ftp .nospaces.net', 'www_foo_net',
"www.'jester'.net", 'www:8080',
'www . notnsense.com', 'email@address.com',
// URL-ish but not quite
' http://www.google.com', 'http://www.google.com:8081 ',
'www.google.com foo bar', 'google.com/search?q=not quite'
];
for (i = 0; i < bad.length; i++) {
assertFalse(bad[i] + ' should be bad',
goog.editor.Link.isLikelyUrl(bad[i]));
}
}
function testIsLikelyEmailAddress() {
var good = [
// Valid email addresses
'foo@foo.com', 'foo1@foo2.foo3.com', 'f45_1@goog13.org', 'user@gmail.co.uk',
'jon-smith@crazy.net', 'roland1@capuchino.gov', 'ernir@gshi.nl',
'JOON@jno.COM', 'media@meDIa.fREnology.FR', 'john.mail4me@del.icio.us',
'www9@wc3.madeup1.org', 'hi@192.168.1.103', 'hi@192.168.1.1'
];
for (var i = 0; i < good.length; i++) {
assertTrue(goog.editor.Link.isLikelyEmailAddress(good[i]));
}
var bad = [
// Malformed/incomplete email addresses
'user', '@gmail.com', 'user@gmail', 'user@.com', 'user@gmail.c',
'user@gmail.co.u', '@ya.com', '.@hi3.nl', 'jim.com',
'ed:@gmail.com', '*!&.banana!*&!', ':jon@gmail.com',
'3g?@bil.com', 'adam be@hi.net', 'john\nsmith@test.com',
"www.'jester'.net", "'james'@covald.net", 'ftp://user@site.com/',
'aim:goim?screenname=en2es', 'user:pass@site.com', 'user@site.com yay'
];
for (i = 0; i < bad.length; i++) {
assertFalse(goog.editor.Link.isLikelyEmailAddress(bad[i]));
}
}
function testIsMailToLink() {
assertFalse(goog.editor.Link.isMailto());
assertFalse(goog.editor.Link.isMailto(null));
assertFalse(goog.editor.Link.isMailto(''));
assertFalse(goog.editor.Link.isMailto('http://foo.com'));
assertFalse(goog.editor.Link.isMailto('http://mailto:80'));
assertTrue(goog.editor.Link.isMailto('mailto:'));
assertTrue(goog.editor.Link.isMailto('mailto://'));
assertTrue(goog.editor.Link.isMailto('mailto://ptucker@gmail.com'));
}
function testGetValidLinkFromText() {
var textLinkPairs = [
// input text, expected link output
'www.foo.com', 'http://www.foo.com',
'user@gmail.com', 'mailto:user@gmail.com',
'http://www.foo.com', 'http://www.foo.com',
'https://this.that.edu', 'https://this.that.edu',
'nothing to see here', null
];
var link = new goog.editor.Link(anchor, true);
for (var i = 0; i < textLinkPairs.length; i += 2) {
link.currentText_ = textLinkPairs[i];
var result = link.getValidLinkFromText();
assertEquals(textLinkPairs[i + 1], result);
}
}
@@ -0,0 +1,484 @@
// 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 Utilties for working with DOM nodes related to rich text
* editing. Many of these are not general enough to go into goog.dom.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.node');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.TagName');
goog.require('goog.dom.iter.ChildIterator');
goog.require('goog.dom.iter.SiblingIterator');
goog.require('goog.iter');
goog.require('goog.object');
goog.require('goog.string');
goog.require('goog.string.Unicode');
goog.require('goog.userAgent');
/**
* Names of all block-level tags
* @type {Object}
* @private
*/
goog.editor.node.BLOCK_TAG_NAMES_ = goog.object.createSet(
goog.dom.TagName.ADDRESS,
goog.dom.TagName.ARTICLE,
goog.dom.TagName.ASIDE,
goog.dom.TagName.BLOCKQUOTE,
goog.dom.TagName.BODY,
goog.dom.TagName.CAPTION,
goog.dom.TagName.CENTER,
goog.dom.TagName.COL,
goog.dom.TagName.COLGROUP,
goog.dom.TagName.DETAILS,
goog.dom.TagName.DIR,
goog.dom.TagName.DIV,
goog.dom.TagName.DL,
goog.dom.TagName.DD,
goog.dom.TagName.DT,
goog.dom.TagName.FIELDSET,
goog.dom.TagName.FIGCAPTION,
goog.dom.TagName.FIGURE,
goog.dom.TagName.FOOTER,
goog.dom.TagName.FORM,
goog.dom.TagName.H1,
goog.dom.TagName.H2,
goog.dom.TagName.H3,
goog.dom.TagName.H4,
goog.dom.TagName.H5,
goog.dom.TagName.H6,
goog.dom.TagName.HEADER,
goog.dom.TagName.HGROUP,
goog.dom.TagName.HR,
goog.dom.TagName.ISINDEX,
goog.dom.TagName.OL,
goog.dom.TagName.LI,
goog.dom.TagName.MAP,
goog.dom.TagName.MENU,
goog.dom.TagName.NAV,
goog.dom.TagName.OPTGROUP,
goog.dom.TagName.OPTION,
goog.dom.TagName.P,
goog.dom.TagName.PRE,
goog.dom.TagName.SECTION,
goog.dom.TagName.SUMMARY,
goog.dom.TagName.TABLE,
goog.dom.TagName.TBODY,
goog.dom.TagName.TD,
goog.dom.TagName.TFOOT,
goog.dom.TagName.TH,
goog.dom.TagName.THEAD,
goog.dom.TagName.TR,
goog.dom.TagName.UL);
/**
* Names of tags that have intrinsic content.
* TODO(robbyw): What about object, br, input, textarea, button, isindex,
* hr, keygen, select, table, tr, td?
* @type {Object}
* @private
*/
goog.editor.node.NON_EMPTY_TAGS_ = goog.object.createSet(
goog.dom.TagName.IMG, goog.dom.TagName.IFRAME, goog.dom.TagName.EMBED);
/**
* Check if the node is in a standards mode document.
* @param {Node} node The node to test.
* @return {boolean} Whether the node is in a standards mode document.
*/
goog.editor.node.isStandardsMode = function(node) {
return goog.dom.getDomHelper(node).isCss1CompatMode();
};
/**
* Get the right-most non-ignorable leaf node of the given node.
* @param {Node} parent The parent ndoe.
* @return {Node} The right-most non-ignorable leaf node.
*/
goog.editor.node.getRightMostLeaf = function(parent) {
var temp;
while (temp = goog.editor.node.getLastChild(parent)) {
parent = temp;
}
return parent;
};
/**
* Get the left-most non-ignorable leaf node of the given node.
* @param {Node} parent The parent ndoe.
* @return {Node} The left-most non-ignorable leaf node.
*/
goog.editor.node.getLeftMostLeaf = function(parent) {
var temp;
while (temp = goog.editor.node.getFirstChild(parent)) {
parent = temp;
}
return parent;
};
/**
* Version of firstChild that skips nodes that are entirely
* whitespace and comments.
* @param {Node} parent The reference node.
* @return {Node} The first child of sibling that is important according to
* goog.editor.node.isImportant, or null if no such node exists.
*/
goog.editor.node.getFirstChild = function(parent) {
return goog.editor.node.getChildHelper_(parent, false);
};
/**
* Version of lastChild that skips nodes that are entirely whitespace or
* comments. (Normally lastChild is a property of all DOM nodes that gives the
* last of the nodes contained directly in the reference node.)
* @param {Node} parent The reference node.
* @return {Node} The last child of sibling that is important according to
* goog.editor.node.isImportant, or null if no such node exists.
*/
goog.editor.node.getLastChild = function(parent) {
return goog.editor.node.getChildHelper_(parent, true);
};
/**
* Version of previoussibling that skips nodes that are entirely
* whitespace or comments. (Normally previousSibling is a property
* of all DOM nodes that gives the sibling node, the node that is
* a child of the same parent, that occurs immediately before the
* reference node.)
* @param {Node} sibling The reference node.
* @return {Node} The closest previous sibling to sibling that is
* important according to goog.editor.node.isImportant, or null if no such
* node exists.
*/
goog.editor.node.getPreviousSibling = function(sibling) {
return /** @type {Node} */ (goog.editor.node.getFirstValue_(
goog.iter.filter(new goog.dom.iter.SiblingIterator(sibling, false, true),
goog.editor.node.isImportant)));
};
/**
* Version of nextSibling that skips nodes that are entirely whitespace or
* comments.
* @param {Node} sibling The reference node.
* @return {Node} The closest next sibling to sibling that is important
* according to goog.editor.node.isImportant, or null if no
* such node exists.
*/
goog.editor.node.getNextSibling = function(sibling) {
return /** @type {Node} */ (goog.editor.node.getFirstValue_(
goog.iter.filter(new goog.dom.iter.SiblingIterator(sibling),
goog.editor.node.isImportant)));
};
/**
* Internal helper for lastChild/firstChild that skips nodes that are entirely
* whitespace or comments.
* @param {Node} parent The reference node.
* @param {boolean} isReversed Whether children should be traversed forward
* or backward.
* @return {Node} The first/last child of sibling that is important according
* to goog.editor.node.isImportant, or null if no such node exists.
* @private
*/
goog.editor.node.getChildHelper_ = function(parent, isReversed) {
return (!parent || parent.nodeType != goog.dom.NodeType.ELEMENT) ? null :
/** @type {Node} */ (goog.editor.node.getFirstValue_(goog.iter.filter(
new goog.dom.iter.ChildIterator(
/** @type {!Element} */ (parent), isReversed),
goog.editor.node.isImportant)));
};
/**
* Utility function that returns the first value from an iterator or null if
* the iterator is empty.
* @param {goog.iter.Iterator} iterator The iterator to get a value from.
* @return {*} The first value from the iterator.
* @private
*/
goog.editor.node.getFirstValue_ = function(iterator) {
/** @preserveTry */
try {
return iterator.next();
} catch (e) {
return null;
}
};
/**
* Determine if a node should be returned by the iterator functions.
* @param {Node} node An object implementing the DOM1 Node interface.
* @return {boolean} Whether the node is an element, or a text node that
* is not all whitespace.
*/
goog.editor.node.isImportant = function(node) {
// Return true if the node is not either a TextNode or an ElementNode.
return node.nodeType == goog.dom.NodeType.ELEMENT ||
node.nodeType == goog.dom.NodeType.TEXT &&
!goog.editor.node.isAllNonNbspWhiteSpace(node);
};
/**
* Determine whether a node's text content is entirely whitespace.
* @param {Node} textNode A node implementing the CharacterData interface (i.e.,
* a Text, Comment, or CDATASection node.
* @return {boolean} Whether the text content of node is whitespace,
* otherwise false.
*/
goog.editor.node.isAllNonNbspWhiteSpace = function(textNode) {
return goog.string.isBreakingWhitespace(textNode.nodeValue);
};
/**
* Returns true if the node contains only whitespace and is not and does not
* contain any images, iframes or embed tags.
* @param {Node} node The node to check.
* @param {boolean=} opt_prohibitSingleNbsp By default, this function treats a
* single nbsp as empty. Set this to true to treat this case as non-empty.
* @return {boolean} Whether the node contains only whitespace.
*/
goog.editor.node.isEmpty = function(node, opt_prohibitSingleNbsp) {
var nodeData = goog.dom.getRawTextContent(node);
if (node.getElementsByTagName) {
for (var tag in goog.editor.node.NON_EMPTY_TAGS_) {
if (node.tagName == tag || node.getElementsByTagName(tag).length > 0) {
return false;
}
}
}
return (!opt_prohibitSingleNbsp && nodeData == goog.string.Unicode.NBSP) ||
goog.string.isBreakingWhitespace(nodeData);
};
/**
* Returns the length of the text in node if it is a text node, or the number
* of children of the node, if it is an element. Useful for range-manipulation
* code where you need to know the offset for the right side of the node.
* @param {Node} node The node to get the length of.
* @return {number} The length of the node.
*/
goog.editor.node.getLength = function(node) {
return node.length || node.childNodes.length;
};
/**
* Search child nodes using a predicate function and return the first node that
* satisfies the condition.
* @param {Node} parent The parent node to search.
* @param {function(Node):boolean} hasProperty A function that takes a child
* node as a parameter and returns true if it meets the criteria.
* @return {?number} The index of the node found, or null if no node is found.
*/
goog.editor.node.findInChildren = function(parent, hasProperty) {
for (var i = 0, len = parent.childNodes.length; i < len; i++) {
if (hasProperty(parent.childNodes[i])) {
return i;
}
}
return null;
};
/**
* Search ancestor nodes using a predicate function and returns the topmost
* ancestor in the chain of consecutive ancestors that satisfies the condition.
*
* @param {Node} node The node whose ancestors have to be searched.
* @param {function(Node): boolean} hasProperty A function that takes a parent
* node as a parameter and returns true if it meets the criteria.
* @return {Node} The topmost ancestor or null if no ancestor satisfies the
* predicate function.
*/
goog.editor.node.findHighestMatchingAncestor = function(node, hasProperty) {
var parent = node.parentNode;
var ancestor = null;
while (parent && hasProperty(parent)) {
ancestor = parent;
parent = parent.parentNode;
}
return ancestor;
};
/**
* Checks if node is a block-level html element. The <tt>display</tt> css
* property is ignored.
* @param {Node} node The node to test.
* @return {boolean} Whether the node is a block-level node.
*/
goog.editor.node.isBlockTag = function(node) {
return !!goog.editor.node.BLOCK_TAG_NAMES_[node.tagName];
};
/**
* Skips siblings of a node that are empty text nodes.
* @param {Node} node A node. May be null.
* @return {Node} The node or the first sibling of the node that is not an
* empty text node. May be null.
*/
goog.editor.node.skipEmptyTextNodes = function(node) {
while (node && node.nodeType == goog.dom.NodeType.TEXT &&
!node.nodeValue) {
node = node.nextSibling;
}
return node;
};
/**
* Checks if an element is a top-level editable container (meaning that
* it itself is not editable, but all its child nodes are editable).
* @param {Node} element The element to test.
* @return {boolean} Whether the element is a top-level editable container.
*/
goog.editor.node.isEditableContainer = function(element) {
return element.getAttribute &&
element.getAttribute('g_editable') == 'true';
};
/**
* Checks if a node is inside an editable container.
* @param {Node} node The node to test.
* @return {boolean} Whether the node is in an editable container.
*/
goog.editor.node.isEditable = function(node) {
return !!goog.dom.getAncestor(node, goog.editor.node.isEditableContainer);
};
/**
* Finds the top-most DOM node inside an editable field that is an ancestor
* (or self) of a given DOM node and meets the specified criteria.
* @param {Node} node The DOM node where the search starts.
* @param {function(Node) : boolean} criteria A function that takes a DOM node
* as a parameter and returns a boolean to indicate whether the node meets
* the criteria or not.
* @return {Node} The DOM node if found, or null.
*/
goog.editor.node.findTopMostEditableAncestor = function(node, criteria) {
var targetNode = null;
while (node && !goog.editor.node.isEditableContainer(node)) {
if (criteria(node)) {
targetNode = node;
}
node = node.parentNode;
}
return targetNode;
};
/**
* Splits off a subtree.
* @param {!Node} currentNode The starting splitting point.
* @param {Node=} opt_secondHalf The initial leftmost leaf the new subtree.
* If null, siblings after currentNode will be placed in the subtree, but
* no additional node will be.
* @param {Node=} opt_root The top of the tree where splitting stops at.
* @return {!Node} The new subtree.
*/
goog.editor.node.splitDomTreeAt = function(currentNode,
opt_secondHalf, opt_root) {
var parent;
while (currentNode != opt_root && (parent = currentNode.parentNode)) {
opt_secondHalf = goog.editor.node.getSecondHalfOfNode_(parent, currentNode,
opt_secondHalf);
currentNode = parent;
}
return /** @type {!Node} */(opt_secondHalf);
};
/**
* Creates a clone of node, moving all children after startNode to it.
* When firstChild is not null or undefined, it is also appended to the clone
* as the first child.
* @param {!Node} node The node to clone.
* @param {!Node} startNode All siblings after this node will be moved to the
* clone.
* @param {Node|undefined} firstChild The first child of the new cloned element.
* @return {!Node} The cloned node that now contains the children after
* startNode.
* @private
*/
goog.editor.node.getSecondHalfOfNode_ = function(node, startNode, firstChild) {
var secondHalf = /** @type {!Node} */(node.cloneNode(false));
while (startNode.nextSibling) {
goog.dom.appendChild(secondHalf, startNode.nextSibling);
}
if (firstChild) {
secondHalf.insertBefore(firstChild, secondHalf.firstChild);
}
return secondHalf;
};
/**
* Appends all of oldNode's children to newNode. This removes all children from
* oldNode and appends them to newNode. oldNode is left with no children.
* @param {!Node} newNode Node to transfer children to.
* @param {Node} oldNode Node to transfer children from.
* @deprecated Use goog.dom.append directly instead.
*/
goog.editor.node.transferChildren = function(newNode, oldNode) {
goog.dom.append(newNode, oldNode.childNodes);
};
/**
* Replaces the innerHTML of a node.
*
* IE has serious problems if you try to set innerHTML of an editable node with
* any selection. Early versions of IE tear up the old internal tree storage, to
* help avoid ref-counting loops. But this sometimes leaves the selection object
* in a bad state and leads to segfaults.
*
* Removing the nodes first prevents IE from tearing them up. This is not
* strictly necessary in nodes that do not have the selection. You should always
* use this function when setting innerHTML inside of a field.
*
* @param {Node} node A node.
* @param {string} html The innerHTML to set on the node.
*/
goog.editor.node.replaceInnerHtml = function(node, html) {
// Only do this IE. On gecko, we use element change events, and don't
// want to trigger spurious events.
if (goog.userAgent.IE) {
goog.dom.removeChildren(node);
}
node.innerHTML = html;
};
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<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.
-->
<!--
@author jparent@google.com (Julie Parent)
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.Node
</title>
<script src="../base.js">
</script>
<script>
goog.require('goog.editor.nodeTest');
</script>
</head>
<body>
<div id="parentNode"><div></div><div></div><div></div></div>
<a id="link" href="foo.html">Foo</a>
<div id="editableTest" g_editable="true">node<span>element</span></div>
</body>
</html>
@@ -0,0 +1,645 @@
// 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.nodeTest');
goog.setTestOnly('goog.editor.nodeTest');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.TagName');
goog.require('goog.editor.node');
goog.require('goog.style');
goog.require('goog.testing.ExpectedFailures');
goog.require('goog.testing.dom');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var expectedFailures;
var parentNode;
var childNode1;
var childNode2;
var childNode3;
var gChildWsNode1 = null;
var gChildTextNode1 = null;
var gChildNbspNode1 = null;
var gChildMixedNode1 = null;
var gChildWsNode2a = null;
var gChildWsNode2b = null;
var gChildTextNode3a = null;
var gChildWsNode3 = null;
var gChildTextNode3b = null;
function setUpPage() {
expectedFailures = new goog.testing.ExpectedFailures();
parentNode = document.getElementById('parentNode');
childNode1 = parentNode.childNodes[0];
childNode2 = parentNode.childNodes[1];
childNode3 = parentNode.childNodes[2];
}
function tearDown() {
expectedFailures.handleTearDown();
}
function setUpDomTree() {
gChildWsNode1 = document.createTextNode(' \t\r\n');
gChildTextNode1 = document.createTextNode('Child node');
gChildNbspNode1 = document.createTextNode('\u00a0');
gChildMixedNode1 = document.createTextNode('Text\n plus\u00a0');
gChildWsNode2a = document.createTextNode('');
gChildWsNode2b = document.createTextNode(' ');
gChildTextNode3a = document.createTextNode('I am a grand child');
gChildWsNode3 = document.createTextNode(' \t \r \n');
gChildTextNode3b = document.createTextNode('I am also a grand child');
childNode3.appendChild(gChildTextNode3a);
childNode3.appendChild(gChildWsNode3);
childNode3.appendChild(gChildTextNode3b);
childNode1.appendChild(gChildMixedNode1);
childNode1.appendChild(gChildWsNode1);
childNode1.appendChild(gChildNbspNode1);
childNode1.appendChild(gChildTextNode1);
childNode2.appendChild(gChildWsNode2a);
childNode2.appendChild(gChildWsNode2b);
document.body.appendChild(parentNode);
}
function tearDownDomTree() {
childNode1.innerHTML = childNode2.innerHTML = childNode3.innerHTML = '';
gChildWsNode1 = null;
gChildTextNode1 = null;
gChildNbspNode1 = null;
gChildMixedNode1 = null;
gChildWsNode2a = null;
gChildWsNode2b = null;
gChildTextNode3a = null;
gChildWsNode3 = null;
gChildTextNode3b = null;
}
function testGetCompatModeQuirks() {
var quirksIfr = document.createElement('iframe');
document.body.appendChild(quirksIfr);
// Webkit used to default to standards mode, but fixed this in
// Safari 4/Chrome 2, aka, WebKit 530.
// Also IE10 fails here.
// TODO(johnlenz): IE10+ inherit quirks mode from the owner document
// according to:
// http://msdn.microsoft.com/en-us/library/ff955402(v=vs.85).aspx
// but this test shows different behavior for IE10 and 11. If we discover
// that we care about quirks mode documents we should investigate
// this failure.
expectedFailures.expectFailureFor(
(goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('530')) ||
(goog.userAgent.IE && goog.userAgent.isVersionOrHigher('10') &&
!goog.userAgent.isVersionOrHigher('11')));
expectedFailures.run(function() {
assertFalse('Empty sourceless iframe is quirks mode, not standards mode',
goog.editor.node.isStandardsMode(
goog.dom.getFrameContentDocument(quirksIfr)));
});
document.body.removeChild(quirksIfr);
}
function testGetCompatModeStandards() {
var standardsIfr = document.createElement('iframe');
document.body.appendChild(standardsIfr);
var doc = goog.dom.getFrameContentDocument(standardsIfr);
doc.open();
doc.write('<!DOCTYPE HTML><html><head></head><body>&nbsp;</body></html>');
doc.close();
assertTrue('Iframe with DOCTYPE written in is standards mode',
goog.editor.node.isStandardsMode(doc));
document.body.removeChild(standardsIfr);
}
/**
* Creates a DOM tree and tests that getLeftMostLeaf returns proper node
*/
function testGetLeftMostLeaf() {
setUpDomTree();
assertEquals('Should skip ws node', gChildMixedNode1,
goog.editor.node.getLeftMostLeaf(parentNode));
assertEquals('Should skip ws node', gChildMixedNode1,
goog.editor.node.getLeftMostLeaf(childNode1));
assertEquals('Has no non ws leaves', childNode2,
goog.editor.node.getLeftMostLeaf(childNode2));
assertEquals('Should return first child', gChildTextNode3a,
goog.editor.node.getLeftMostLeaf(childNode3));
assertEquals('Has no children', gChildTextNode1,
goog.editor.node.getLeftMostLeaf(gChildTextNode1));
tearDownDomTree();
}
/**
* Creates a DOM tree and tests that getRightMostLeaf returns proper node
*/
function testGetRightMostLeaf() {
setUpDomTree();
assertEquals("Should return child3's rightmost child", gChildTextNode3b,
goog.editor.node.getRightMostLeaf(parentNode));
assertEquals('Should skip ws node', gChildTextNode1,
goog.editor.node.getRightMostLeaf(childNode1));
assertEquals('Has no non ws leaves', childNode2,
goog.editor.node.getRightMostLeaf(childNode2));
assertEquals('Should return last child', gChildTextNode3b,
goog.editor.node.getRightMostLeaf(childNode3));
assertEquals('Has no children', gChildTextNode1,
goog.editor.node.getRightMostLeaf(gChildTextNode1));
tearDownDomTree();
}
/**
* Creates a DOM tree and tests that getFirstChild properly ignores
* ignorable nodes
*/
function testGetFirstChild() {
setUpDomTree();
assertNull('Has no none ws children',
goog.editor.node.getFirstChild(childNode2));
assertEquals('Should skip first child, as it is ws', gChildMixedNode1,
goog.editor.node.getFirstChild(childNode1));
assertEquals('Should just return first child', gChildTextNode3a,
goog.editor.node.getFirstChild(childNode3));
assertEquals('Should return first child', childNode1,
goog.editor.node.getFirstChild(parentNode));
assertNull('First child of a text node should return null',
goog.editor.node.getFirstChild(gChildTextNode1));
assertNull('First child of null should return null',
goog.editor.node.getFirstChild(null));
tearDownDomTree();
}
/**
* Create a DOM tree and test that getLastChild properly ignores
* ignorable nodes
*/
function testGetLastChild() {
setUpDomTree();
assertNull('Has no none ws children',
goog.editor.node.getLastChild(childNode2));
assertEquals('Should skip last child, as it is ws', gChildTextNode1,
goog.editor.node.getLastChild(childNode1));
assertEquals('Should just return last child', gChildTextNode3b,
goog.editor.node.getLastChild(childNode3));
assertEquals('Should return last child', childNode3,
goog.editor.node.getLastChild(parentNode));
assertNull('Last child of a text node should return null',
goog.editor.node.getLastChild(gChildTextNode1));
assertNull('Last child of null should return null',
goog.editor.node.getLastChild(gChildTextNode1));
tearDownDomTree();
}
/**
* Test if nodes that should be ignorable return false and nodes that should
* not be ignored return true.
*/
function testIsImportant() {
var wsNode = document.createTextNode(' \t\r\n');
assertFalse('White space node is ignorable',
goog.editor.node.isImportant(wsNode));
var textNode = document.createTextNode('Hello');
assertTrue('Text node is important', goog.editor.node.isImportant(textNode));
var nbspNode = document.createTextNode('\u00a0');
assertTrue('Node with nbsp is important',
goog.editor.node.isImportant(nbspNode));
var imageNode = document.createElement('img');
assertTrue('Image node is important',
goog.editor.node.isImportant(imageNode));
}
/**
* Test that isAllNonNbspWhiteSpace returns true if node contains only
* whitespace that is not nbsp and false otherwise
*/
function testIsAllNonNbspWhiteSpace() {
var wsNode = document.createTextNode(' \t\r\n');
assertTrue('String is all non nbsp',
goog.editor.node.isAllNonNbspWhiteSpace(wsNode));
var textNode = document.createTextNode('Hello');
assertFalse('String should not be whitespace',
goog.editor.node.isAllNonNbspWhiteSpace(textNode));
var nbspNode = document.createTextNode('\u00a0');
assertFalse('String has nbsp',
goog.editor.node.isAllNonNbspWhiteSpace(nbspNode));
}
/**
* Creates a DOM tree and Test that getPreviousSibling properly ignores
* ignorable nodes
*/
function testGetPreviousSibling() {
setUpDomTree();
assertNull('No previous sibling',
goog.editor.node.getPreviousSibling(gChildTextNode3a));
assertEquals('Should have text sibling', gChildTextNode3a,
goog.editor.node.getPreviousSibling(gChildWsNode3));
assertEquals('Should skip over white space sibling', gChildTextNode3a,
goog.editor.node.getPreviousSibling(gChildTextNode3b));
assertNull('No previous sibling',
goog.editor.node.getPreviousSibling(gChildMixedNode1));
assertEquals('Should have mixed text sibling', gChildMixedNode1,
goog.editor.node.getPreviousSibling(gChildWsNode1));
assertEquals('Should skip over white space sibling', gChildMixedNode1,
goog.editor.node.getPreviousSibling(gChildNbspNode1));
assertNotEquals('Should not move past ws and nbsp', gChildMixedNode1,
goog.editor.node.getPreviousSibling(gChildTextNode1));
assertEquals('Should go to child 2', childNode2,
goog.editor.node.getPreviousSibling(childNode3));
assertEquals('Should go to child 1', childNode1,
goog.editor.node.getPreviousSibling(childNode2));
assertNull('Only has white space siblings',
goog.editor.node.getPreviousSibling(gChildWsNode2b));
tearDownDomTree();
}
/**
* Creates a DOM tree and tests that getNextSibling properly ignores igrnorable
* nodes when determining the next sibling
*/
function testGetNextSibling() {
setUpDomTree();
assertEquals('Child 1 should have Child 2', childNode2,
goog.editor.node.getNextSibling(childNode1));
assertEquals('Child 2 should have child 3', childNode3,
goog.editor.node.getNextSibling(childNode2));
assertNull('Child 3 has no next sibling',
goog.editor.node.getNextSibling(childNode3));
assertNotEquals('Should not skip ws and nbsp nodes', gChildTextNode1,
goog.editor.node.getNextSibling(gChildMixedNode1));
assertNotEquals('Should not skip nbsp node', gChildTextNode1,
goog.editor.node.getNextSibling(gChildWsNode1));
assertEquals('Should have sibling', gChildTextNode1,
goog.editor.node.getNextSibling(gChildNbspNode1));
assertNull('Should have no next sibling',
goog.editor.node.getNextSibling(gChildTextNode1));
assertNull('Only has ws sibling',
goog.editor.node.getNextSibling(gChildWsNode2a));
assertNull('Has no next sibling',
goog.editor.node.getNextSibling(gChildWsNode2b));
assertEquals('Should skip ws node', gChildTextNode3b,
goog.editor.node.getNextSibling(gChildTextNode3a));
tearDownDomTree();
}
function testIsEmpty() {
var textNode = document.createTextNode('');
assertTrue('Text node with no content should be empty',
goog.editor.node.isEmpty(textNode));
textNode.data = '\xa0';
assertTrue('Text node with nbsp should be empty',
goog.editor.node.isEmpty(textNode));
assertFalse('Text node with nbsp should not be empty when prohibited',
goog.editor.node.isEmpty(textNode, true));
textNode.data = ' ';
assertTrue('Text node with whitespace should be empty',
goog.editor.node.isEmpty(textNode));
textNode.data = 'notEmpty';
assertFalse('Text node with text should not be empty',
goog.editor.node.isEmpty(textNode));
var div = document.createElement('div');
assertTrue('Empty div should be empty',
goog.editor.node.isEmpty(div));
div.innerHTML = '<iframe></iframe>';
assertFalse('Div containing an iframe is not empty',
goog.editor.node.isEmpty(div));
div.innerHTML = '<img></img>';
assertFalse('Div containing an image is not empty',
goog.editor.node.isEmpty(div));
div.innerHTML = '<embed></embed>';
assertFalse('Div containing an embed is not empty',
goog.editor.node.isEmpty(div));
div.innerHTML = '<div><span></span></div>';
assertTrue('Div containing other empty tags is empty',
goog.editor.node.isEmpty(div));
div.innerHTML = '<div><span> </span></div>';
assertTrue('Div containing other empty tags and whitespace is empty',
goog.editor.node.isEmpty(div));
div.innerHTML = '<div><span>Not empty</span></div>';
assertFalse('Div containing tags and text is not empty',
goog.editor.node.isEmpty(div));
var img = document.createElement(goog.dom.TagName.IMG);
assertFalse('Empty img should not be empty',
goog.editor.node.isEmpty(img));
var iframe = document.createElement(goog.dom.TagName.IFRAME);
assertFalse('Empty iframe should not be empty',
goog.editor.node.isEmpty(iframe));
var embed = document.createElement('embed');
assertFalse('Empty embed should not be empty',
goog.editor.node.isEmpty(embed));
}
/**
* Test that getLength returns 0 if the node has no length and no children,
* the # of children if the node has no length but does have children,
* and the length of the node if the node does have length
*/
function testGetLength() {
var parentNode = document.createElement('p');
assertEquals('Length 0 and no children', 0,
goog.editor.node.getLength(parentNode));
var childNode1 = document.createTextNode('node 1');
var childNode2 = document.createTextNode('node number 2');
var childNode3 = document.createTextNode('');
parentNode.appendChild(childNode1);
parentNode.appendChild(childNode2);
parentNode.appendChild(childNode3);
assertEquals('Length 0 and 3 children', 3,
goog.editor.node.getLength(parentNode));
assertEquals('Text node, length 6', 6,
goog.editor.node.getLength(childNode1));
assertEquals('Text node, length 0', 0,
goog.editor.node.getLength(childNode3));
}
function testFindInChildrenSuccess() {
var parentNode = document.createElement('div');
parentNode.innerHTML = '<div>foo</div><b>foo2</b>';
var index = goog.editor.node.findInChildren(parentNode,
function(node) {
return node.tagName == 'B';
});
assertEquals('Should find second child', index, 1);
}
function testFindInChildrenFailure() {
var parentNode = document.createElement('div');
parentNode.innerHTML = '<div>foo</div><b>foo2</b>';
var index = goog.editor.node.findInChildren(parentNode,
function(node) {
return false;
});
assertNull("Shouldn't find a child", index);
}
function testFindHighestMatchingAncestor() {
setUpDomTree();
var predicateFunc = function(node) {
return node.tagName == 'DIV';
};
var node = goog.editor.node.findHighestMatchingAncestor(
gChildTextNode3a, predicateFunc);
assertNotNull('Should return an ancestor', node);
assertEquals('Should have found "parentNode" as the last ' +
'ancestor matching the predicate',
parentNode,
node);
predicateFunc = function(node) {
return node.childNodes.length == 1;
};
node = goog.editor.node.findHighestMatchingAncestor(gChildTextNode3a,
predicateFunc);
assertNull("Shouldn't return an ancestor", node);
tearDownDomTree();
}
function testIsBlock() {
var blockDisplays = ['block', 'list-item', 'table', 'table-caption',
'table-cell', 'table-column', 'table-column-group', 'table-footer',
'table-footer-group', 'table-header-group', 'table-row',
'table-row-group'];
var structuralTags = [
goog.dom.TagName.BODY,
goog.dom.TagName.FRAME,
goog.dom.TagName.FRAMESET,
goog.dom.TagName.HEAD,
goog.dom.TagName.HTML
];
// The following tags are considered inline in IE, except LEGEND which is
// only a block element in WEBKIT.
var ambiguousTags = [
goog.dom.TagName.DETAILS,
goog.dom.TagName.HR,
goog.dom.TagName.ISINDEX,
goog.dom.TagName.LEGEND,
goog.dom.TagName.MAP,
goog.dom.TagName.NOFRAMES,
goog.dom.TagName.OPTGROUP,
goog.dom.TagName.OPTION,
goog.dom.TagName.SUMMARY
];
// Older versions of IE and Gecko consider the following elements to be
// inline, but IE9+ and Gecko 2.0+ recognize the new elements.
var legacyAmbiguousTags = [
goog.dom.TagName.ARTICLE,
goog.dom.TagName.ASIDE,
goog.dom.TagName.FIGCAPTION,
goog.dom.TagName.FIGURE,
goog.dom.TagName.FOOTER,
goog.dom.TagName.HEADER,
goog.dom.TagName.HGROUP,
goog.dom.TagName.NAV,
goog.dom.TagName.SECTION
];
var tagsToIgnore = goog.array.flatten(structuralTags, ambiguousTags);
if ((goog.userAgent.IE && !goog.userAgent.isDocumentModeOrHigher(9)) ||
(goog.userAgent.GECKO && !goog.userAgent.isVersionOrHigher('2'))) {
goog.array.extend(tagsToIgnore, legacyAmbiguousTags);
}
// Appending an applet tag can cause the test to hang if Java is blocked on
// the system.
tagsToIgnore.push(goog.dom.TagName.APPLET);
// Appending an embed tag to the page in IE brings up a warning dialog about
// loading Java content.
if (goog.userAgent.IE) {
tagsToIgnore.push(goog.dom.TagName.EMBED);
}
for (var tag in goog.dom.TagName) {
if (goog.array.contains(tagsToIgnore, tag)) {
continue;
}
var el = goog.dom.createElement(tag);
document.body.appendChild(el);
var display = goog.style.getCascadedStyle(el, 'display') ||
goog.style.getComputedStyle(el, 'display');
goog.dom.removeNode(el);
if (goog.editor.node.isBlockTag(el)) {
assertContains('Display for ' + tag + ' should be block-like',
display, blockDisplays);
} else {
assertNotContains('Display for ' + tag + ' should not be block-like',
display, blockDisplays);
}
}
}
function createDivWithTextNodes(var_args) {
var dom = goog.dom.createDom('div');
for (var i = 0; i < arguments.length; i++) {
goog.dom.appendChild(dom, goog.dom.createTextNode(arguments[i]));
}
return dom;
}
function testSkipEmptyTextNodes() {
assertNull('skipEmptyTextNodes should gracefully handle null',
goog.editor.node.skipEmptyTextNodes(null));
var dom1 = createDivWithTextNodes('abc', '', 'xyz', '', '');
assertEquals('expected not to skip first child', dom1.firstChild,
goog.editor.node.skipEmptyTextNodes(dom1.firstChild));
assertEquals('expected to skip second child', dom1.childNodes[2],
goog.editor.node.skipEmptyTextNodes(dom1.childNodes[1]));
assertNull('expected to skip all the rest of the children',
goog.editor.node.skipEmptyTextNodes(dom1.childNodes[3]));
}
function testIsEditableContainer() {
var editableContainerElement = document.getElementById('editableTest');
assertTrue('Container element should be considered editable container',
goog.editor.node.isEditableContainer(editableContainerElement));
var nonEditableContainerElement = document.getElementById('parentNode');
assertFalse('Other element should not be considered editable container',
goog.editor.node.isEditableContainer(nonEditableContainerElement));
}
function testIsEditable() {
var editableContainerElement = document.getElementById('editableTest');
var childNode = editableContainerElement.firstChild;
var childElement = editableContainerElement.getElementsByTagName('span')[0];
assertFalse('Container element should not be considered editable',
goog.editor.node.isEditable(editableContainerElement));
assertTrue('Child text node should be considered editable',
goog.editor.node.isEditable(childNode));
assertTrue('Child element should be considered editable',
goog.editor.node.isEditable(childElement));
assertTrue('Grandchild node should be considered editable',
goog.editor.node.isEditable(childElement.firstChild));
assertFalse('Other element should not be considered editable',
goog.editor.node.isEditable(document.getElementById('parentNode')));
}
function testFindTopMostEditableAncestor() {
var root = document.getElementById('editableTest');
var span = root.getElementsByTagName(goog.dom.TagName.SPAN)[0];
var textNode = span.firstChild;
assertEquals('Should return self if self is matched.',
textNode, goog.editor.node.findTopMostEditableAncestor(textNode,
function(node) {
return node.nodeType == goog.dom.NodeType.TEXT;
}));
assertEquals('Should not walk out of editable node.',
null, goog.editor.node.findTopMostEditableAncestor(textNode,
function(node) {
return node.tagName == goog.dom.TagName.BODY;
}));
assertEquals('Should not match editable container.',
null, goog.editor.node.findTopMostEditableAncestor(textNode,
function(node) {
return node.tagName == goog.dom.TagName.DIV;
}));
assertEquals('Should find node in editable container.',
span, goog.editor.node.findTopMostEditableAncestor(textNode,
function(node) {
return node.tagName == goog.dom.TagName.SPAN;
}));
}
function testSplitDomTreeAt() {
var innerHTML = '<p>1<b>2</b>3</p>';
var root = goog.dom.createElement(goog.dom.TagName.DIV);
root.innerHTML = innerHTML;
var result = goog.editor.node.splitDomTreeAt(
root.getElementsByTagName(goog.dom.TagName.B)[0], null, root);
goog.testing.dom.assertHtmlContentsMatch('<p>1<b>2</b></p>', root);
goog.testing.dom.assertHtmlContentsMatch('<p>3</p>', result);
root.innerHTML = innerHTML;
result = goog.editor.node.splitDomTreeAt(
root.getElementsByTagName(goog.dom.TagName.B)[0],
goog.dom.createTextNode('and'),
root);
goog.testing.dom.assertHtmlContentsMatch('<p>1<b>2</b></p>', root);
goog.testing.dom.assertHtmlContentsMatch('<p>and3</p>', result);
}
function testTransferChildren() {
var prefix = '<b>Bold 1</b>';
var innerHTML = '<b>Bold</b><ul><li>Item 1</li><li>Item 2</li></ul>';
var root1 = goog.dom.createElement(goog.dom.TagName.DIV);
root1.innerHTML = innerHTML;
var root2 = goog.dom.createElement(goog.dom.TagName.P);
root2.innerHTML = prefix;
var b = root1.getElementsByTagName(goog.dom.TagName.B)[0];
// Transfer the children.
goog.editor.node.transferChildren(root2, root1);
assertEquals(0, root1.childNodes.length);
goog.testing.dom.assertHtmlContentsMatch(prefix + innerHTML, root2);
assertEquals(b, root2.getElementsByTagName(goog.dom.TagName.B)[1]);
// Transfer them back.
goog.editor.node.transferChildren(root1, root2);
assertEquals(0, root2.childNodes.length);
goog.testing.dom.assertHtmlContentsMatch(prefix + innerHTML, root1);
assertEquals(b, root1.getElementsByTagName(goog.dom.TagName.B)[1]);
}
@@ -0,0 +1,463 @@
// 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 Abstract API for TrogEdit plugins.
*
* @see ../demos/editor/editor.html
*/
goog.provide('goog.editor.Plugin');
// TODO(user): Remove the dependency on goog.editor.Command asap. Currently only
// needed for execCommand issues with links.
goog.require('goog.events.EventTarget');
goog.require('goog.functions');
goog.require('goog.log');
goog.require('goog.object');
goog.require('goog.reflect');
goog.require('goog.userAgent');
/**
* Abstract API for trogedit plugins.
* @constructor
* @extends {goog.events.EventTarget}
*/
goog.editor.Plugin = function() {
goog.events.EventTarget.call(this);
/**
* Whether this plugin is enabled for the registered field object.
* @type {boolean}
* @private
*/
this.enabled_ = this.activeOnUneditableFields();
};
goog.inherits(goog.editor.Plugin, goog.events.EventTarget);
/**
* The field object this plugin is attached to.
* @type {goog.editor.Field}
* @protected
* @deprecated Use goog.editor.Plugin.getFieldObject and
* goog.editor.Plugin.setFieldObject.
*/
goog.editor.Plugin.prototype.fieldObject = null;
/**
* @return {goog.dom.DomHelper?} The dom helper object associated with the
* currently active field.
*/
goog.editor.Plugin.prototype.getFieldDomHelper = function() {
return this.getFieldObject() && this.getFieldObject().getEditableDomHelper();
};
/**
* Indicates if this plugin should be automatically disposed when the
* registered field is disposed. This should be changed to false for
* plugins used as multi-field plugins.
* @type {boolean}
* @private
*/
goog.editor.Plugin.prototype.autoDispose_ = true;
/**
* The logger for this plugin.
* @type {goog.log.Logger}
* @protected
*/
goog.editor.Plugin.prototype.logger =
goog.log.getLogger('goog.editor.Plugin');
/**
* Sets the field object for use with this plugin.
* @return {goog.editor.Field} The editable field object.
* @protected
* @suppress {deprecated} Until fieldObject can be made private.
*/
goog.editor.Plugin.prototype.getFieldObject = function() {
return this.fieldObject;
};
/**
* Sets the field object for use with this plugin.
* @param {goog.editor.Field} fieldObject The editable field object.
* @protected
* @suppress {deprecated} Until fieldObject can be made private.
*/
goog.editor.Plugin.prototype.setFieldObject = function(fieldObject) {
this.fieldObject = fieldObject;
};
/**
* Registers the field object for use with this plugin.
* @param {goog.editor.Field} fieldObject The editable field object.
*/
goog.editor.Plugin.prototype.registerFieldObject = function(fieldObject) {
this.setFieldObject(fieldObject);
};
/**
* Unregisters and disables this plugin for the current field object.
* @param {goog.editor.Field} fieldObj The field object. For single-field
* plugins, this parameter is ignored.
*/
goog.editor.Plugin.prototype.unregisterFieldObject = function(fieldObj) {
if (this.getFieldObject()) {
this.disable(this.getFieldObject());
this.setFieldObject(null);
}
};
/**
* Enables this plugin for the specified, registered field object. A field
* object should only be enabled when it is loaded.
* @param {goog.editor.Field} fieldObject The field object.
*/
goog.editor.Plugin.prototype.enable = function(fieldObject) {
if (this.getFieldObject() == fieldObject) {
this.enabled_ = true;
} else {
goog.log.error(this.logger, 'Trying to enable an unregistered field with ' +
'this plugin.');
}
};
/**
* Disables this plugin for the specified, registered field object.
* @param {goog.editor.Field} fieldObject The field object.
*/
goog.editor.Plugin.prototype.disable = function(fieldObject) {
if (this.getFieldObject() == fieldObject) {
this.enabled_ = false;
} else {
goog.log.error(this.logger, 'Trying to disable an unregistered field ' +
'with this plugin.');
}
};
/**
* Returns whether this plugin is enabled for the field object.
*
* @param {goog.editor.Field} fieldObject The field object.
* @return {boolean} Whether this plugin is enabled for the field object.
*/
goog.editor.Plugin.prototype.isEnabled = function(fieldObject) {
return this.getFieldObject() == fieldObject ? this.enabled_ : false;
};
/**
* Set if this plugin should automatically be disposed when the registered
* field is disposed.
* @param {boolean} autoDispose Whether to autoDispose.
*/
goog.editor.Plugin.prototype.setAutoDispose = function(autoDispose) {
this.autoDispose_ = autoDispose;
};
/**
* @return {boolean} Whether or not this plugin should automatically be disposed
* when it's registered field is disposed.
*/
goog.editor.Plugin.prototype.isAutoDispose = function() {
return this.autoDispose_;
};
/**
* @return {boolean} If true, field will not disable the command
* when the field becomes uneditable.
*/
goog.editor.Plugin.prototype.activeOnUneditableFields = goog.functions.FALSE;
/**
* @param {string} command The command to check.
* @return {boolean} If true, field will not dispatch change events
* for commands of this type. This is useful for "seamless" plugins like
* dialogs and lorem ipsum.
*/
goog.editor.Plugin.prototype.isSilentCommand = goog.functions.FALSE;
/** @override */
goog.editor.Plugin.prototype.disposeInternal = function() {
if (this.getFieldObject()) {
this.unregisterFieldObject(this.getFieldObject());
}
goog.editor.Plugin.superClass_.disposeInternal.call(this);
};
/**
* @return {string} The ID unique to this plugin class. Note that different
* instances off the plugin share the same classId.
*/
goog.editor.Plugin.prototype.getTrogClassId;
/**
* An enum of operations that plugins may support.
* @enum {number}
*/
goog.editor.Plugin.Op = {
KEYDOWN: 1,
KEYPRESS: 2,
KEYUP: 3,
SELECTION: 4,
SHORTCUT: 5,
EXEC_COMMAND: 6,
QUERY_COMMAND: 7,
PREPARE_CONTENTS_HTML: 8,
CLEAN_CONTENTS_HTML: 10,
CLEAN_CONTENTS_DOM: 11
};
/**
* A map from plugin operations to the names of the methods that
* invoke those operations.
*/
goog.editor.Plugin.OPCODE = goog.object.transpose(
goog.reflect.object(goog.editor.Plugin, {
handleKeyDown: goog.editor.Plugin.Op.KEYDOWN,
handleKeyPress: goog.editor.Plugin.Op.KEYPRESS,
handleKeyUp: goog.editor.Plugin.Op.KEYUP,
handleSelectionChange: goog.editor.Plugin.Op.SELECTION,
handleKeyboardShortcut: goog.editor.Plugin.Op.SHORTCUT,
execCommand: goog.editor.Plugin.Op.EXEC_COMMAND,
queryCommandValue: goog.editor.Plugin.Op.QUERY_COMMAND,
prepareContentsHtml: goog.editor.Plugin.Op.PREPARE_CONTENTS_HTML,
cleanContentsHtml: goog.editor.Plugin.Op.CLEAN_CONTENTS_HTML,
cleanContentsDom: goog.editor.Plugin.Op.CLEAN_CONTENTS_DOM
}));
/**
* A set of op codes that run even on disabled plugins.
*/
goog.editor.Plugin.IRREPRESSIBLE_OPS = goog.object.createSet(
goog.editor.Plugin.Op.PREPARE_CONTENTS_HTML,
goog.editor.Plugin.Op.CLEAN_CONTENTS_HTML,
goog.editor.Plugin.Op.CLEAN_CONTENTS_DOM);
/**
* Handles keydown. It is run before handleKeyboardShortcut and if it returns
* true handleKeyboardShortcut will not be called.
* @param {!goog.events.BrowserEvent} e The browser event.
* @return {boolean} Whether the event was handled and thus should *not* be
* propagated to other plugins or handleKeyboardShortcut.
*/
goog.editor.Plugin.prototype.handleKeyDown;
/**
* Handles keypress. It is run before handleKeyboardShortcut and if it returns
* true handleKeyboardShortcut will not be called.
* @param {!goog.events.BrowserEvent} e The browser event.
* @return {boolean} Whether the event was handled and thus should *not* be
* propagated to other plugins or handleKeyboardShortcut.
*/
goog.editor.Plugin.prototype.handleKeyPress;
/**
* Handles keyup.
* @param {!goog.events.BrowserEvent} e The browser event.
* @return {boolean} Whether the event was handled and thus should *not* be
* propagated to other plugins.
*/
goog.editor.Plugin.prototype.handleKeyUp;
/**
* Handles selection change.
* @param {!goog.events.BrowserEvent=} opt_e The browser event.
* @param {!Node=} opt_target The node the selection changed to.
* @return {boolean} Whether the event was handled and thus should *not* be
* propagated to other plugins.
*/
goog.editor.Plugin.prototype.handleSelectionChange;
/**
* Handles keyboard shortcuts. Preferred to using handleKey* as it will use
* the proper event based on browser and will be more performant. If
* handleKeyPress/handleKeyDown returns true, this will not be called. If the
* plugin handles the shortcut, it is responsible for dispatching appropriate
* events (change, selection change at the time of this comment). If the plugin
* calls execCommand on the editable field, then execCommand already takes care
* of dispatching events.
* NOTE: For performance reasons this is only called when any key is pressed
* in conjunction with ctrl/meta keys OR when a small subset of keys (defined
* in goog.editor.Field.POTENTIAL_SHORTCUT_KEYCODES_) are pressed without
* ctrl/meta keys. We specifically don't invoke it when altKey is pressed since
* alt key is used in many i8n UIs to enter certain characters.
* @param {!goog.events.BrowserEvent} e The browser event.
* @param {string} key The key pressed.
* @param {boolean} isModifierPressed Whether the ctrl/meta key was pressed or
* not.
* @return {boolean} Whether the event was handled and thus should *not* be
* propagated to other plugins. We also call preventDefault on the event if
* the return value is true.
*/
goog.editor.Plugin.prototype.handleKeyboardShortcut;
/**
* Handles execCommand. This default implementation handles dispatching
* BEFORECHANGE, CHANGE, and SELECTIONCHANGE events, and calls
* execCommandInternal to perform the actual command. Plugins that want to
* do their own event dispatching should override execCommand, otherwise
* it is preferred to only override execCommandInternal.
*
* This version of execCommand will only work for single field plugins.
* Multi-field plugins must override execCommand.
*
* @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.
*/
goog.editor.Plugin.prototype.execCommand = function(command, var_args) {
// TODO(user): Replace all uses of isSilentCommand with plugins that just
// override this base execCommand method.
var silent = this.isSilentCommand(command);
if (!silent) {
// Stop listening to mutation events in Firefox while text formatting
// is happening. This prevents us from trying to size the field in the
// middle of an execCommand, catching the field in a strange intermediary
// state where both replacement nodes and original nodes are appended to
// the dom. Note that change events get turned back on by
// fieldObj.dispatchChange.
if (goog.userAgent.GECKO) {
this.getFieldObject().stopChangeEvents(true, true);
}
this.getFieldObject().dispatchBeforeChange();
}
try {
var result = this.execCommandInternal.apply(this, arguments);
} finally {
// If the above execCommandInternal call throws an exception, we still need
// to turn change events back on (see http://b/issue?id=1471355).
// NOTE: If if you add to or change the methods called in this finally
// block, please add them as expected calls to the unit test function
// testExecCommandException().
if (!silent) {
// dispatchChange includes a call to startChangeEvents, which unwinds the
// call to stopChangeEvents made before the try block.
this.getFieldObject().dispatchChange();
this.getFieldObject().dispatchSelectionChangeEvent();
}
}
return result;
};
/**
* Handles execCommand. This default implementation does nothing, and is
* called by execCommand, which handles event dispatching. This method should
* be overriden by plugins that don't need to do their own event dispatching.
* If custom event dispatching is needed, execCommand shoul be overriden
* instead.
*
* @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.
* @protected
*/
goog.editor.Plugin.prototype.execCommandInternal;
/**
* Gets the state of this command if this plugin serves that command.
* @param {string} command The command to check.
* @return {*} The value of the command.
*/
goog.editor.Plugin.prototype.queryCommandValue;
/**
* Prepares the given HTML for editing. Strips out content that should not
* appear in an editor, and normalizes content as appropriate. The inverse
* of cleanContentsHtml.
*
* This op is invoked even on disabled plugins.
*
* @param {string} originalHtml The original HTML.
* @param {Object} styles A map of strings. If the plugin wants to add
* any styles to the field element, it should add them as key-value
* pairs to this object.
* @return {string} New HTML that's ok for editing.
*/
goog.editor.Plugin.prototype.prepareContentsHtml;
/**
* Cleans the contents of the node passed to it. The node contents are modified
* directly, and the modifications will subsequently be used, for operations
* such as saving the innerHTML of the editor etc. Since the plugins act on
* the DOM directly, this method can be very expensive.
*
* This op is invoked even on disabled plugins.
*
* @param {!Element} fieldCopy The copy of the editable field which
* needs to be cleaned up.
*/
goog.editor.Plugin.prototype.cleanContentsDom;
/**
* Cleans the html contents of Trogedit. Both cleanContentsDom and
* and cleanContentsHtml will be called on contents extracted from Trogedit.
* The inverse of prepareContentsHtml.
*
* This op is invoked even on disabled plugins.
*
* @param {string} originalHtml The trogedit HTML.
* @return {string} Cleaned-up HTML.
*/
goog.editor.Plugin.prototype.cleanContentsHtml;
/**
* Whether the string corresponds to a command this plugin handles.
* @param {string} command Command string to check.
* @return {boolean} Whether the plugin handles this type of command.
*/
goog.editor.Plugin.prototype.isSupportedCommand = function(command) {
return false;
};
@@ -0,0 +1,30 @@
<!--
All Rights Reserved.
Tests for goog.editor.Plugin.
@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>
Editor Unit Tests - goog.editor.Plugin
</title>
<script src="../base.js">
</script>
<script src="../deps.js">
</script>
<script>
goog.require('goog.editor.PluginTest');
</script>
</head>
<body>
</body>
</html>
@@ -0,0 +1,177 @@
// 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.PluginTest');
goog.setTestOnly('goog.editor.PluginTest');
goog.require('goog.editor.Field');
goog.require('goog.editor.Plugin');
goog.require('goog.functions');
goog.require('goog.testing.StrictMock');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var plugin;
var fieldObject;
function setUp() {
plugin = new goog.editor.Plugin();
fieldObject = {};
}
function tearDown() {
plugin.dispose();
}
function testRegisterFieldObject() {
plugin.registerFieldObject(fieldObject);
assertEquals('Register field object must be stored in protected field.',
fieldObject, plugin.fieldObject);
assertFalse('Newly registered plugin must not be enabled.',
plugin.isEnabled(fieldObject));
}
function testUnregisterFieldObject() {
plugin.registerFieldObject(fieldObject);
plugin.enable(fieldObject);
plugin.unregisterFieldObject(fieldObject);
assertNull('fieldObject property must be undefined after ' +
'unregistering a field object.', plugin.fieldObject);
assertFalse('Unregistered field object must not be enabled',
plugin.isEnabled(fieldObject));
}
function testEnable() {
plugin.registerFieldObject(fieldObject);
plugin.enable(fieldObject);
assertTrue('Enabled field object must be enabled according to isEnabled().',
plugin.isEnabled(fieldObject));
}
function testDisable() {
plugin.registerFieldObject(fieldObject);
plugin.enable(fieldObject);
plugin.disable(fieldObject);
assertFalse('Disabled field object must be disabled according to ' +
'isEnabled().', plugin.isEnabled(fieldObject));
}
function testIsEnabled() {
// Other base cases covered while testing enable() and disable().
assertFalse('Unregistered field object must be disabled according ' +
'to isEnabled().', plugin.isEnabled(fieldObject));
}
function testIsSupportedCommand() {
assertFalse('Base plugin class must not support any commands.',
plugin.isSupportedCommand('+indent'));
}
function testExecCommand() {
var mockField = new goog.testing.StrictMock(goog.editor.Field);
plugin.registerFieldObject(mockField);
if (goog.userAgent.GECKO) {
mockField.stopChangeEvents(true, true);
}
mockField.dispatchBeforeChange();
// Note(user): dispatch change turns back on (delayed) change events.
mockField.dispatchChange();
mockField.dispatchSelectionChangeEvent();
mockField.$replay();
var passedCommand, passedArg;
plugin.execCommandInternal = function(command, arg) {
passedCommand = command;
passedArg = arg;
};
plugin.execCommand('+indent', true);
// Verify that execCommand dispatched the expected events.
mockField.$verify();
mockField.$reset();
// Verify that execCommandInternal was called with the correct arguments.
assertEquals('+indent', passedCommand);
assertTrue(passedArg);
plugin.isSilentCommand = goog.functions.constant(true);
mockField.$replay();
plugin.execCommand('+outdent', false);
// Verify that execCommand on a silent plugin dispatched no events.
mockField.$verify();
// Verify that execCommandInternal was called with the correct arguments.
assertEquals('+outdent', passedCommand);
assertFalse(passedArg);
}
/**
* Regression test for http://b/issue?id=1471355 .
*/
function testExecCommandException() {
var mockField = new goog.testing.StrictMock(goog.editor.Field);
plugin.registerFieldObject(mockField);
plugin.execCommandInternal = function() {
throw 1;
};
if (goog.userAgent.GECKO) {
mockField.stopChangeEvents(true, true);
}
mockField.dispatchBeforeChange();
// Note(user): dispatch change turns back on (delayed) change events.
mockField.dispatchChange();
mockField.dispatchSelectionChangeEvent();
mockField.$replay();
assertThrows('Exception should not be swallowed', function() {
plugin.execCommand();
});
// Verifies that cleanup is done despite the exception.
mockField.$verify();
}
function testDisposed() {
plugin.registerFieldObject(fieldObject);
plugin.dispose();
assert(plugin.getDisposed());
assertNull('Disposed plugin must not have a field object.',
plugin.fieldObject);
assertFalse('Disposed plugin must not have an enabled field object.',
plugin.isEnabled(fieldObject));
}
function testIsAndSetAutoDispose() {
assertTrue('Plugin must start auto-disposable', plugin.isAutoDispose());
plugin.setAutoDispose(false);
assertFalse(plugin.isAutoDispose());
plugin.setAutoDispose(true);
assertTrue(plugin.isAutoDispose());
}
@@ -0,0 +1,712 @@
// Copyright 2005 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Base class for bubble plugins.
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.AbstractBubblePlugin');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.dom.classlist');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.style');
goog.require('goog.events');
goog.require('goog.events.EventHandler');
goog.require('goog.events.EventType');
goog.require('goog.events.KeyCodes');
goog.require('goog.events.actionEventWrapper');
goog.require('goog.functions');
goog.require('goog.string.Unicode');
goog.require('goog.ui.Component');
goog.require('goog.ui.editor.Bubble');
goog.require('goog.userAgent');
/**
* Base class for bubble plugins. This is used for to connect user behavior
* in the editor to a goog.ui.editor.Bubble UI element that allows
* the user to modify the properties of an element on their page (e.g. the alt
* text of an image tag).
*
* Subclasses should override the abstract method getBubbleTargetFromSelection()
* with code to determine if the current selection should activate the bubble
* type. The other abstract method createBubbleContents() should be overriden
* with code to create the inside markup of the bubble. The base class creates
* the rest of the bubble.
*
* @constructor
* @extends {goog.editor.Plugin}
*/
goog.editor.plugins.AbstractBubblePlugin = function() {
goog.editor.plugins.AbstractBubblePlugin.base(this, 'constructor');
/**
* Place to register events the plugin listens to.
* @type {goog.events.EventHandler<
* !goog.editor.plugins.AbstractBubblePlugin>}
* @protected
*/
this.eventRegister = new goog.events.EventHandler(this);
/**
* Instance factory function that creates a bubble UI component. If set to a
* non-null value, this function will be used to create a bubble instead of
* the global factory function. It takes as parameters the bubble parent
* element and the z index to draw the bubble at.
* @type {?function(!Element, number): !goog.ui.editor.Bubble}
* @private
*/
this.bubbleFactory_ = null;
};
goog.inherits(goog.editor.plugins.AbstractBubblePlugin, goog.editor.Plugin);
/**
* The css class name of option link elements.
* @type {string}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.OPTION_LINK_CLASSNAME_ =
goog.getCssName('tr_option-link');
/**
* The css class name of link elements.
* @type {string}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.LINK_CLASSNAME_ =
goog.getCssName('tr_bubble_link');
/**
* A class name to mark elements that should be reachable by keyboard tabbing.
* @type {string}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_ =
goog.getCssName('tr_bubble_tabbable');
/**
* The constant string used to separate option links.
* @type {string}
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.DASH_NBSP_STRING =
goog.string.Unicode.NBSP + '-' + goog.string.Unicode.NBSP;
/**
* Default factory function for creating a bubble UI component.
* @param {!Element} parent The parent element for the bubble.
* @param {number} zIndex The z index to draw the bubble at.
* @return {!goog.ui.editor.Bubble} The new bubble component.
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.defaultBubbleFactory_ = function(
parent, zIndex) {
return new goog.ui.editor.Bubble(parent, zIndex);
};
/**
* Global factory function that creates a bubble UI component. It takes as
* parameters the bubble parent element and the z index to draw the bubble at.
* @type {function(!Element, number): !goog.ui.editor.Bubble}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_ =
goog.editor.plugins.AbstractBubblePlugin.defaultBubbleFactory_;
/**
* Sets the global bubble factory function.
* @param {function(!Element, number): !goog.ui.editor.Bubble}
* bubbleFactory Function that creates a bubble for the given bubble parent
* element and z index.
*/
goog.editor.plugins.AbstractBubblePlugin.setBubbleFactory = function(
bubbleFactory) {
goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_ = bubbleFactory;
};
/**
* Map from field id to shared bubble object.
* @type {!Object<goog.ui.editor.Bubble>}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.bubbleMap_ = {};
/**
* The optional parent of the bubble. If null or not set, we will use the
* application document. This is useful when you have an editor embedded in
* a scrolling DIV.
* @type {Element|undefined}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.bubbleParent_;
/**
* The id of the panel this plugin added to the shared bubble. Null when
* this plugin doesn't currently have a panel in a bubble.
* @type {string?}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.panelId_ = null;
/**
* Whether this bubble should support tabbing through elements. False
* by default.
* @type {boolean}
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.keyboardNavigationEnabled_ =
false;
/**
* Sets the instance bubble factory function. If set to a non-null value, this
* function will be used to create a bubble instead of the global factory
* function.
* @param {?function(!Element, number): !goog.ui.editor.Bubble} bubbleFactory
* Function that creates a bubble for the given bubble parent element and z
* index. Null to reset the factory function.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.setBubbleFactory = function(
bubbleFactory) {
this.bubbleFactory_ = bubbleFactory;
};
/**
* Sets whether the bubble should support tabbing through elements.
* @param {boolean} keyboardNavigationEnabled
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.enableKeyboardNavigation =
function(keyboardNavigationEnabled) {
this.keyboardNavigationEnabled_ = keyboardNavigationEnabled;
};
/**
* Sets the bubble parent.
* @param {Element} bubbleParent An element where the bubble will be
* anchored. If null, we will use the application document. This
* is useful when you have an editor embedded in a scrolling div.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.setBubbleParent = function(
bubbleParent) {
this.bubbleParent_ = bubbleParent;
};
/**
* Returns the bubble map. Subclasses may override to use a separate map.
* @return {!Object<goog.ui.editor.Bubble>}
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleMap = function() {
return goog.editor.plugins.AbstractBubblePlugin.bubbleMap_;
};
/**
* @return {goog.dom.DomHelper} The dom helper for the bubble window.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleDom = function() {
return this.dom_;
};
/** @override */
goog.editor.plugins.AbstractBubblePlugin.prototype.getTrogClassId =
goog.functions.constant('AbstractBubblePlugin');
/**
* Returns the element whose properties the bubble manipulates.
* @return {Element} The target element.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getTargetElement =
function() {
return this.targetElement_;
};
/** @override */
goog.editor.plugins.AbstractBubblePlugin.prototype.handleKeyUp = function(e) {
// For example, when an image is selected, pressing any key overwrites
// the image and the panel should be hidden.
// Therefore we need to track key presses when the bubble is showing.
if (this.isVisible()) {
this.handleSelectionChange();
}
return false;
};
/**
* Pops up a property bubble for the given selection if appropriate and closes
* open property bubbles if no longer needed. This should not be overridden.
* @override
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.handleSelectionChange =
function(opt_e, opt_target) {
var selectedElement;
if (opt_e) {
selectedElement = /** @type {Element} */ (opt_e.target);
} else if (opt_target) {
selectedElement = /** @type {Element} */ (opt_target);
} else {
var range = this.getFieldObject().getRange();
if (range) {
var startNode = range.getStartNode();
var endNode = range.getEndNode();
var startOffset = range.getStartOffset();
var endOffset = range.getEndOffset();
// Sometimes in IE, the range will be collapsed, but think the end node
// and start node are different (although in the same visible position).
// In this case, favor the position IE thinks is the start node.
if (goog.userAgent.IE && range.isCollapsed() && startNode != endNode) {
range = goog.dom.Range.createCaret(startNode, startOffset);
}
if (startNode.nodeType == goog.dom.NodeType.ELEMENT &&
startNode == endNode && startOffset == endOffset - 1) {
var element = startNode.childNodes[startOffset];
if (element.nodeType == goog.dom.NodeType.ELEMENT) {
selectedElement = element;
}
}
}
selectedElement = selectedElement || range && range.getContainerElement();
}
return this.handleSelectionChangeInternal(selectedElement);
};
/**
* Pops up a property bubble for the given selection if appropriate and closes
* open property bubbles if no longer needed.
* @param {Element?} selectedElement The selected element.
* @return {boolean} Always false, allowing every bubble plugin to handle the
* event.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.
handleSelectionChangeInternal = function(selectedElement) {
if (selectedElement) {
var bubbleTarget = this.getBubbleTargetFromSelection(selectedElement);
if (bubbleTarget) {
if (bubbleTarget != this.targetElement_ || !this.panelId_) {
// Make sure any existing panel of the same type is closed before
// creating a new one.
if (this.panelId_) {
this.closeBubble();
}
this.createBubble(bubbleTarget);
}
return false;
}
}
if (this.panelId_) {
this.closeBubble();
}
return false;
};
/**
* Should be overriden by subclasses to return the bubble target element or
* null if an element of their required type isn't found.
* @param {Element} selectedElement The target of the selection change event or
* the parent container of the current entire selection.
* @return {Element?} The HTML bubble target element or null if no element of
* the required type is not found.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.
getBubbleTargetFromSelection = goog.abstractMethod;
/** @override */
goog.editor.plugins.AbstractBubblePlugin.prototype.disable = function(field) {
// When the field is made uneditable, dispose of the bubble. We do this
// because the next time the field is made editable again it may be in
// a different document / iframe.
if (field.isUneditable()) {
var bubbleMap = this.getBubbleMap();
var bubble = bubbleMap[field.id];
if (bubble) {
if (field == this.getFieldObject()) {
this.closeBubble();
}
bubble.dispose();
delete bubbleMap[field.id];
}
}
};
/**
* @return {!goog.ui.editor.Bubble} The shared bubble object for the field this
* plugin is registered on. Creates it if necessary.
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getSharedBubble_ =
function() {
var bubbleParent = /** @type {!Element} */ (this.bubbleParent_ ||
this.getFieldObject().getAppWindow().document.body);
this.dom_ = goog.dom.getDomHelper(bubbleParent);
var bubbleMap = this.getBubbleMap();
var bubble = bubbleMap[this.getFieldObject().id];
if (!bubble) {
var factory = this.bubbleFactory_ ||
goog.editor.plugins.AbstractBubblePlugin.globalBubbleFactory_;
bubble = factory.call(null, bubbleParent,
this.getFieldObject().getBaseZindex());
bubbleMap[this.getFieldObject().id] = bubble;
}
return bubble;
};
/**
* Creates and shows the property bubble.
* @param {Element} targetElement The target element of the bubble.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.createBubble = function(
targetElement) {
var bubble = this.getSharedBubble_();
if (!bubble.hasPanelOfType(this.getBubbleType())) {
this.targetElement_ = targetElement;
this.panelId_ = bubble.addPanel(this.getBubbleType(), this.getBubbleTitle(),
targetElement,
goog.bind(this.createBubbleContents, this),
this.shouldPreferBubbleAboveElement());
this.eventRegister.listen(bubble, goog.ui.Component.EventType.HIDE,
this.handlePanelClosed_);
this.onShow();
if (this.keyboardNavigationEnabled_) {
this.eventRegister.listen(bubble.getContentElement(),
goog.events.EventType.KEYDOWN, this.onBubbleKey_);
}
}
};
/**
* @return {string} The type of bubble shown by this plugin. Usually the tag
* name of the element this bubble targets.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleType = function() {
return '';
};
/**
* @return {string} The title for bubble shown by this plugin. Defaults to no
* title. Should be overridden by subclasses.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.getBubbleTitle = function() {
return '';
};
/**
* @return {boolean} Whether the bubble should prefer placement above the
* target element.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.
shouldPreferBubbleAboveElement = goog.functions.FALSE;
/**
* Should be overriden by subclasses to add the type specific contents to the
* bubble.
* @param {Element} bubbleContainer The container element of the bubble to
* which the contents should be added.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.createBubbleContents =
goog.abstractMethod;
/**
* Register the handler for the target's CLICK event.
* @param {Element} target The event source element.
* @param {Function} handler The event handler.
* @protected
* @deprecated Use goog.editor.plugins.AbstractBubblePlugin.
* registerActionHandler to register click and enter events.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.registerClickHandler =
function(target, handler) {
this.registerActionHandler(target, handler);
};
/**
* Register the handler for the target's CLICK and ENTER key events.
* @param {Element} target The event source element.
* @param {Function} handler The event handler.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.registerActionHandler =
function(target, handler) {
this.eventRegister.listenWithWrapper(target, goog.events.actionEventWrapper,
handler);
};
/**
* Closes the bubble.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.closeBubble = function() {
if (this.panelId_) {
this.getSharedBubble_().removePanel(this.panelId_);
this.handlePanelClosed_();
}
};
/**
* Called after the bubble is shown. The default implementation does nothing.
* Override it to provide your own one.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.onShow = goog.nullFunction;
/**
* Called when the bubble is closed or hidden. The default implementation does
* nothing.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.cleanOnBubbleClose =
goog.nullFunction;
/**
* Handles when the bubble panel is closed. Invoked when the entire bubble is
* hidden and also directly when the panel is closed manually.
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.handlePanelClosed_ =
function() {
this.targetElement_ = null;
this.panelId_ = null;
this.eventRegister.removeAll();
this.cleanOnBubbleClose();
};
/**
* In case the keyboard navigation is enabled, this will set focus on the first
* tabbable element in the bubble when TAB is clicked.
* @override
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.handleKeyDown = function(e) {
if (this.keyboardNavigationEnabled_ &&
this.isVisible() &&
e.keyCode == goog.events.KeyCodes.TAB && !e.shiftKey) {
var bubbleEl = this.getSharedBubble_().getContentElement();
var tabbable = goog.dom.getElementByClass(
goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_, bubbleEl);
if (tabbable) {
tabbable.focus();
e.preventDefault();
return true;
}
}
return false;
};
/**
* Handles a key event on the bubble. This ensures that the focus loops through
* the tabbable elements found in the bubble and then the focus is got by the
* field element.
* @param {goog.events.BrowserEvent} e The event.
* @private
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.onBubbleKey_ = function(e) {
if (this.isVisible() &&
e.keyCode == goog.events.KeyCodes.TAB) {
var bubbleEl = this.getSharedBubble_().getContentElement();
var tabbables = goog.dom.getElementsByClass(
goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_, bubbleEl);
var tabbable = e.shiftKey ? tabbables[0] : goog.array.peek(tabbables);
var tabbingOutOfBubble = tabbable == e.target;
if (tabbingOutOfBubble) {
this.getFieldObject().focus();
e.preventDefault();
}
}
};
/**
* @return {boolean} Whether the bubble is visible.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.isVisible = function() {
return !!this.panelId_;
};
/**
* Reposition the property bubble.
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.reposition = function() {
var bubble = this.getSharedBubble_();
if (bubble) {
bubble.reposition();
}
};
/**
* Helper method that creates option links (such as edit, test, remove)
* @param {string} id String id for the span id.
* @return {Element} The option link element.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.createLinkOption = function(
id) {
// Dash plus link are together in a span so we can hide/show them easily
return this.dom_.createDom(goog.dom.TagName.SPAN,
{
id: id,
className:
goog.editor.plugins.AbstractBubblePlugin.OPTION_LINK_CLASSNAME_
},
this.dom_.createTextNode(
goog.editor.plugins.AbstractBubblePlugin.DASH_NBSP_STRING));
};
/**
* Helper method that creates a link with text set to linkText and optionally
* wires up a listener for the CLICK event or the link. The link is navigable by
* tabs if {@code enableKeyboardNavigation(true)} was called.
* @param {string} linkId The id of the link.
* @param {string} linkText Text of the link.
* @param {Function=} opt_onClick Optional function to call when the link is
* clicked.
* @param {Element=} opt_container If specified, location to insert link. If no
* container is specified, the old link is removed and replaced.
* @return {Element} The link element.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.createLink = function(
linkId, linkText, opt_onClick, opt_container) {
var link = this.createLinkHelper(linkId, linkText, false, opt_container);
if (opt_onClick) {
this.registerActionHandler(link, opt_onClick);
}
return link;
};
/**
* Helper method to create a link to insert into the bubble. The link is
* navigable by tabs if {@code enableKeyboardNavigation(true)} was called.
* @param {string} linkId The id of the link.
* @param {string} linkText Text of the link.
* @param {boolean} isAnchor Set to true to create an actual anchor tag
* instead of a span. Actual links are right clickable (e.g. to open in
* a new window) and also update window status on hover.
* @param {Element=} opt_container If specified, location to insert link. If no
* container is specified, the old link is removed and replaced.
* @return {Element} The link element.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.createLinkHelper = function(
linkId, linkText, isAnchor, opt_container) {
var link = this.dom_.createDom(
isAnchor ? goog.dom.TagName.A : goog.dom.TagName.SPAN,
{className: goog.editor.plugins.AbstractBubblePlugin.LINK_CLASSNAME_},
linkText);
if (this.keyboardNavigationEnabled_) {
this.setTabbable(link);
}
link.setAttribute('role', 'link');
this.setupLink(link, linkId, opt_container);
goog.editor.style.makeUnselectable(link, this.eventRegister);
return link;
};
/**
* Makes the given element tabbable.
*
* <p>Elements created by createLink[Helper] are tabbable even without
* calling this method. Call it for other elements if needed.
*
* <p>If tabindex is not already set in the element, this function sets it to 0.
* You'll usually want to also call {@code enableKeyboardNavigation(true)}.
*
* @param {!Element} element
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.setTabbable =
function(element) {
if (!element.hasAttribute('tabindex')) {
element.setAttribute('tabindex', 0);
}
goog.dom.classlist.add(element,
goog.editor.plugins.AbstractBubblePlugin.TABBABLE_CLASSNAME_);
};
/**
* Inserts a link in the given container if it is specified or removes
* the old link with this id and replaces it with the new link
* @param {Element} link Html element to insert.
* @param {string} linkId Id of the link.
* @param {Element=} opt_container If specified, location to insert link.
* @protected
*/
goog.editor.plugins.AbstractBubblePlugin.prototype.setupLink = function(
link, linkId, opt_container) {
if (opt_container) {
opt_container.appendChild(link);
} else {
var oldLink = this.dom_.getElement(linkId);
if (oldLink) {
goog.dom.replaceNode(link, oldLink);
}
}
link.id = linkId;
};
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
@author tildahl@google.com (Michael Tildahl)
@author robbyw@google.com (Robby Walker)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.AbstractBubblePlugin
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.AbstractBubblePluginTest');
</script>
<link rel="stylesheet" type="text/css" href="../../css/bubble.css" />
</head>
<body>
<div id="field">
</div>
</body>
</html>
@@ -0,0 +1,436 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.AbstractBubblePluginTest');
goog.setTestOnly('goog.editor.plugins.AbstractBubblePluginTest');
goog.require('goog.dom');
goog.require('goog.editor.plugins.AbstractBubblePlugin');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.EventType');
goog.require('goog.events.KeyCodes');
goog.require('goog.functions');
goog.require('goog.style');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.events.Event');
goog.require('goog.testing.jsunit');
goog.require('goog.ui.editor.Bubble');
goog.require('goog.userAgent');
var testHelper;
var fieldDiv;
var COMMAND = 'base';
var fieldMock;
var bubblePlugin;
var link;
var link2;
function setUpPage() {
fieldDiv = goog.dom.getElement('field');
var viewportSize = goog.dom.getViewportSize();
// Some tests depends on enough size of viewport.
if (viewportSize.width < 600 || viewportSize.height < 440) {
window.moveTo(0, 0);
window.resizeTo(640, 480);
}
}
function setUp() {
testHelper = new goog.testing.editor.TestHelper(fieldDiv);
testHelper.setUpEditableElement();
fieldMock = new goog.testing.editor.FieldMock();
bubblePlugin = new goog.editor.plugins.AbstractBubblePlugin(COMMAND);
bubblePlugin.fieldObject = fieldMock;
fieldDiv.innerHTML = '<a href="http://www.google.com">Google</a>' +
'<a href="http://www.google.com">Google2</a>';
link = fieldDiv.firstChild;
link2 = fieldDiv.lastChild;
window.scrollTo(0, 0);
goog.style.setStyle(document.body, 'direction', 'ltr');
goog.style.setStyle(document.getElementById('field'), 'position', 'static');
}
function tearDown() {
bubblePlugin.closeBubble();
testHelper.tearDownEditableElement();
}
/**
* This is a helper function for setting up the targetElement with a
* given direction.
*
* @param {string} dir The direction of the targetElement, 'ltr' or 'rtl'.
*/
function prepareTargetWithGivenDirection(dir) {
goog.style.setStyle(document.body, 'direction', dir);
fieldDiv.style.direction = dir;
fieldDiv.innerHTML = '<a href="http://www.google.com">Google</a>';
link = fieldDiv.firstChild;
fieldMock.$replay();
bubblePlugin.createBubbleContents = function(bubbleContainer) {
bubbleContainer.innerHTML = '<div style="border:1px solid blue;">B</div>';
goog.style.setStyle(bubbleContainer, 'border', '1px solid white');
};
bubblePlugin.registerFieldObject(fieldMock);
bubblePlugin.enable(fieldMock);
bubblePlugin.createBubble(link);
}
/**
* Similar in intent to mock reset, but implemented by recreating the mock
* variable. $reset() can't work because it will reset general any-time
* expectations done in the fieldMock constructor.
*/
function resetFieldMock() {
fieldMock = new goog.testing.editor.FieldMock();
bubblePlugin.fieldObject = fieldMock;
}
function helpTestCreateBubble(opt_fn) {
fieldMock.$replay();
var numCalled = 0;
bubblePlugin.createBubbleContents = function(bubbleContainer) {
numCalled++;
assertNotNull('bubbleContainer should not be null', bubbleContainer);
};
if (opt_fn) {
opt_fn();
}
bubblePlugin.createBubble(link);
assertEquals('createBubbleContents should be called', 1, numCalled);
fieldMock.$verify();
}
function testCreateBubble(opt_fn) {
helpTestCreateBubble(opt_fn);
assertTrue(bubblePlugin.getSharedBubble_() instanceof goog.ui.editor.Bubble);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
}
function testOpeningBubbleCallsOnShow() {
var numCalled = 0;
testCreateBubble(function() {
bubblePlugin.onShow = function() {
numCalled++;
};
});
assertEquals('onShow should be called', 1, numCalled);
fieldMock.$verify();
}
function testCloseBubble() {
testCreateBubble();
bubblePlugin.closeBubble();
assertFalse('Bubble should not be visible', bubblePlugin.isVisible());
fieldMock.$verify();
}
function testZindexBehavior() {
// Don't use the default return values.
fieldMock.$reset();
fieldMock.getAppWindow().$anyTimes().$returns(window);
fieldMock.getEditableDomHelper().$anyTimes()
.$returns(goog.dom.getDomHelper(document));
fieldMock.getBaseZindex().$returns(2);
bubblePlugin.createBubbleContents = goog.nullFunction;
fieldMock.$replay();
bubblePlugin.createBubble(link);
assertEquals('2',
'' + bubblePlugin.getSharedBubble_().bubbleContainer_.style.zIndex);
fieldMock.$verify();
}
function testNoTwoBubblesOpenAtSameTime() {
fieldMock.$replay();
var origClose = goog.bind(bubblePlugin.closeBubble, bubblePlugin);
var numTimesCloseCalled = 0;
bubblePlugin.closeBubble = function() {
numTimesCloseCalled++;
origClose();
};
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
bubblePlugin.createBubbleContents = goog.nullFunction;
bubblePlugin.handleSelectionChangeInternal(link);
assertEquals(0, numTimesCloseCalled);
assertEquals(link, bubblePlugin.targetElement_);
fieldMock.$verify();
bubblePlugin.handleSelectionChangeInternal(link2);
assertEquals(1, numTimesCloseCalled);
assertEquals(link2, bubblePlugin.targetElement_);
fieldMock.$verify();
}
function testHandleSelectionChangeWithEvent() {
fieldMock.$replay();
var fakeEvent =
new goog.events.BrowserEvent({type: 'mouseup', target: link});
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
bubblePlugin.createBubbleContents = goog.nullFunction;
bubblePlugin.handleSelectionChange(fakeEvent);
assertTrue('Bubble should have been opened', bubblePlugin.isVisible());
assertEquals('Bubble target should be provided event\'s target',
link, bubblePlugin.targetElement_);
}
function testHandleSelectionChangeWithTarget() {
fieldMock.$replay();
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
bubblePlugin.createBubbleContents = goog.nullFunction;
bubblePlugin.handleSelectionChange(undefined, link2);
assertTrue('Bubble should have been opened', bubblePlugin.isVisible());
assertEquals('Bubble target should be provided target',
link2, bubblePlugin.targetElement_);
}
/**
* Regression test for @bug 2945341
*/
function testSelectOneTextCharacterNoError() {
fieldMock.$replay();
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
bubblePlugin.createBubbleContents = goog.nullFunction;
// Select first char of first link's text node.
testHelper.select(link.firstChild, 0, link.firstChild, 1);
// This should execute without js errors.
bubblePlugin.handleSelectionChange();
assertTrue('Bubble should have been opened', bubblePlugin.isVisible());
fieldMock.$verify();
}
function testTabKeyEvents() {
fieldMock.$replay();
bubblePlugin.enableKeyboardNavigation(true);
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
var nonTabbable1, tabbable1, tabbable2, nonTabbable2;
bubblePlugin.createBubbleContents = function(container) {
nonTabbable1 = goog.dom.createDom('div');
tabbable1 = goog.dom.createDom('div');
tabbable2 = goog.dom.createDom('div');
nonTabbable2 = goog.dom.createDom('div');
goog.dom.append(
container, nonTabbable1, tabbable1, tabbable2, nonTabbable2);
bubblePlugin.setTabbable(tabbable1);
bubblePlugin.setTabbable(tabbable2);
};
bubblePlugin.handleSelectionChangeInternal(link);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
var tabHandledByBubble = simulateTabKeyOnBubble();
assertTrue('The action should be handled by the plugin', tabHandledByBubble);
assertFocused(tabbable1);
// Tab on the first tabbable. The test framework doesn't easily let us verify
// the desired behavior - namely, that the second tabbable gets focused - but
// we verify that the field doesn't get the focus.
goog.testing.events.fireKeySequence(tabbable1, goog.events.KeyCodes.TAB);
fieldMock.$verify();
// Tabbing on the last tabbable should trigger focus() of the target field.
resetFieldMock();
fieldMock.focus();
fieldMock.$replay();
goog.testing.events.fireKeySequence(tabbable2, goog.events.KeyCodes.TAB);
fieldMock.$verify();
}
function testTabKeyEventsWithShiftKey() {
fieldMock.$replay();
bubblePlugin.enableKeyboardNavigation(true);
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
var nonTabbable, tabbable1, tabbable2;
bubblePlugin.createBubbleContents = function(container) {
nonTabbable = goog.dom.createDom('div');
tabbable1 = goog.dom.createDom('div');
// The test acts only on one tabbable, but we give another one to make sure
// that the tabbable we act on is not also the last.
tabbable2 = goog.dom.createDom('div');
goog.dom.append(container, nonTabbable, tabbable1, tabbable2);
bubblePlugin.setTabbable(tabbable1);
bubblePlugin.setTabbable(tabbable2);
};
bubblePlugin.handleSelectionChangeInternal(link);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
var tabHandledByBubble = simulateTabKeyOnBubble();
assertTrue('The action should be handled by the plugin', tabHandledByBubble);
assertFocused(tabbable1);
fieldMock.$verify();
// Shift-tabbing on the first tabbable should trigger focus() of the target
// field.
resetFieldMock();
fieldMock.focus();
fieldMock.$replay();
goog.testing.events.fireKeySequence(
tabbable1, goog.events.KeyCodes.TAB, {shiftKey: true});
fieldMock.$verify();
}
function testLinksAreTabbable() {
fieldMock.$replay();
bubblePlugin.enableKeyboardNavigation(true);
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
var nonTabbable1, link1, link2, nonTabbable2;
bubblePlugin.createBubbleContents = function(container) {
nonTabbable1 = goog.dom.createDom('div');
goog.dom.appendChild(container, nonTabbable1);
bubbleLink1 = this.createLink('linkInBubble1', 'Foo', false, container);
bubbleLink2 = this.createLink('linkInBubble2', 'Bar', false, container);
nonTabbable2 = goog.dom.createDom('div');
goog.dom.appendChild(container, nonTabbable2);
};
bubblePlugin.handleSelectionChangeInternal(link);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
var tabHandledByBubble = simulateTabKeyOnBubble();
assertTrue('The action should be handled by the plugin', tabHandledByBubble);
assertFocused(bubbleLink1);
fieldMock.$verify();
// Tabbing on the last link should trigger focus() of the target field.
resetFieldMock();
fieldMock.focus();
fieldMock.$replay();
goog.testing.events.fireKeySequence(bubbleLink2, goog.events.KeyCodes.TAB);
fieldMock.$verify();
}
function testTabKeyNoEffectKeyboardNavDisabled() {
fieldMock.$replay();
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
var bubbleLink;
bubblePlugin.createBubbleContents = function(container) {
bubbleLink = this.createLink('linkInBubble', 'Foo', false, container);
};
bubblePlugin.handleSelectionChangeInternal(link);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
var tabHandledByBubble = simulateTabKeyOnBubble();
assertFalse('The action should not be handled by the plugin',
tabHandledByBubble);
assertNotFocused(bubbleLink);
// Verify that tabbing the link doesn't cause focus of the field.
goog.testing.events.fireKeySequence(bubbleLink, goog.events.KeyCodes.TAB);
fieldMock.$verify();
}
function testOtherKeyEventNoEffectKeyboardNavEnabled() {
fieldMock.$replay();
bubblePlugin.enableKeyboardNavigation(true);
bubblePlugin.getBubbleTargetFromSelection = goog.functions.identity;
var bubbleLink;
bubblePlugin.createBubbleContents = function(container) {
bubbleLink = this.createLink('linkInBubble', 'Foo', false, container);
};
bubblePlugin.handleSelectionChangeInternal(link);
assertTrue('Bubble should be visible', bubblePlugin.isVisible());
// Test pressing CTRL + B: this should not have any effect.
var keyHandledByBubble =
simulateKeyDownOnBubble(goog.events.KeyCodes.B, true);
assertFalse('The action should not be handled by the plugin',
keyHandledByBubble);
assertNotFocused(bubbleLink);
fieldMock.$verify();
}
function testSetTabbableSetsTabIndex() {
var element1 = goog.dom.createDom('div');
var element2 = goog.dom.createDom('div');
element1.setAttribute('tabIndex', '1');
bubblePlugin.setTabbable(element1);
bubblePlugin.setTabbable(element2);
assertEquals('1', element1.getAttribute('tabIndex'));
assertEquals('0', element2.getAttribute('tabIndex'));
}
function testDisable() {
testCreateBubble();
fieldMock.setUneditable(true);
bubblePlugin.disable(fieldMock);
bubblePlugin.closeBubble();
}
/**
* Sends a tab key event to the bubble.
* @return {boolean} whether the bubble hanlded the event.
*/
function simulateTabKeyOnBubble() {
return simulateKeyDownOnBubble(goog.events.KeyCodes.TAB, false);
}
/**
* Sends a key event to the bubble.
* @param {number} keyCode
* @param {boolean} isCtrl
* @return {boolean} whether the bubble hanlded the event.
*/
function simulateKeyDownOnBubble(keyCode, isCtrl) {
// In some browsers (e.g. FireFox) the editable field is marked with
// designMode on. In the test setting (and not in production setting), the
// bubble element shares the same window and hence the designMode. In this
// mode, activeElement remains the <body> and isn't changed along with the
// focus as a result of tab key.
bubblePlugin.getSharedBubble_().getContentElement().
ownerDocument.designMode = 'off';
var event =
new goog.testing.events.Event(goog.events.EventType.KEYDOWN, null);
event.keyCode = keyCode;
event.ctrlKey = isCtrl;
return bubblePlugin.handleKeyDown(event);
}
function assertFocused(element) {
// The activeElement assertion below doesn't work in IE7. At this time IE7 is
// no longer supported by any client product, so we don't care.
if (goog.userAgent.IE && !goog.userAgent.isVersionOrHigher(8)) {
return;
}
assertEquals('unexpected focus', element, document.activeElement);
}
function assertNotFocused(element) {
assertNotEquals('unexpected focus', element, document.activeElement);
}
@@ -0,0 +1,333 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview An abstract superclass for TrogEdit dialog plugins. Each
* Trogedit dialog has its own plugin.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.plugins.AbstractDialogPlugin');
goog.provide('goog.editor.plugins.AbstractDialogPlugin.EventType');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.editor.Field');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.range');
goog.require('goog.events');
goog.require('goog.ui.editor.AbstractDialog');
// *** Public interface ***************************************************** //
/**
* An abstract superclass for a Trogedit plugin that creates exactly one
* dialog. By default dialogs are not reused -- each time execCommand is called,
* a new instance of the dialog object is created (and the old one disposed of).
* To enable reusing of the dialog object, subclasses should call
* setReuseDialog() after calling the superclass constructor.
* @param {string} command The command that this plugin handles.
* @constructor
* @extends {goog.editor.Plugin}
*/
goog.editor.plugins.AbstractDialogPlugin = function(command) {
goog.editor.Plugin.call(this);
this.command_ = command;
};
goog.inherits(goog.editor.plugins.AbstractDialogPlugin, goog.editor.Plugin);
/** @override */
goog.editor.plugins.AbstractDialogPlugin.prototype.isSupportedCommand =
function(command) {
return command == this.command_;
};
/**
* Handles execCommand. Dialog plugins don't make any changes when they open a
* dialog, just when the dialog closes (because only modal dialogs are
* supported). Hence this method does not dispatch the change events that the
* superclass method does.
* @param {string} command The command to execute.
* @param {...*} var_args Any additional parameters needed to
* execute the command.
* @return {*} The result of the execCommand, if any.
* @override
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.execCommand = function(
command, var_args) {
return this.execCommandInternal.apply(this, arguments);
};
// *** Events *************************************************************** //
/**
* Event type constants for events the dialog plugins fire.
* @enum {string}
*/
goog.editor.plugins.AbstractDialogPlugin.EventType = {
// This event is fired when a dialog has been opened.
OPENED: 'dialogOpened',
// This event is fired when a dialog has been closed.
CLOSED: 'dialogClosed'
};
// *** Protected interface ************************************************** //
/**
* Creates a new instance of this plugin's dialog. Must be overridden by
* subclasses.
* @param {!goog.dom.DomHelper} dialogDomHelper The dom helper to be used to
* create the dialog.
* @param {*=} opt_arg The dialog specific argument. Concrete subclasses should
* declare a specific type.
* @return {goog.ui.editor.AbstractDialog} The newly created dialog.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.createDialog =
goog.abstractMethod;
/**
* Returns the current dialog that was created and opened by this plugin.
* @return {goog.ui.editor.AbstractDialog} The current dialog that was created
* and opened by this plugin.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.getDialog = function() {
return this.dialog_;
};
/**
* Sets whether this plugin should reuse the same instance of the dialog each
* time execCommand is called or create a new one. This is intended for use by
* subclasses only, hence protected.
* @param {boolean} reuse Whether to reuse the dialog.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.setReuseDialog =
function(reuse) {
this.reuseDialog_ = reuse;
};
/**
* Handles execCommand by opening the dialog. Dispatches
* {@link goog.editor.plugins.AbstractDialogPlugin.EventType.OPENED} after the
* dialog is shown.
* @param {string} command The command to execute.
* @param {*=} opt_arg The dialog specific argument. Should be the same as
* {@link createDialog}.
* @return {*} Always returns true, indicating the dialog was shown.
* @protected
* @override
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.execCommandInternal =
function(command, opt_arg) {
// If this plugin should not reuse dialog instances, first dispose of the
// previous dialog.
if (!this.reuseDialog_) {
this.disposeDialog_();
}
// If there is no dialog yet (or we aren't reusing the previous one), create
// one.
if (!this.dialog_) {
this.dialog_ = this.createDialog(
// TODO(user): Add Field.getAppDomHelper. (Note dom helper will
// need to be updated if setAppWindow is called by clients.)
goog.dom.getDomHelper(this.getFieldObject().getAppWindow()),
opt_arg);
}
// Since we're opening a dialog, we need to clear the selection because the
// focus will be going to the dialog, and if we leave an selection in the
// editor while another selection is active in the dialog as the user is
// typing, some browsers will screw up the original selection. But first we
// save it so we can restore it when the dialog closes.
// getRange may return null if there is no selection in the field.
var tempRange = this.getFieldObject().getRange();
// saveUsingDom() did not work as well as saveUsingNormalizedCarets(),
// not sure why.
this.savedRange_ = tempRange && goog.editor.range.saveUsingNormalizedCarets(
tempRange);
goog.dom.Range.clearSelection(
this.getFieldObject().getEditableDomHelper().getWindow());
// Listen for the dialog closing so we can clean up.
goog.events.listenOnce(this.dialog_,
goog.ui.editor.AbstractDialog.EventType.AFTER_HIDE,
this.handleAfterHide,
false,
this);
this.getFieldObject().setModalMode(true);
this.dialog_.show();
this.dispatchEvent(goog.editor.plugins.AbstractDialogPlugin.EventType.OPENED);
// Since the selection has left the document, dispatch a selection
// change event.
this.getFieldObject().dispatchSelectionChangeEvent();
return true;
};
/**
* Cleans up after the dialog has closed, including restoring the selection to
* what it was before the dialog was opened. If a subclass modifies the editable
* field's content such that the original selection is no longer valid (usually
* the case when the user clicks OK, and sometimes also on Cancel), it is that
* subclass' responsibility to place the selection in the desired place during
* the OK or Cancel (or other) handler. In that case, this method will leave the
* selection in place.
* @param {goog.events.Event} e The AFTER_HIDE event object.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.handleAfterHide = function(
e) {
this.getFieldObject().setModalMode(false);
this.restoreOriginalSelection();
if (!this.reuseDialog_) {
this.disposeDialog_();
}
this.dispatchEvent(goog.editor.plugins.AbstractDialogPlugin.EventType.CLOSED);
// Since the selection has returned to the document, dispatch a selection
// change event.
this.getFieldObject().dispatchSelectionChangeEvent();
// When the dialog closes due to pressing enter or escape, that happens on the
// keydown event. But the browser will still fire a keyup event after that,
// which is caught by the editable field and causes it to try to fire a
// selection change event. To avoid that, we "debounce" the selection change
// event, meaning the editable field will not fire that event if the keyup
// that caused it immediately after this dialog was hidden ("immediately"
// means a small number of milliseconds defined by the editable field).
this.getFieldObject().debounceEvent(
goog.editor.Field.EventType.SELECTIONCHANGE);
};
/**
* Restores the selection in the editable field to what it was before the dialog
* was opened. This is not guaranteed to work if the contents of the field
* have changed.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.restoreOriginalSelection =
function() {
this.getFieldObject().restoreSavedRange(this.savedRange_);
this.savedRange_ = null;
};
/**
* Cleans up the structure used to save the original selection before the dialog
* was opened. Should be used by subclasses that don't restore the original
* selection via restoreOriginalSelection.
* @protected
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.disposeOriginalSelection =
function() {
if (this.savedRange_) {
this.savedRange_.dispose();
this.savedRange_ = null;
}
};
/** @override */
goog.editor.plugins.AbstractDialogPlugin.prototype.disposeInternal =
function() {
this.disposeDialog_();
goog.editor.plugins.AbstractDialogPlugin.base(this, 'disposeInternal');
};
// *** Private implementation *********************************************** //
/**
* The command that this plugin handles.
* @type {string}
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.command_;
/**
* The current dialog that was created and opened by this plugin.
* @type {goog.ui.editor.AbstractDialog}
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.dialog_;
/**
* Whether this plugin should reuse the same instance of the dialog each time
* execCommand is called or create a new one.
* @type {boolean}
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.reuseDialog_ = false;
/**
* Mutex to prevent recursive calls to disposeDialog_.
* @type {boolean}
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.isDisposingDialog_ = false;
/**
* SavedRange representing the selection before the dialog was opened.
* @type {goog.dom.SavedRange}
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.savedRange_;
/**
* Disposes of the dialog if needed. It is this abstract class' responsibility
* to dispose of the dialog. The "if needed" refers to the fact this method
* might be called twice (nested calls, not sequential) in the dispose flow, so
* if the dialog was already disposed once it should not be disposed again.
* @private
*/
goog.editor.plugins.AbstractDialogPlugin.prototype.disposeDialog_ = function() {
// Wrap disposing the dialog in a mutex. Otherwise disposing it would cause it
// to get hidden (if it is still open) and fire AFTER_HIDE, which in
// turn would cause the dialog to be disposed again (closure only flags an
// object as disposed after the dispose call chain completes, so it doesn't
// prevent recursive dispose calls).
if (this.dialog_ && !this.isDisposingDialog_) {
this.isDisposingDialog_ = true;
this.dialog_.dispose();
this.dialog_ = null;
this.isDisposingDialog_ = false;
}
};
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<!--
@author marcosalmeida@google.com (Marcos Almeida)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.AbstractDialogPlugin
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.AbstractDialogPluginTest');
</script>
</head>
<body>
</body>
</html>
@@ -0,0 +1,403 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.AbstractDialogPluginTest');
goog.setTestOnly('goog.editor.plugins.AbstractDialogPluginTest');
goog.require('goog.dom.SavedRange');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.AbstractDialogPlugin');
goog.require('goog.events.Event');
goog.require('goog.events.EventHandler');
goog.require('goog.functions');
goog.require('goog.testing.MockClock');
goog.require('goog.testing.MockControl');
goog.require('goog.testing.PropertyReplacer');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.testing.mockmatchers.ArgumentMatcher');
goog.require('goog.ui.editor.AbstractDialog');
goog.require('goog.userAgent');
var plugin;
var mockCtrl;
var mockField;
var mockSavedRange;
var mockOpenedHandler;
var mockClosedHandler;
var COMMAND = 'myCommand';
var stubs = new goog.testing.PropertyReplacer();
var mockClock;
var fieldObj;
var fieldElem;
var mockHandler;
function setUp() {
mockCtrl = new goog.testing.MockControl();
mockOpenedHandler = mockCtrl.createLooseMock(goog.events.EventHandler);
mockClosedHandler = mockCtrl.createLooseMock(goog.events.EventHandler);
mockField = new goog.testing.editor.FieldMock(undefined, undefined, {});
mockCtrl.addMock(mockField);
mockField.focus();
plugin = createDialogPlugin();
}
function setUpMockRange() {
mockSavedRange = mockCtrl.createLooseMock(goog.dom.SavedRange);
mockSavedRange.restore();
stubs.setPath('goog.editor.range.saveUsingNormalizedCarets',
goog.functions.constant(mockSavedRange));
}
function tearDown() {
stubs.reset();
tearDownRealEditableField();
if (mockClock) {
// Crucial to letting time operations work normally in the rest of tests.
mockClock.dispose();
}
if (plugin) {
mockField.$setIgnoreUnexpectedCalls(true);
plugin.dispose();
}
}
/**
* Creates a concrete instance of goog.ui.editor.AbstractDialog by adding
* a plain implementation of createDialogControl().
* @param {goog.dom.DomHelper} dialogDomHelper The dom helper to be used to
* create the dialog.
* @return {goog.ui.editor.AbstractDialog} The created dialog.
*/
function createDialog(domHelper) {
var dialog = new goog.ui.editor.AbstractDialog(domHelper);
dialog.createDialogControl = function() {
return new goog.ui.editor.AbstractDialog.Builder(dialog).build();
};
return dialog;
}
/**
* Creates a concrete instance of the abstract class
* goog.editor.plugins.AbstractDialogPlugin
* and registers it with the mock editable field being used.
* @return {goog.editor.plugins.AbstractDialogPlugin} The created plugin.
*/
function createDialogPlugin() {
var plugin = new goog.editor.plugins.AbstractDialogPlugin(COMMAND);
plugin.createDialog = createDialog;
plugin.returnControlToEditableField = plugin.restoreOriginalSelection;
plugin.registerFieldObject(mockField);
plugin.addEventListener(
goog.editor.plugins.AbstractDialogPlugin.EventType.OPENED,
mockOpenedHandler);
plugin.addEventListener(
goog.editor.plugins.AbstractDialogPlugin.EventType.CLOSED,
mockClosedHandler);
return plugin;
}
/**
* Sets up the mock event handler to expect an OPENED event.
*/
function expectOpened(opt_times) {
mockOpenedHandler.handleEvent(new goog.testing.mockmatchers.ArgumentMatcher(
function(arg) {
return arg.type ==
goog.editor.plugins.AbstractDialogPlugin.EventType.OPENED;
}));
mockField.dispatchSelectionChangeEvent();
if (opt_times) {
mockOpenedHandler.$times(opt_times);
mockField.$times(opt_times);
}
}
/**
* Sets up the mock event handler to expect a CLOSED event.
*/
function expectClosed(opt_times) {
mockClosedHandler.handleEvent(new goog.testing.mockmatchers.ArgumentMatcher(
function(arg) {
return arg.type ==
goog.editor.plugins.AbstractDialogPlugin.EventType.CLOSED;
}));
mockField.dispatchSelectionChangeEvent();
if (opt_times) {
mockClosedHandler.$times(opt_times);
mockField.$times(opt_times);
}
}
/**
* Tests the simple flow of calling execCommand (which opens the
* dialog) and immediately disposing of the plugin (which closes the dialog).
* @param {boolean=} opt_reuse Whether to set the plugin to reuse its dialog.
*/
function testExecAndDispose(opt_reuse) {
setUpMockRange();
expectOpened();
expectClosed();
mockField.debounceEvent(goog.editor.Field.EventType.SELECTIONCHANGE);
mockCtrl.$replayAll();
if (opt_reuse) {
plugin.setReuseDialog(true);
}
assertFalse('Dialog should not be open yet',
!!plugin.getDialog() && plugin.getDialog().isOpen());
plugin.execCommand(COMMAND);
assertTrue('Dialog should be open now',
!!plugin.getDialog() && plugin.getDialog().isOpen());
var tempDialog = plugin.getDialog();
plugin.dispose();
assertFalse('Dialog should not still be open after disposal',
tempDialog.isOpen());
mockCtrl.$verifyAll();
}
/**
* Tests execCommand and dispose while reusing the dialog.
*/
function testExecAndDisposeReuse() {
testExecAndDispose(true);
}
/**
* Tests the flow of calling execCommand (which opens the dialog) and
* then hiding it (simulating that a user did somthing to cause the dialog to
* close).
* @param {boolean} reuse Whether to set the plugin to reuse its dialog.
*/
function testExecAndHide(opt_reuse) {
setUpMockRange();
expectOpened();
expectClosed();
mockField.debounceEvent(goog.editor.Field.EventType.SELECTIONCHANGE);
mockCtrl.$replayAll();
if (opt_reuse) {
plugin.setReuseDialog(true);
}
assertFalse('Dialog should not be open yet',
!!plugin.getDialog() && plugin.getDialog().isOpen());
plugin.execCommand(COMMAND);
assertTrue('Dialog should be open now',
!!plugin.getDialog() && plugin.getDialog().isOpen());
var tempDialog = plugin.getDialog();
plugin.getDialog().hide();
assertFalse('Dialog should not still be open after hiding',
tempDialog.isOpen());
if (opt_reuse) {
assertFalse('Dialog should not be disposed after hiding (will be reused)',
tempDialog.isDisposed());
} else {
assertTrue('Dialog should be disposed after hiding',
tempDialog.isDisposed());
}
plugin.dispose();
mockCtrl.$verifyAll();
}
/**
* Tests execCommand and hide while reusing the dialog.
*/
function testExecAndHideReuse() {
testExecAndHide(true);
}
/**
* Tests the flow of calling execCommand (which opens a dialog) and
* then calling it again before the first dialog is closed. This is not
* something anyone should be doing since dialogs are (usually?) modal so the
* user can't do another execCommand before closing the first dialog. But
* since the API makes it possible, I thought it would be good to guard
* against and unit test.
* @param {boolean} reuse Whether to set the plugin to reuse its dialog.
*/
function testExecTwice(opt_reuse) {
setUpMockRange();
if (opt_reuse) {
expectOpened(2); // The second exec should cause a second OPENED event.
// But the dialog was not closed between exec calls, so only one CLOSED is
// expected.
expectClosed();
plugin.setReuseDialog(true);
mockField.debounceEvent(goog.editor.Field.EventType.SELECTIONCHANGE);
} else {
expectOpened(2); // The second exec should cause a second OPENED event.
// The first dialog will be disposed so there should be two CLOSED events.
expectClosed(2);
mockSavedRange.restore(); // Expected 2x, once already recorded in setup.
mockField.focus(); // Expected 2x, once already recorded in setup.
mockField.debounceEvent(goog.editor.Field.EventType.SELECTIONCHANGE);
mockField.$times(2);
}
mockCtrl.$replayAll();
assertFalse('Dialog should not be open yet',
!!plugin.getDialog() && plugin.getDialog().isOpen());
plugin.execCommand(COMMAND);
assertTrue('Dialog should be open now',
!!plugin.getDialog() && plugin.getDialog().isOpen());
var tempDialog = plugin.getDialog();
plugin.execCommand(COMMAND);
if (opt_reuse) {
assertTrue('Reused dialog should still be open after second exec',
tempDialog.isOpen());
assertFalse('Reused dialog should not be disposed after second exec',
tempDialog.isDisposed());
} else {
assertFalse('First dialog should not still be open after opening second',
tempDialog.isOpen());
assertTrue('First dialog should be disposed after opening second',
tempDialog.isDisposed());
}
plugin.dispose();
mockCtrl.$verifyAll();
}
/**
* Tests execCommand twice while reusing the dialog.
*/
function testExecTwiceReuse() {
// Test is failing with an out-of-memory error in IE7.
if (goog.userAgent.IE && !goog.userAgent.isVersionOrHigher('8')) {
return;
}
testExecTwice(true);
}
/**
* Tests that the selection is cleared when the dialog opens and is
* correctly restored after it closes.
*/
function testRestoreSelection() {
setUpRealEditableField();
fieldObj.setHtml(false, '12345');
var elem = fieldObj.getElement();
var helper = new goog.testing.editor.TestHelper(elem);
helper.select('12345', 1, '12345', 4); // Selects '234'.
assertEquals('Incorrect text selected before dialog is opened',
'234',
fieldObj.getRange().getText());
plugin.execCommand(COMMAND);
if (!goog.userAgent.IE && !goog.userAgent.OPERA) {
// IE returns some bogus range when field doesn't have selection.
// Opera can't remove the selection from a whitebox field.
assertNull('There should be no selection while dialog is open',
fieldObj.getRange());
}
plugin.getDialog().hide();
assertEquals('Incorrect text selected after dialog is closed',
'234',
fieldObj.getRange().getText());
}
/**
* Setup a real editable field (instead of a mock) and register the plugin to
* it.
*/
function setUpRealEditableField() {
fieldElem = document.createElement('div');
fieldElem.id = 'myField';
document.body.appendChild(fieldElem);
fieldObj = new goog.editor.Field('myField', document);
fieldObj.makeEditable();
// Register the plugin to that field.
plugin.getTrogClassId = goog.functions.constant('myClassId');
fieldObj.registerPlugin(plugin);
}
/**
* Tear down the real editable field.
*/
function tearDownRealEditableField() {
if (fieldObj) {
fieldObj.makeUneditable();
fieldObj.dispose();
fieldObj = null;
}
if (fieldElem && fieldElem.parentNode == document.body) {
document.body.removeChild(fieldElem);
}
}
/**
* Tests that after the dialog is hidden via a keystroke, the editable field
* doesn't fire an extra SELECTIONCHANGE event due to the keyup from that
* keystroke.
* There is also a robot test in dialog_robot.html to test debouncing the
* SELECTIONCHANGE event when the dialog closes.
*/
function testDebounceSelectionChange() {
mockClock = new goog.testing.MockClock(true);
// Initial time is 0 which evaluates to false in debouncing implementation.
mockClock.tick(1);
setUpRealEditableField();
// Set up a mock event handler to make sure selection change isn't fired
// more than once on close and a second time on close.
var count = 0;
fieldObj.addEventListener(goog.editor.Field.EventType.SELECTIONCHANGE,
function(e) {
count++;
});
assertEquals(0, count);
plugin.execCommand(COMMAND);
assertEquals(1, count);
plugin.getDialog().hide();
assertEquals(2, count);
// Fake the keyup event firing on the field after the dialog closes.
var e = new goog.events.Event('keyup', plugin.fieldObject.getElement());
e.keyCode = 13;
goog.testing.events.fireBrowserEvent(e);
// Tick the mock clock so that selection change tries to fire.
mockClock.tick(goog.editor.Field.SELECTION_CHANGE_FREQUENCY_ + 1);
// Ensure the handler did not fire again.
assertEquals(2, count);
}
@@ -0,0 +1,78 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Abstract Editor plugin class to handle tab keys. Has one
* abstract method which should be overriden to handle a tab key press.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.AbstractTabHandler');
goog.require('goog.editor.Plugin');
goog.require('goog.events.KeyCodes');
goog.require('goog.userAgent');
/**
* Plugin to handle tab keys. Specific tab behavior defined by subclasses.
*
* @constructor
* @extends {goog.editor.Plugin}
*/
goog.editor.plugins.AbstractTabHandler = function() {
goog.editor.Plugin.call(this);
};
goog.inherits(goog.editor.plugins.AbstractTabHandler, goog.editor.Plugin);
/** @override */
goog.editor.plugins.AbstractTabHandler.prototype.getTrogClassId =
goog.abstractMethod;
/** @override */
goog.editor.plugins.AbstractTabHandler.prototype.handleKeyboardShortcut =
function(e, key, isModifierPressed) {
// If a dialog doesn't have selectable field, Moz grabs the event and
// performs actions in editor window. This solves that problem and allows
// the event to be passed on to proper handlers.
if (goog.userAgent.GECKO && this.getFieldObject().inModalMode()) {
return false;
}
// Don't handle Ctrl+Tab since the user is most likely trying to switch
// browser tabs. See bug 1305086.
// FF3 on Mac sends Ctrl-Tab to trogedit and we end up inserting a tab, but
// then it also switches the tabs. See bug 1511681. Note that we don't use
// isModifierPressed here since isModifierPressed is true only if metaKey
// is true on Mac.
if (e.keyCode == goog.events.KeyCodes.TAB && !e.metaKey && !e.ctrlKey) {
return this.handleTabKey(e);
}
return false;
};
/**
* Handle a tab key press.
* @param {goog.events.Event} e The key event.
* @return {boolean} Whether this event was handled by this plugin.
* @protected
*/
goog.editor.plugins.AbstractTabHandler.prototype.handleTabKey =
goog.abstractMethod;
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<!--
@author ajp@google.com (Andy Perelson)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.AbstractTabHandler
</title>
<script src="../../base.js">
</script>
<script src="../../deps.js">
</script>
<script>
goog.require('goog.editor.plugins.AbstractTabHandlerTest');
</script>
</head>
<body>
</body>
</html>
@@ -0,0 +1,81 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.AbstractTabHandlerTest');
goog.setTestOnly('goog.editor.plugins.AbstractTabHandlerTest');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.AbstractTabHandler');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.KeyCodes');
goog.require('goog.testing.StrictMock');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var tabHandler;
var editableField;
var handleTabKeyCalled = false;
function setUp() {
editableField = new goog.testing.editor.FieldMock();
editableField.inModalMode = goog.editor.Field.prototype.inModalMode;
editableField.setModalMode = goog.editor.Field.prototype.setModalMode;
tabHandler = new goog.editor.plugins.AbstractTabHandler();
tabHandler.registerFieldObject(editableField);
tabHandler.handleTabKey = function(e) {
handleTabKeyCalled = true;
return true;
};
}
function tearDown() {
tabHandler.dispose();
}
function testHandleKey() {
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.ctrlKey = false;
event.metaKey = false;
assertTrue('Event must be handled when no modifier keys are pressed.',
tabHandler.handleKeyboardShortcut(event, '', false));
assertTrue(handleTabKeyCalled);
handleTabKeyCalled = false;
editableField.setModalMode(true);
if (goog.userAgent.GECKO) {
assertFalse('Event must not be handled when in modal mode',
tabHandler.handleKeyboardShortcut(event, '', false));
assertFalse(handleTabKeyCalled);
} else {
assertTrue('Event must be handled when in modal mode',
tabHandler.handleKeyboardShortcut(event, '', false));
assertTrue(handleTabKeyCalled);
handleTabKeyCalled = false;
}
event.ctrlKey = true;
assertFalse('Plugin must never handle tab key press when ctrlKey is pressed.',
tabHandler.handleKeyboardShortcut(event, '', false));
assertFalse(handleTabKeyCalled);
event.ctrlKey = false;
event.metaKey = true;
assertFalse('Plugin must never handle tab key press when metaKey is pressed.',
tabHandler.handleKeyboardShortcut(event, '', false));
assertFalse(handleTabKeyCalled);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,94 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.BasicTextFormatter Tests
</title>
<script type="text/javascript" src="../../base.js">
</script>
<script type="text/javascript">
goog.require('goog.editor.plugins.BasicTextFormatterTest');
</script>
</head>
<body style="font-size:16px">
<!-- This has to be created here so it is loaded when we need it -->
<iframe id="iframe"></iframe>
<div id="html">
<ul id="outerUL" type="1">
<li>foo</li>
<ul id="innerUL">
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>
<li>bar</li>
<li>baz</li>
</ul>
...
<ul id="outerUL2">
<li>foo</li>
<ul>
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>
<ul>
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>
<li>bar</li>
<li>baz</li>
</ul>
<ol id="ol">
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ol>
<div>
<ol id="switchListType">
<li>switch</li>
<li>list</li>
<li>type</li>
</ol>
</div>
<p>before <span id="toQuote">Foo. Bar, baz.</span> after</p>
<p>before <div id="toQuote2">Foo.<p/>Bar,<p/>baz.</div> after</p>
<div id="divQuote"><div>lorem</div><div>ipsum</div><div>dolor</div></div>
<p id="geckolist"><font face="courier">test</font></p>
<table>
<tr> <th> head1</th>
<th id= "outerTh"><span id="emptyTh">head2</span></th> </tr>
<tr> <td> one </td> <td>two </td> </tr>
<tr>
<td> three</td>
<td id="outerTd"> <span id="emptyTd"><strong>four</strong></span></td>
</tr>
<tr id="outerTr"> <td><span id="emptyTr"> five </span></td></tr>
</table>
<div id="linkwrapper">Foo<span id="link">Pre<a href="http://www.google.com">Outside Span<span style="font-size:15pt">Inside Span</span></a></span></div>
</div>
<div id="root"></div>
<div id="real-field"></div>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,451 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview goog.editor plugin to handle splitting block quotes.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.Blockquote');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.TagName');
goog.require('goog.dom.classlist');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Command');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.node');
goog.require('goog.functions');
goog.require('goog.log');
/**
* Plugin to handle splitting block quotes. This plugin does nothing on its
* own and should be used in conjunction with EnterHandler or one of its
* subclasses.
* @param {boolean} requiresClassNameToSplit Whether to split only blockquotes
* that have the given classname.
* @param {string=} opt_className The classname to apply to generated
* blockquotes. Defaults to 'tr_bq'.
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.Blockquote = function(requiresClassNameToSplit,
opt_className) {
goog.editor.Plugin.call(this);
/**
* Whether we only split blockquotes that have {@link classname}, or whether
* all blockquote tags should be split on enter.
* @type {boolean}
* @private
*/
this.requiresClassNameToSplit_ = requiresClassNameToSplit;
/**
* Classname to put on blockquotes that are generated via the toolbar for
* blockquote, so that we can internally distinguish these from blockquotes
* that are used for indentation. This classname can be over-ridden by
* clients for styling or other purposes.
* @type {string}
* @private
*/
this.className_ = opt_className || goog.getCssName('tr_bq');
};
goog.inherits(goog.editor.plugins.Blockquote, goog.editor.Plugin);
/**
* Command implemented by this plugin.
* @type {string}
*/
goog.editor.plugins.Blockquote.SPLIT_COMMAND = '+splitBlockquote';
/**
* Class ID used to identify this plugin.
* @type {string}
*/
goog.editor.plugins.Blockquote.CLASS_ID = 'Blockquote';
/**
* Logging object.
* @type {goog.log.Logger}
* @protected
* @override
*/
goog.editor.plugins.Blockquote.prototype.logger =
goog.log.getLogger('goog.editor.plugins.Blockquote');
/** @override */
goog.editor.plugins.Blockquote.prototype.getTrogClassId = function() {
return goog.editor.plugins.Blockquote.CLASS_ID;
};
/**
* Since our exec command is always called from elsewhere, we make it silent.
* @override
*/
goog.editor.plugins.Blockquote.prototype.isSilentCommand = goog.functions.TRUE;
/**
* Checks if a node is a blockquote which can be split. A splittable blockquote
* meets the following criteria:
* <ol>
* <li>Node is a blockquote element</li>
* <li>Node has the blockquote classname if the classname is required to
* split</li>
* </ol>
*
* @param {Node} node DOM node in question.
* @return {boolean} Whether the node is a splittable blockquote.
*/
goog.editor.plugins.Blockquote.prototype.isSplittableBlockquote =
function(node) {
if (node.tagName != goog.dom.TagName.BLOCKQUOTE) {
return false;
}
if (!this.requiresClassNameToSplit_) {
return true;
}
return goog.dom.classlist.contains(/** @type {!Element} */ (node),
this.className_);
};
/**
* Checks if a node is a blockquote element which has been setup.
* @param {Node} node DOM node to check.
* @return {boolean} Whether the node is a blockquote with the required class
* name applied.
*/
goog.editor.plugins.Blockquote.prototype.isSetupBlockquote =
function(node) {
return node.tagName == goog.dom.TagName.BLOCKQUOTE &&
goog.dom.classlist.contains(/** @type {!Element} */ (node),
this.className_);
};
/**
* Checks if a node is a blockquote element which has not been setup yet.
* @param {Node} node DOM node to check.
* @return {boolean} Whether the node is a blockquote without the required
* class name applied.
*/
goog.editor.plugins.Blockquote.prototype.isUnsetupBlockquote =
function(node) {
return node.tagName == goog.dom.TagName.BLOCKQUOTE &&
!this.isSetupBlockquote(node);
};
/**
* Gets the class name required for setup blockquotes.
* @return {string} The blockquote class name.
*/
goog.editor.plugins.Blockquote.prototype.getBlockquoteClassName = function() {
return this.className_;
};
/**
* Helper routine which walks up the tree to find the topmost
* ancestor with only a single child. The ancestor node or the original
* node (if no ancestor was found) is then removed from the DOM.
*
* @param {Node} node The node whose ancestors have to be searched.
* @param {Node} root The root node to stop the search at.
* @private
*/
goog.editor.plugins.Blockquote.findAndRemoveSingleChildAncestor_ = function(
node, root) {
var predicateFunc = function(parentNode) {
return parentNode != root && parentNode.childNodes.length == 1;
};
var ancestor = goog.editor.node.findHighestMatchingAncestor(node,
predicateFunc);
if (!ancestor) {
ancestor = node;
}
goog.dom.removeNode(ancestor);
};
/**
* Remove every nodes from the DOM tree that are all white space nodes.
* @param {Array<Node>} nodes Nodes to be checked.
* @private
*/
goog.editor.plugins.Blockquote.removeAllWhiteSpaceNodes_ = function(nodes) {
for (var i = 0; i < nodes.length; ++i) {
if (goog.editor.node.isEmpty(nodes[i], true)) {
goog.dom.removeNode(nodes[i]);
}
}
};
/** @override */
goog.editor.plugins.Blockquote.prototype.isSupportedCommand = function(
command) {
return command == goog.editor.plugins.Blockquote.SPLIT_COMMAND;
};
/**
* Splits a quoted region if any. To be called on a key press event. When this
* function returns true, the event that caused it to be called should be
* canceled.
* @param {string} command The command to execute.
* @param {...*} var_args Single additional argument representing the current
* cursor position. If BrowserFeature.HAS_W3C_RANGES it is an object with a
* {@code node} key and an {@code offset} key. In other cases (legacy IE)
* it is a single node.
* @return {boolean|undefined} Boolean true when the quoted region has been
* split, false or undefined otherwise.
* @override
*/
goog.editor.plugins.Blockquote.prototype.execCommandInternal = function(
command, var_args) {
var pos = arguments[1];
if (command == goog.editor.plugins.Blockquote.SPLIT_COMMAND && pos &&
(this.className_ || !this.requiresClassNameToSplit_)) {
return goog.editor.BrowserFeature.HAS_W3C_RANGES ?
this.splitQuotedBlockW3C_(pos) :
this.splitQuotedBlockIE_(/** @type {Node} */ (pos));
}
};
/**
* Version of splitQuotedBlock_ that uses W3C ranges.
* @param {Object} anchorPos The current cursor position.
* @return {boolean} Whether the blockquote was split.
* @private
*/
goog.editor.plugins.Blockquote.prototype.splitQuotedBlockW3C_ =
function(anchorPos) {
var cursorNode = anchorPos.node;
var quoteNode = goog.editor.node.findTopMostEditableAncestor(
cursorNode.parentNode, goog.bind(this.isSplittableBlockquote, this));
var secondHalf, textNodeToRemove;
var insertTextNode = false;
// There are two special conditions that we account for here.
//
// 1. Whenever the cursor is after (one<BR>|) or just before a BR element
// (one|<BR>) and the user presses enter, the second quoted block starts
// with a BR which appears to the user as an extra newline. This stems
// from the fact that we create two text nodes as our split boundaries
// and the BR becomes a part of the second half because of this.
//
// 2. When the cursor is at the end of a text node with no siblings and
// the user presses enter, the second blockquote might contain a
// empty subtree that ends in a 0 length text node. We account for that
// as a post-splitting operation.
if (quoteNode) {
// selection is in a line that has text in it
if (cursorNode.nodeType == goog.dom.NodeType.TEXT) {
if (anchorPos.offset == cursorNode.length) {
var siblingNode = cursorNode.nextSibling;
// This accounts for the condition where the cursor appears at the
// end of a text node and right before the BR eg: one|<BR>. We ensure
// that we split on the BR in that case.
if (siblingNode && siblingNode.tagName == goog.dom.TagName.BR) {
cursorNode = siblingNode;
// This might be null but splitDomTreeAt accounts for the null case.
secondHalf = siblingNode.nextSibling;
} else {
textNodeToRemove = cursorNode.splitText(anchorPos.offset);
secondHalf = textNodeToRemove;
}
} else {
secondHalf = cursorNode.splitText(anchorPos.offset);
}
} else if (cursorNode.tagName == goog.dom.TagName.BR) {
// This might be null but splitDomTreeAt accounts for the null case.
secondHalf = cursorNode.nextSibling;
} else {
// The selection is in a line that is empty, with more than 1 level
// of quote.
insertTextNode = true;
}
} else {
// Check if current node is a quote node.
// This will happen if user clicks in an empty line in the quote,
// when there is 1 level of quote.
if (this.isSetupBlockquote(cursorNode)) {
quoteNode = cursorNode;
insertTextNode = true;
}
}
if (insertTextNode) {
// Create two empty text nodes to split between.
cursorNode = this.insertEmptyTextNodeBeforeRange_();
secondHalf = this.insertEmptyTextNodeBeforeRange_();
}
if (!quoteNode) {
return false;
}
secondHalf = goog.editor.node.splitDomTreeAt(cursorNode, secondHalf,
quoteNode);
goog.dom.insertSiblingAfter(secondHalf, quoteNode);
// Set the insertion point.
var dh = this.getFieldDomHelper();
var tagToInsert =
this.getFieldObject().queryCommandValue(
goog.editor.Command.DEFAULT_TAG) ||
goog.dom.TagName.DIV;
var container = dh.createElement(/** @type {string} */ (tagToInsert));
container.innerHTML = '&nbsp;'; // Prevent the div from collapsing.
quoteNode.parentNode.insertBefore(container, secondHalf);
dh.getWindow().getSelection().collapse(container, 0);
// We need to account for the condition where the second blockquote
// might contain an empty DOM tree. This arises from trying to split
// at the end of an empty text node. We resolve this by walking up the tree
// till we either reach the blockquote or till we hit a node with more
// than one child. The resulting node is then removed from the DOM.
if (textNodeToRemove) {
goog.editor.plugins.Blockquote.findAndRemoveSingleChildAncestor_(
textNodeToRemove, secondHalf);
}
goog.editor.plugins.Blockquote.removeAllWhiteSpaceNodes_(
[quoteNode, secondHalf]);
return true;
};
/**
* Inserts an empty text node before the field's range.
* @return {!Node} The empty text node.
* @private
*/
goog.editor.plugins.Blockquote.prototype.insertEmptyTextNodeBeforeRange_ =
function() {
var range = this.getFieldObject().getRange();
var node = this.getFieldDomHelper().createTextNode('');
range.insertNode(node, true);
return node;
};
/**
* IE version of splitQuotedBlock_.
* @param {Node} splitNode The current cursor position.
* @return {boolean} Whether the blockquote was split.
* @private
*/
goog.editor.plugins.Blockquote.prototype.splitQuotedBlockIE_ =
function(splitNode) {
var dh = this.getFieldDomHelper();
var quoteNode = goog.editor.node.findTopMostEditableAncestor(
splitNode.parentNode, goog.bind(this.isSplittableBlockquote, this));
if (!quoteNode) {
return false;
}
var clone = splitNode.cloneNode(false);
// Whenever the cursor is just before a BR element (one|<BR>) and the user
// presses enter, the second quoted block starts with a BR which appears
// to the user as an extra newline. This stems from the fact that the
// dummy span that we create (splitNode) occurs before the BR and we split
// on that.
if (splitNode.nextSibling &&
splitNode.nextSibling.tagName == goog.dom.TagName.BR) {
splitNode = splitNode.nextSibling;
}
var secondHalf = goog.editor.node.splitDomTreeAt(splitNode, clone, quoteNode);
goog.dom.insertSiblingAfter(secondHalf, quoteNode);
// Set insertion point.
var tagToInsert =
this.getFieldObject().queryCommandValue(
goog.editor.Command.DEFAULT_TAG) ||
goog.dom.TagName.DIV;
var div = dh.createElement(/** @type {string} */ (tagToInsert));
quoteNode.parentNode.insertBefore(div, secondHalf);
// The div needs non-whitespace contents in order for the insertion point
// to get correctly inserted.
div.innerHTML = '&nbsp;';
// Moving the range 1 char isn't enough when you have markup.
// This moves the range to the end of the nbsp.
var range = dh.getDocument().selection.createRange();
range.moveToElementText(splitNode);
range.move('character', 2);
range.select();
// Remove the no-longer-necessary nbsp.
div.innerHTML = '';
// Clear the original selection.
range.pasteHTML('');
// We need to remove clone from the DOM but just removing clone alone will
// not suffice. Let's assume we have the following DOM structure and the
// cursor is placed after the first numbered list item "one".
//
// <blockquote class="gmail-quote">
// <div><div>a</div><ol><li>one|</li></ol></div>
// <div>b</div>
// </blockquote>
//
// After pressing enter, we have the following structure.
//
// <blockquote class="gmail-quote">
// <div><div>a</div><ol><li>one|</li></ol></div>
// </blockquote>
// <div>&nbsp;</div>
// <blockquote class="gmail-quote">
// <div><ol><li><span id=""></span></li></ol></div>
// <div>b</div>
// </blockquote>
//
// The clone is contained in a subtree which should be removed. This stems
// from the fact that we invoke splitDomTreeAt with the dummy span
// as the starting splitting point and this results in the empty subtree
// <div><ol><li><span id=""></span></li></ol></div>.
//
// We resolve this by walking up the tree till we either reach the
// blockquote or till we hit a node with more than one child. The resulting
// node is then removed from the DOM.
goog.editor.plugins.Blockquote.findAndRemoveSingleChildAncestor_(
clone, secondHalf);
goog.editor.plugins.Blockquote.removeAllWhiteSpaceNodes_(
[quoteNode, secondHalf]);
return true;
};
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
Tests for goog.editor.plugins.Blockquote
@author robbyw@google.com (Robby Walker)
-->
<html>
<!--
Copyright 2009 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.Blockquote
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.BlockquoteTest');
</script>
</head>
<body>
<div id="root">
</div>
</body>
</html>
@@ -0,0 +1,209 @@
// Copyright 2009 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.BlockquoteTest');
goog.setTestOnly('goog.editor.plugins.BlockquoteTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.plugins.Blockquote');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
var SPLIT = '<span id="split-point"></span>';
var root, helper, field, plugin;
function setUp() {
root = goog.dom.getElement('root');
helper = new goog.testing.editor.TestHelper(root);
field = new goog.testing.editor.FieldMock();
helper.setUpEditableElement();
}
function tearDown() {
field.$verify();
helper.tearDownEditableElement();
}
function createPlugin(requireClassname, opt_paragraphMode) {
field.queryCommandValue('+defaultTag').$anyTimes().$returns(
opt_paragraphMode ? goog.dom.TagName.P : undefined);
plugin = new goog.editor.plugins.Blockquote(requireClassname);
plugin.registerFieldObject(field);
plugin.enable(field);
}
function execCommand() {
field.$replay();
// With splitPoint we try to mimic the behavior of EnterHandler's
// deleteCursorSelection_.
var splitPoint = goog.dom.getElement('split-point');
var position = goog.editor.BrowserFeature.HAS_W3C_RANGES ?
{node: splitPoint.nextSibling, offset: 0} : splitPoint;
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
goog.dom.removeNode(splitPoint);
goog.dom.Range.createCaret(position.node, 0).select();
} else {
goog.dom.Range.createCaret(position, 0).select();
}
var result = plugin.execCommand(goog.editor.plugins.Blockquote.SPLIT_COMMAND,
position);
if (!goog.editor.BrowserFeature.HAS_W3C_RANGES) {
goog.dom.removeNode(splitPoint);
}
return result;
}
function testSplitBlockquoteDoesNothingWhenNotInBlockquote() {
root.innerHTML = '<div>Test' + SPLIT + 'ing</div>';
createPlugin(false);
assertFalse(execCommand());
helper.assertHtmlMatches('<div>Testing</div>');
}
function testSplitBlockquoteDoesNothingWhenNotInBlockquoteWithClass() {
root.innerHTML = '<blockquote>Test' + SPLIT + 'ing</blockquote>';
createPlugin(true);
assertFalse(execCommand());
helper.assertHtmlMatches('<blockquote>Testing</blockquote>');
}
function testSplitBlockquoteInBlockquoteWithoutClass() {
root.innerHTML = '<blockquote>Test' + SPLIT + 'ing</blockquote>';
createPlugin(false);
assertTrue(execCommand());
helper.assertHtmlMatches(
'<blockquote>Test</blockquote>' +
'<div>' +
(goog.editor.BrowserFeature.HAS_W3C_RANGES ? '&nbsp;' : '') +
'</div>' +
'<blockquote>ing</blockquote>');
}
function testSplitBlockquoteInBlockquoteWithoutClassInParagraphMode() {
root.innerHTML = '<blockquote>Test' + SPLIT + 'ing</blockquote>';
createPlugin(false, true);
assertTrue(execCommand());
helper.assertHtmlMatches(
'<blockquote>Test</blockquote>' +
'<p>' +
(goog.editor.BrowserFeature.HAS_W3C_RANGES ? '&nbsp;' : '') +
'</p>' +
'<blockquote>ing</blockquote>');
}
function testSplitBlockquoteInBlockquoteWithClass() {
root.innerHTML =
'<blockquote class="tr_bq">Test' + SPLIT + 'ing</blockquote>';
createPlugin(true);
assertTrue(execCommand());
helper.assertHtmlMatches(
'<blockquote class="tr_bq">Test</blockquote>' +
'<div>' +
(goog.editor.BrowserFeature.HAS_W3C_RANGES ? '&nbsp;' : '') +
'</div>' +
'<blockquote class="tr_bq">ing</blockquote>');
}
function testSplitBlockquoteInBlockquoteWithClassInParagraphMode() {
root.innerHTML =
'<blockquote class="tr_bq">Test' + SPLIT + 'ing</blockquote>';
createPlugin(true, true);
assertTrue(execCommand());
helper.assertHtmlMatches(
'<blockquote class="tr_bq">Test</blockquote>' +
'<p>' +
(goog.editor.BrowserFeature.HAS_W3C_RANGES ? '&nbsp;' : '') +
'</p>' +
'<blockquote class="tr_bq">ing</blockquote>');
}
function testIsSplittableBlockquoteWhenRequiresClassNameToSplit() {
createPlugin(true);
var blockquoteWithClassName = goog.dom.createDom('blockquote', 'tr_bq');
assertTrue('blockquote should be detected as splittable',
plugin.isSplittableBlockquote(blockquoteWithClassName));
var blockquoteWithoutClassName = goog.dom.createDom('blockquote', 'foo');
assertFalse('blockquote should not be detected as splittable',
plugin.isSplittableBlockquote(blockquoteWithoutClassName));
var nonBlockquote = goog.dom.createDom('span', 'tr_bq');
assertFalse('element should not be detected as splittable',
plugin.isSplittableBlockquote(nonBlockquote));
}
function testIsSplittableBlockquoteWhenNotRequiresClassNameToSplit() {
createPlugin(false);
var blockquoteWithClassName = goog.dom.createDom('blockquote', 'tr_bq');
assertTrue('blockquote should be detected as splittable',
plugin.isSplittableBlockquote(blockquoteWithClassName));
var blockquoteWithoutClassName = goog.dom.createDom('blockquote', 'foo');
assertTrue('blockquote should be detected as splittable',
plugin.isSplittableBlockquote(blockquoteWithoutClassName));
var nonBlockquote = goog.dom.createDom('span', 'tr_bq');
assertFalse('element should not be detected as splittable',
plugin.isSplittableBlockquote(nonBlockquote));
}
function testIsSetupBlockquote() {
createPlugin(false);
var blockquoteWithClassName = goog.dom.createDom('blockquote', 'tr_bq');
assertTrue('blockquote should be detected as setup',
plugin.isSetupBlockquote(blockquoteWithClassName));
var blockquoteWithoutClassName = goog.dom.createDom('blockquote', 'foo');
assertFalse('blockquote should not be detected as setup',
plugin.isSetupBlockquote(blockquoteWithoutClassName));
var nonBlockquote = goog.dom.createDom('span', 'tr_bq');
assertFalse('element should not be detected as setup',
plugin.isSetupBlockquote(nonBlockquote));
}
function testIsUnsetupBlockquote() {
createPlugin(false);
var blockquoteWithClassName = goog.dom.createDom('blockquote', 'tr_bq');
assertFalse('blockquote should not be detected as unsetup',
plugin.isUnsetupBlockquote(blockquoteWithClassName));
var blockquoteWithoutClassName = goog.dom.createDom('blockquote', 'foo');
assertTrue('blockquote should be detected as unsetup',
plugin.isUnsetupBlockquote(blockquoteWithoutClassName));
var nonBlockquote = goog.dom.createDom('span', 'tr_bq');
assertFalse('element should not be detected as unsetup',
plugin.isUnsetupBlockquote(nonBlockquote));
}
@@ -0,0 +1,89 @@
// Copyright 2009 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// All Rights Reserved
/**
* @fileoverview Plugin for generating emoticons.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.plugins.Emoticons');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.range');
goog.require('goog.functions');
goog.require('goog.ui.emoji.Emoji');
goog.require('goog.userAgent');
/**
* Plugin for generating emoticons.
*
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.Emoticons = function() {
goog.editor.plugins.Emoticons.base(this, 'constructor');
};
goog.inherits(goog.editor.plugins.Emoticons, goog.editor.Plugin);
/** The emoticon command. */
goog.editor.plugins.Emoticons.COMMAND = '+emoticon';
/** @override */
goog.editor.plugins.Emoticons.prototype.getTrogClassId =
goog.functions.constant(goog.editor.plugins.Emoticons.COMMAND);
/** @override */
goog.editor.plugins.Emoticons.prototype.isSupportedCommand = function(
command) {
return command == goog.editor.plugins.Emoticons.COMMAND;
};
/**
* Inserts an emoticon into the editor at the cursor location. Places the
* cursor to the right of the inserted emoticon.
* @param {string} command Command to execute.
* @param {*=} opt_arg Emoji to insert.
* @return {!Object|undefined} The result of the command.
* @override
*/
goog.editor.plugins.Emoticons.prototype.execCommandInternal = function(
command, opt_arg) {
var emoji = /** @type {goog.ui.emoji.Emoji} */ (opt_arg);
var dom = this.getFieldDomHelper();
var img = dom.createDom(goog.dom.TagName.IMG, {
'src': emoji.getUrl(),
'style': 'margin:0 0.2ex;vertical-align:middle'
});
img.setAttribute(goog.ui.emoji.Emoji.ATTRIBUTE, emoji.getId());
this.getFieldObject().getRange().replaceContentsWithNode(img);
// IE8 does the right thing with the cursor, and has a js error when we try
// to place the cursor manually.
// IE9 loses the cursor when the window is focused, so focus first.
if (!goog.userAgent.IE || goog.userAgent.isDocumentModeOrHigher(9)) {
this.getFieldObject().focus();
goog.editor.range.placeCursorNextTo(img, false);
}
};
@@ -0,0 +1,23 @@
<html>
<!--
Copyright 2010 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.EmoticonsTest');
</script>
</head>
<div id="parent">
<div id="testField">
I am text.
</div>
</div>
<body>
</body>
</html>
@@ -0,0 +1,84 @@
// Copyright 2010 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.EmoticonsTest');
goog.setTestOnly('goog.editor.plugins.EmoticonsTest');
goog.require('goog.Uri');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.Emoticons');
goog.require('goog.testing.jsunit');
goog.require('goog.ui.emoji.Emoji');
goog.require('goog.userAgent');
var HTML;
function setUp() {
HTML = goog.dom.getElement('parent').innerHTML;
}
function tearDown() {
goog.dom.getElement('parent').innerHTML = HTML;
}
function testEmojiWithEmoticonsPlugin() {
runEmojiTestWithPlugin(new goog.editor.plugins.Emoticons());
}
function runEmojiTestWithPlugin(plugin) {
var field = new goog.editor.Field('testField');
field.registerPlugin(plugin);
field.makeEditable();
field.focusAndPlaceCursorAtStart();
var src = 'testdata/emoji/4F4.gif';
var id = '4F4';
var emoji = new goog.ui.emoji.Emoji(src, id);
field.execCommand(goog.editor.plugins.Emoticons.COMMAND, emoji);
// The url may be relative or absolute.
var imgs = field.getEditableDomHelper().
getElementsByTagNameAndClass(goog.dom.TagName.IMG);
assertEquals(1, imgs.length);
var img = imgs[0];
assertUriEquals(src, img.getAttribute('src'));
assertEquals(id, img.getAttribute(goog.ui.emoji.Emoji.ATTRIBUTE));
var range = field.getRange();
assertNotNull('must have a selection', range);
assertTrue('range must be a cursor', range.isCollapsed());
if (goog.userAgent.WEBKIT) {
assertEquals('range starts after image',
2, range.getStartOffset());
} else if (goog.userAgent.GECKO) {
assertEquals('range starts after image',
2, goog.array.indexOf(range.getContainerElement().childNodes,
range.getStartNode()));
}
// Firefox 3.6 is still tested, and would fail here - treitel December 2012
if (!(goog.userAgent.GECKO && !goog.userAgent.isVersionOrHigher(2))) {
assertEquals('range must be around image',
img.parentElement, range.getContainerElement());
}
}
function assertUriEquals(expected, actual) {
var winUri = new goog.Uri(window.location);
assertEquals(winUri.resolve(new goog.Uri(expected)).toString(),
winUri.resolve(new goog.Uri(actual)).toString());
}
@@ -0,0 +1,768 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Plugin to handle enter keys.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.EnterHandler');
goog.require('goog.dom');
goog.require('goog.dom.NodeOffset');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.node');
goog.require('goog.editor.plugins.Blockquote');
goog.require('goog.editor.range');
goog.require('goog.editor.style');
goog.require('goog.events.KeyCodes');
goog.require('goog.functions');
goog.require('goog.object');
goog.require('goog.string');
goog.require('goog.userAgent');
/**
* Plugin to handle enter keys. This does all the crazy to normalize (as much as
* is reasonable) what happens when you hit enter. This also handles the
* special casing of hitting enter in a blockquote.
*
* In IE, Webkit, and Opera, the resulting HTML uses one DIV tag per line. In
* Firefox, the resulting HTML uses BR tags at the end of each line.
*
* @constructor
* @extends {goog.editor.Plugin}
*/
goog.editor.plugins.EnterHandler = function() {
goog.editor.Plugin.call(this);
};
goog.inherits(goog.editor.plugins.EnterHandler, goog.editor.Plugin);
/**
* The type of block level tag to add on enter, for browsers that support
* specifying the default block-level tag. Can be overriden by subclasses; must
* be either DIV or P.
* @type {goog.dom.TagName}
* @protected
*/
goog.editor.plugins.EnterHandler.prototype.tag = goog.dom.TagName.DIV;
/** @override */
goog.editor.plugins.EnterHandler.prototype.getTrogClassId = function() {
return 'EnterHandler';
};
/** @override */
goog.editor.plugins.EnterHandler.prototype.enable = function(fieldObject) {
goog.editor.plugins.EnterHandler.base(this, 'enable', fieldObject);
if (goog.editor.BrowserFeature.SUPPORTS_OPERA_DEFAULTBLOCK_COMMAND &&
(this.tag == goog.dom.TagName.P || this.tag == goog.dom.TagName.DIV)) {
var doc = this.getFieldDomHelper().getDocument();
doc.execCommand('opera-defaultBlock', false, this.tag);
}
};
/**
* If the contents are empty, return the 'default' html for the field.
* The 'default' contents depend on the enter handling mode, so it
* makes the most sense in this plugin.
* @param {string} html The html to prepare.
* @return {string} The original HTML, or default contents if that
* html is empty.
* @override
*/
goog.editor.plugins.EnterHandler.prototype.prepareContentsHtml = function(
html) {
if (!html || goog.string.isBreakingWhitespace(html)) {
return goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES ?
this.getNonCollapsingBlankHtml() : '';
}
return html;
};
/**
* Gets HTML with no contents that won't collapse, for browsers that
* collapse the empty string.
* @return {string} Blank html.
* @protected
*/
goog.editor.plugins.EnterHandler.prototype.getNonCollapsingBlankHtml =
goog.functions.constant('<br>');
/**
* Internal backspace handler.
* @param {goog.events.Event} e The keypress event.
* @param {goog.dom.AbstractRange} range The closure range object.
* @protected
*/
goog.editor.plugins.EnterHandler.prototype.handleBackspaceInternal = function(e,
range) {
var field = this.getFieldObject().getElement();
var container = range && range.getStartNode();
if (field.firstChild == container && goog.editor.node.isEmpty(container)) {
e.preventDefault();
// TODO(user): I think we probably don't need to stopPropagation here
e.stopPropagation();
}
};
/**
* Fix paragraphs to be the correct type of node.
* @param {goog.events.Event} e The <enter> key event.
* @param {boolean} split Whether we already split up a blockquote by
* manually inserting elements.
* @protected
*/
goog.editor.plugins.EnterHandler.prototype.processParagraphTagsInternal =
function(e, split) {
// Force IE to turn the node we are leaving into a DIV. If we do turn
// it into a DIV, the node IE creates in response to ENTER will also be
// a DIV. If we don't, it will be a P. We handle that case
// in handleKeyUpIE_
if (goog.userAgent.IE || goog.userAgent.OPERA) {
this.ensureBlockIeOpera(goog.dom.TagName.DIV);
} else if (!split && goog.userAgent.WEBKIT) {
// WebKit duplicates a blockquote when the user hits enter. Let's cancel
// this and insert a BR instead, to make it more consistent with the other
// browsers.
var range = this.getFieldObject().getRange();
if (!range || !goog.editor.plugins.EnterHandler.isDirectlyInBlockquote(
range.getContainerElement())) {
return;
}
var dh = this.getFieldDomHelper();
var br = dh.createElement(goog.dom.TagName.BR);
range.insertNode(br, true);
// If the BR is at the end of a block element, Safari still thinks there is
// only one line instead of two, so we need to add another BR in that case.
if (goog.editor.node.isBlockTag(br.parentNode) &&
!goog.editor.node.skipEmptyTextNodes(br.nextSibling)) {
goog.dom.insertSiblingBefore(
dh.createElement(goog.dom.TagName.BR), br);
}
goog.editor.range.placeCursorNextTo(br, false);
e.preventDefault();
}
};
/**
* Determines whether the lowest containing block node is a blockquote.
* @param {Node} n The node.
* @return {boolean} Whether the deepest block ancestor of n is a blockquote.
*/
goog.editor.plugins.EnterHandler.isDirectlyInBlockquote = function(n) {
for (var current = n; current; current = current.parentNode) {
if (goog.editor.node.isBlockTag(current)) {
return current.tagName == goog.dom.TagName.BLOCKQUOTE;
}
}
return false;
};
/**
* Internal delete key handler.
* @param {goog.events.Event} e The keypress event.
* @protected
*/
goog.editor.plugins.EnterHandler.prototype.handleDeleteGecko = function(e) {
this.deleteBrGecko(e);
};
/**
* Deletes the element at the cursor if it is a BR node, and if it does, calls
* e.preventDefault to stop the browser from deleting. Only necessary in Gecko
* as a workaround for mozilla bug 205350 where deleting a BR that is followed
* by a block element doesn't work (the BR gets immediately replaced). We also
* need to account for an ill-formed cursor which occurs from us trying to
* stop the browser from deleting.
*
* @param {goog.events.Event} e The DELETE keypress event.
* @protected
*/
goog.editor.plugins.EnterHandler.prototype.deleteBrGecko = function(e) {
var range = this.getFieldObject().getRange();
if (range.isCollapsed()) {
var container = range.getEndNode();
if (container.nodeType == goog.dom.NodeType.ELEMENT) {
var nextNode = container.childNodes[range.getEndOffset()];
if (nextNode && nextNode.tagName == goog.dom.TagName.BR) {
// We want to retrieve the first non-whitespace previous sibling
// as we could have added an empty text node below and want to
// properly handle deleting a sequence of BR's.
var previousSibling = goog.editor.node.getPreviousSibling(nextNode);
var nextSibling = nextNode.nextSibling;
container.removeChild(nextNode);
e.preventDefault();
// When we delete a BR followed by a block level element, the cursor
// has a line-height which spans the height of the block level element.
// e.g. If we delete a BR followed by a UL, the resulting HTML will
// appear to the end user like:-
//
// | * one
// | * two
// | * three
//
// There are a couple of cases that we have to account for in order to
// properly conform to what the user expects when DELETE is pressed.
//
// 1. If the BR has a previous sibling and the previous sibling is
// not a block level element or a BR, we place the cursor at the
// end of that.
// 2. If the BR doesn't have a previous sibling or the previous sibling
// is a block level element or a BR, we place the cursor at the
// beginning of the leftmost leaf of its next sibling.
if (nextSibling && goog.editor.node.isBlockTag(nextSibling)) {
if (previousSibling &&
!(previousSibling.tagName == goog.dom.TagName.BR ||
goog.editor.node.isBlockTag(previousSibling))) {
goog.dom.Range.createCaret(
previousSibling,
goog.editor.node.getLength(previousSibling)).select();
} else {
var leftMostLeaf = goog.editor.node.getLeftMostLeaf(nextSibling);
goog.dom.Range.createCaret(leftMostLeaf, 0).select();
}
}
}
}
}
};
/** @override */
goog.editor.plugins.EnterHandler.prototype.handleKeyPress = function(e) {
// If a dialog doesn't have selectable field, Gecko grabs the event and
// performs actions in editor window. This solves that problem and allows
// the event to be passed on to proper handlers.
if (goog.userAgent.GECKO && this.getFieldObject().inModalMode()) {
return false;
}
// Firefox will allow the first node in an iframe to be deleted
// on a backspace. Disallow it if the node is empty.
if (e.keyCode == goog.events.KeyCodes.BACKSPACE) {
this.handleBackspaceInternal(e, this.getFieldObject().getRange());
} else if (e.keyCode == goog.events.KeyCodes.ENTER) {
if (goog.userAgent.GECKO) {
if (!e.shiftKey) {
// Behave similarly to IE's content editable return carriage:
// If the shift key is down or specified by the application, insert a
// BR, otherwise split paragraphs
this.handleEnterGecko_(e);
}
} else {
// In Gecko-based browsers, this is handled in the handleEnterGecko_
// method.
this.getFieldObject().dispatchBeforeChange();
var cursorPosition = this.deleteCursorSelection_();
var split = !!this.getFieldObject().execCommand(
goog.editor.plugins.Blockquote.SPLIT_COMMAND, cursorPosition);
if (split) {
// TODO(user): I think we probably don't need to stopPropagation here
e.preventDefault();
e.stopPropagation();
}
this.releasePositionObject_(cursorPosition);
if (goog.userAgent.WEBKIT) {
this.handleEnterWebkitInternal(e);
}
this.processParagraphTagsInternal(e, split);
this.getFieldObject().dispatchChange();
}
} else if (goog.userAgent.GECKO && e.keyCode == goog.events.KeyCodes.DELETE) {
this.handleDeleteGecko(e);
}
return false;
};
/** @override */
goog.editor.plugins.EnterHandler.prototype.handleKeyUp = function(e) {
// If a dialog doesn't have selectable field, Gecko grabs the event and
// performs actions in editor window. This solves that problem and allows
// the event to be passed on to proper handlers.
if (goog.userAgent.GECKO && this.getFieldObject().inModalMode()) {
return false;
}
this.handleKeyUpInternal(e);
return false;
};
/**
* Internal handler for keyup events.
* @param {goog.events.Event} e The key event.
* @protected
*/
goog.editor.plugins.EnterHandler.prototype.handleKeyUpInternal = function(e) {
if ((goog.userAgent.IE || goog.userAgent.OPERA) &&
e.keyCode == goog.events.KeyCodes.ENTER) {
this.ensureBlockIeOpera(goog.dom.TagName.DIV, true);
}
};
/**
* Handles an enter keypress event on fields in Gecko.
* @param {goog.events.BrowserEvent} e The key event.
* @private
*/
goog.editor.plugins.EnterHandler.prototype.handleEnterGecko_ = function(e) {
// Retrieve whether the selection is collapsed before we delete it.
var range = this.getFieldObject().getRange();
var wasCollapsed = !range || range.isCollapsed();
var cursorPosition = this.deleteCursorSelection_();
var handled = this.getFieldObject().execCommand(
goog.editor.plugins.Blockquote.SPLIT_COMMAND, cursorPosition);
if (handled) {
// TODO(user): I think we probably don't need to stopPropagation here
e.preventDefault();
e.stopPropagation();
}
this.releasePositionObject_(cursorPosition);
if (!handled) {
this.handleEnterAtCursorGeckoInternal(e, wasCollapsed, range);
}
};
/**
* Handle an enter key press in WebKit.
* @param {goog.events.BrowserEvent} e The key press event.
* @protected
*/
goog.editor.plugins.EnterHandler.prototype.handleEnterWebkitInternal =
goog.nullFunction;
/**
* Handle an enter key press on collapsed selection. handleEnterGecko_ ensures
* the selection is collapsed by deleting its contents if it is not. The
* default implementation does nothing.
* @param {goog.events.BrowserEvent} e The key press event.
* @param {boolean} wasCollapsed Whether the selection was collapsed before
* the key press. If it was not, code before this function has already
* cleared the contents of the selection.
* @param {goog.dom.AbstractRange} range Object representing the selection.
* @protected
*/
goog.editor.plugins.EnterHandler.prototype.handleEnterAtCursorGeckoInternal =
goog.nullFunction;
/**
* Names of all the nodes that we don't want to turn into block nodes in IE when
* the user hits enter.
* @type {Object}
* @private
*/
goog.editor.plugins.EnterHandler.DO_NOT_ENSURE_BLOCK_NODES_ =
goog.object.createSet(
goog.dom.TagName.LI, goog.dom.TagName.DIV, goog.dom.TagName.H1,
goog.dom.TagName.H2, goog.dom.TagName.H3, goog.dom.TagName.H4,
goog.dom.TagName.H5, goog.dom.TagName.H6);
/**
* Whether this is a node that contains a single BR tag and non-nbsp
* whitespace.
* @param {Node} node Node to check.
* @return {boolean} Whether this is an element that only contains a BR.
* @protected
*/
goog.editor.plugins.EnterHandler.isBrElem = function(node) {
return goog.editor.node.isEmpty(node) &&
node.getElementsByTagName(goog.dom.TagName.BR).length == 1;
};
/**
* Ensures all text in IE and Opera to be in the given tag in order to control
* Enter spacing. Call this when Enter is pressed if desired.
*
* We want to make sure the user is always inside of a block (or other nodes
* listed in goog.editor.plugins.EnterHandler.IGNORE_ENSURE_BLOCK_NODES_). We
* listen to keypress to force nodes that the user is leaving to turn into
* blocks, but we also need to listen to keyup to force nodes that the user is
* entering to turn into blocks.
* Example: html is: "<h2>foo[cursor]</h2>", and the user hits enter. We
* don't want to format the h2, but we do want to format the P that is
* created on enter. The P node is not available until keyup.
* @param {goog.dom.TagName} tag The tag name to convert to.
* @param {boolean=} opt_keyUp Whether the function is being called on key up.
* When called on key up, the cursor is in the newly created node, so the
* semantics for when to change it to a block are different. Specifically,
* if the resulting node contains only a BR, it is converted to <tag>.
* @protected
*/
goog.editor.plugins.EnterHandler.prototype.ensureBlockIeOpera = function(tag,
opt_keyUp) {
var range = this.getFieldObject().getRange();
var container = range.getContainer();
var field = this.getFieldObject().getElement();
var paragraph;
while (container && container != field) {
// We don't need to ensure a block if we are already in the same block, or
// in another block level node that we don't want to change the format of
// (unless we're handling keyUp and that block node just contains a BR).
var nodeName = container.nodeName;
// Due to @bug 2455389, the call to isBrElem needs to be inlined in the if
// instead of done before and saved in a variable, so that it can be
// short-circuited and avoid a weird IE edge case.
if (nodeName == tag ||
(goog.editor.plugins.EnterHandler.
DO_NOT_ENSURE_BLOCK_NODES_[nodeName] && !(opt_keyUp &&
goog.editor.plugins.EnterHandler.isBrElem(container)))) {
// Opera can create a <p> inside of a <div> in some situations,
// such as when breaking out of a list that is contained in a <div>.
if (goog.userAgent.OPERA && paragraph) {
if (nodeName == tag &&
paragraph == container.lastChild &&
goog.editor.node.isEmpty(paragraph)) {
goog.dom.insertSiblingAfter(paragraph, container);
goog.dom.Range.createFromNodeContents(paragraph).select();
}
break;
}
return;
}
if (goog.userAgent.OPERA && opt_keyUp && nodeName == goog.dom.TagName.P &&
nodeName != tag) {
paragraph = container;
}
container = container.parentNode;
}
if (goog.userAgent.IE && !goog.userAgent.isVersionOrHigher(9)) {
// IE (before IE9) has a bug where if the cursor is directly before a block
// node (e.g., the content is "foo[cursor]<blockquote>bar</blockquote>"),
// the FormatBlock command actually formats the "bar" instead of the "foo".
// This is just wrong. To work-around this, we want to move the
// selection back one character, and then restore it to its prior position.
// NOTE: We use the following "range math" to detect this situation because
// using Closure ranges here triggers a bug in IE that causes a crash.
// parent2 != parent3 ensures moving the cursor forward one character
// crosses at least 1 element boundary, and therefore tests if the cursor is
// at such a boundary. The second check, parent3 != range.parentElement()
// weeds out some cases where the elements are siblings instead of cousins.
var needsHelp = false;
range = range.getBrowserRangeObject();
var range2 = range.duplicate();
range2.moveEnd('character', 1);
// In whitebox mode, when the cursor is at the end of the field, trying to
// move the end of the range will do nothing, and hence the range's text
// will be empty. In this case, the cursor clearly isn't sitting just
// before a block node, since it isn't before anything.
if (range2.text.length) {
var parent2 = range2.parentElement();
var range3 = range2.duplicate();
range3.collapse(false);
var parent3 = range3.parentElement();
if ((needsHelp = parent2 != parent3 &&
parent3 != range.parentElement())) {
range.move('character', -1);
range.select();
}
}
}
this.getFieldObject().getEditableDomHelper().getDocument().execCommand(
'FormatBlock', false, '<' + tag + '>');
if (needsHelp) {
range.move('character', 1);
range.select();
}
};
/**
* Deletes the content at the current cursor position.
* @return {!Node|!Object} Something representing the current cursor position.
* See deleteCursorSelectionIE_ and deleteCursorSelectionW3C_ for details.
* Should be passed to releasePositionObject_ when no longer in use.
* @private
*/
goog.editor.plugins.EnterHandler.prototype.deleteCursorSelection_ = function() {
return goog.editor.BrowserFeature.HAS_W3C_RANGES ?
this.deleteCursorSelectionW3C_() : this.deleteCursorSelectionIE_();
};
/**
* Releases the object returned by deleteCursorSelection_.
* @param {Node|Object} position The object returned by deleteCursorSelection_.
* @private
*/
goog.editor.plugins.EnterHandler.prototype.releasePositionObject_ =
function(position) {
if (!goog.editor.BrowserFeature.HAS_W3C_RANGES) {
(/** @type {Node} */ (position)).removeNode(true);
}
};
/**
* Delete the selection at the current cursor position, then returns a temporary
* node at the current position.
* @return {!Node} A temporary node marking the current cursor position. This
* node should eventually be removed from the DOM.
* @private
*/
goog.editor.plugins.EnterHandler.prototype.deleteCursorSelectionIE_ =
function() {
var doc = this.getFieldDomHelper().getDocument();
var range = doc.selection.createRange();
var id = goog.string.createUniqueString();
range.pasteHTML('<span id="' + id + '"></span>');
var splitNode = doc.getElementById(id);
splitNode.id = '';
return splitNode;
};
/**
* Delete the selection at the current cursor position, then returns the node
* at the current position.
* @return {!goog.editor.range.Point} The current cursor position. Note that
* unlike simulateEnterIE_, this should not be removed from the DOM.
* @private
*/
goog.editor.plugins.EnterHandler.prototype.deleteCursorSelectionW3C_ =
function() {
var range = this.getFieldObject().getRange();
// Delete the current selection if it's is non-collapsed.
// Although this is redundant in FF, it's necessary for Safari
if (!range.isCollapsed()) {
var shouldDelete = true;
// Opera selects the <br> in an empty block if there is no text node
// preceding it. To preserve inline formatting when pressing [enter] inside
// an empty block, don't delete the selection if it only selects a <br> at
// the end of the block.
// TODO(user): Move this into goog.dom.Range. It should detect this state
// when creating a range from the window selection and fix it in the created
// range.
if (goog.userAgent.OPERA) {
var startNode = range.getStartNode();
var startOffset = range.getStartOffset();
if (startNode == range.getEndNode() &&
// This weeds out cases where startNode is a text node.
startNode.lastChild &&
startNode.lastChild.tagName == goog.dom.TagName.BR &&
// If this check is true, then endOffset is implied to be
// startOffset + 1, because the selection is not collapsed and
// it starts and ends within the same element.
startOffset == startNode.childNodes.length - 1) {
shouldDelete = false;
}
}
if (shouldDelete) {
goog.editor.plugins.EnterHandler.deleteW3cRange_(range);
}
}
return goog.editor.range.getDeepEndPoint(range, true);
};
/**
* Deletes the contents of the selection from the DOM.
* @param {goog.dom.AbstractRange} range The range to remove contents from.
* @return {goog.dom.AbstractRange} The resulting range. Used for testing.
* @private
*/
goog.editor.plugins.EnterHandler.deleteW3cRange_ = function(range) {
if (range && !range.isCollapsed()) {
var reselect = true;
var baseNode = range.getContainerElement();
var nodeOffset = new goog.dom.NodeOffset(range.getStartNode(), baseNode);
var rangeOffset = range.getStartOffset();
// Whether the selection crosses no container boundaries.
var isInOneContainer =
goog.editor.plugins.EnterHandler.isInOneContainerW3c_(range);
// Whether the selection ends in a container it doesn't fully select.
var isPartialEnd = !isInOneContainer &&
goog.editor.plugins.EnterHandler.isPartialEndW3c_(range);
// Remove The range contents, and ensure the correct content stays selected.
range.removeContents();
var node = nodeOffset.findTargetNode(baseNode);
if (node) {
range = goog.dom.Range.createCaret(node, rangeOffset);
} else {
// This occurs when the node that would have been referenced has now been
// deleted and there are no other nodes in the baseNode. Thus need to
// set the caret to the end of the base node.
range =
goog.dom.Range.createCaret(baseNode, baseNode.childNodes.length);
reselect = false;
}
range.select();
// If we just deleted everything from the container, add an nbsp
// to the container, and leave the cursor inside of it
if (isInOneContainer) {
var container = goog.editor.style.getContainer(range.getStartNode());
if (goog.editor.node.isEmpty(container, true)) {
var html = '&nbsp;';
if (goog.userAgent.OPERA &&
container.tagName == goog.dom.TagName.LI) {
// Don't break Opera's native break-out-of-lists behavior.
html = '<br>';
}
goog.editor.node.replaceInnerHtml(container, html);
goog.editor.range.selectNodeStart(container.firstChild);
reselect = false;
}
}
if (isPartialEnd) {
/*
This code handles the following, where | is the cursor:
<div>a|b</div><div>c|d</div>
After removeContents, the remaining HTML is
<div>a</div><div>d</div>
which means the line break between the two divs remains. This block
moves children of the second div in to the first div to get the correct
result:
<div>ad</div>
TODO(robbyw): Should we wrap the second div's contents in a span if they
have inline style?
*/
var rangeStart = goog.editor.style.getContainer(range.getStartNode());
var redundantContainer = goog.editor.node.getNextSibling(rangeStart);
if (rangeStart && redundantContainer) {
goog.dom.append(rangeStart, redundantContainer.childNodes);
goog.dom.removeNode(redundantContainer);
}
}
if (reselect) {
// The contents of the original range are gone, so restore the cursor
// position at the start of where the range once was.
range = goog.dom.Range.createCaret(nodeOffset.findTargetNode(baseNode),
rangeOffset);
range.select();
}
}
return range;
};
/**
* Checks whether the whole range is in a single block-level element.
* @param {goog.dom.AbstractRange} range The range to check.
* @return {boolean} Whether the whole range is in a single block-level element.
* @private
*/
goog.editor.plugins.EnterHandler.isInOneContainerW3c_ = function(range) {
// Find the block element containing the start of the selection.
var startContainer = range.getStartNode();
if (goog.editor.style.isContainer(startContainer)) {
startContainer = startContainer.childNodes[range.getStartOffset()] ||
startContainer;
}
startContainer = goog.editor.style.getContainer(startContainer);
// Find the block element containing the end of the selection.
var endContainer = range.getEndNode();
if (goog.editor.style.isContainer(endContainer)) {
endContainer = endContainer.childNodes[range.getEndOffset()] ||
endContainer;
}
endContainer = goog.editor.style.getContainer(endContainer);
// Compare the two.
return startContainer == endContainer;
};
/**
* Checks whether the end of the range is not at the end of a block-level
* element.
* @param {goog.dom.AbstractRange} range The range to check.
* @return {boolean} Whether the end of the range is not at the end of a
* block-level element.
* @private
*/
goog.editor.plugins.EnterHandler.isPartialEndW3c_ = function(range) {
var endContainer = range.getEndNode();
var endOffset = range.getEndOffset();
var node = endContainer;
if (goog.editor.style.isContainer(node)) {
var child = node.childNodes[endOffset];
// Child is null when end offset is >= length, which indicates the entire
// container is selected. Otherwise, we also know the entire container
// is selected if the selection ends at a new container.
if (!child ||
child.nodeType == goog.dom.NodeType.ELEMENT &&
goog.editor.style.isContainer(child)) {
return false;
}
}
var container = goog.editor.style.getContainer(node);
while (container != node) {
if (goog.editor.node.getNextSibling(node)) {
return true;
}
node = node.parentNode;
}
return endOffset != goog.editor.node.getLength(endContainer);
};
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<!--
Tests for goog.editor.plugins.EnterHandler
@author nicksantos@google.com (Nick Santos)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.EnterHandler
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.EnterHandlerTest');
</script>
<style type="text/css">
.tr-field {
border: thin solid blue;
}
.tr_bq {
border-left: thin solid red;
margin-left: 5px;
}
</style>
</head>
<body>
<input type="button" value="Make all fields editable" onclick="setUp()" />
<p>
<div id="container">
This is used to test static utility functions.
</div>
<div id="root">
<div id="field1" class="tr-field">
<blockquote>
This is an
<span id="field1cursor">
selection
</span>
unsetup blockquote
</blockquote>
</div>
<p>
<div id="field2" class="tr-field">
<blockquote class="tr_bq">
This is a
<span id="field2cursor">
selection
</span>
setup blockquote
</blockquote>
</div>
</p>
<p>
</p>
</div>
</p>
</body>
</html>
@@ -0,0 +1,741 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.EnterHandlerTest');
goog.setTestOnly('goog.editor.plugins.EnterHandlerTest');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Field');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.plugins.Blockquote');
goog.require('goog.editor.plugins.EnterHandler');
goog.require('goog.editor.range');
goog.require('goog.events');
goog.require('goog.events.KeyCodes');
goog.require('goog.testing.ExpectedFailures');
goog.require('goog.testing.MockClock');
goog.require('goog.testing.dom');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var savedHtml;
var field1;
var field2;
var firedDelayedChange;
var firedBeforeChange;
var clock;
var container;
var EXPECTEDFAILURES;
function setUpPage() {
container = goog.dom.getElement('container');
}
function setUp() {
EXPECTEDFAILURES = new goog.testing.ExpectedFailures();
savedHtml = goog.dom.getElement('root').innerHTML;
clock = new goog.testing.MockClock(true);
}
function setUpFields(classnameRequiredToSplitBlockquote) {
field1 = makeField('field1', classnameRequiredToSplitBlockquote);
field2 = makeField('field2', classnameRequiredToSplitBlockquote);
field1.makeEditable();
field2.makeEditable();
}
function tearDown() {
clock.dispose();
EXPECTEDFAILURES.handleTearDown();
goog.dom.getElement('root').innerHTML = savedHtml;
}
function testEnterInNonSetupBlockquote() {
setUpFields(true);
resetChangeFlags();
var prevented = !selectNodeAndHitEnter(field1, 'field1cursor');
waitForChangeEvents();
assertChangeFlags();
// make sure there's just one blockquote, and that the text has been deleted.
var elem = field1.getElement();
var dom = field1.getEditableDomHelper();
EXPECTEDFAILURES.expectFailureFor(goog.userAgent.OPERA,
'The blockquote is overwritten with DIV due to CORE-22104 -- Opera ' +
'overwrites the BLOCKQUOTE ancestor with DIV when doing FormatBlock ' +
'for DIV');
try {
assertEquals('Blockquote should not be split',
1, dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem).length);
} catch (e) {
EXPECTEDFAILURES.handleException(e);
}
assert('Selection should be deleted',
-1 == elem.innerHTML.indexOf('selection'));
assertEquals('The event should have been prevented only on webkit',
prevented, goog.userAgent.WEBKIT);
}
function testEnterInSetupBlockquote() {
setUpFields(true);
resetChangeFlags();
var prevented = !selectNodeAndHitEnter(field2, 'field2cursor');
waitForChangeEvents();
assertChangeFlags();
// make sure there are two blockquotes, and a DIV with nbsp in the middle.
var elem = field2.getElement();
var dom = field2.getEditableDomHelper();
assertEquals('Blockquote should be split', 2,
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem).length);
assert('Selection should be deleted',
-1 == elem.innerHTML.indexOf('selection'));
assert('should have div with &nbsp;',
-1 != elem.innerHTML.indexOf('>' + getNbsp() + '<'));
assert('event should have been prevented', prevented);
}
function testEnterInNonSetupBlockquoteWhenClassnameIsNotRequired() {
setUpFields(false);
resetChangeFlags();
var prevented = !selectNodeAndHitEnter(field1, 'field1cursor');
waitForChangeEvents();
assertChangeFlags();
// make sure there are two blockquotes, and a DIV with nbsp in the middle.
var elem = field1.getElement();
var dom = field1.getEditableDomHelper();
assertEquals('Blockquote should be split', 2,
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem).length);
assert('Selection should be deleted',
-1 == elem.innerHTML.indexOf('selection'));
assert('should have div with &nbsp;',
-1 != elem.innerHTML.indexOf('>' + getNbsp() + '<'));
assert('event should have been prevented', prevented);
}
function testEnterInBlockquoteCreatesDivInBrMode() {
setUpFields(true);
selectNodeAndHitEnter(field2, 'field2cursor');
var elem = field2.getElement();
var dom = field2.getEditableDomHelper();
var firstBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[0];
var div = dom.getNextElementSibling(firstBlockquote);
assertEquals('Element after blockquote should be a div', 'DIV', div.tagName);
assertEquals('Element after div should be second blockquote',
'BLOCKQUOTE', dom.getNextElementSibling(div).tagName);
}
/**
* Tests that breaking after a BR doesn't result in unnecessary newlines.
* @bug 1471047
*/
function testEnterInBlockquoteRemovesUnnecessaryBrWithCursorAfterBr() {
setUpFields(true);
// Assume the following HTML snippet:-
// <blockquote>one<br>|two<br></blockquote>
//
// After enter on the cursor position without the fix, the resulting HTML
// after the blockquote split was:-
// <blockquote>one</blockquote>
// <div>&nbsp;</div>
// <blockquote><br>two<br></blockquote>
//
// This creates the impression on an unnecessary newline. The resulting HTML
// after the fix is:-
//
// <blockquote>one<br></blockquote>
// <div>&nbsp;</div>
// <blockquote>two<br></blockquote>
field1.setHtml(false,
'<blockquote id="quote" class="tr_bq">one<br>' +
'two<br></blockquote>');
var dom = field1.getEditableDomHelper();
goog.dom.Range.createCaret(dom.getElement('quote'), 2).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var elem = field1.getElement();
var secondBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[1];
assertHTMLEquals('two<br>', secondBlockquote.innerHTML);
// Verifies that a blockquote split doesn't happen if it doesn't need to.
field1.setHtml(false,
'<blockquote class="tr_bq">one<br id="brcursor"></blockquote>');
selectNodeAndHitEnter(field1, 'brcursor');
assertEquals(
1, dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem).length);
}
/**
* Tests that breaking in a text node before a BR doesn't result in unnecessary
* newlines.
* @bug 1471047
*/
function testEnterInBlockquoteRemovesUnnecessaryBrWithCursorBeforeBr() {
setUpFields(true);
// Assume the following HTML snippet:-
// <blockquote>one|<br>two<br></blockquote>
//
// After enter on the cursor position, the resulting HTML should be.
// <blockquote>one<br></blockquote>
// <div>&nbsp;</div>
// <blockquote>two<br></blockquote>
field1.setHtml(false,
'<blockquote id="quote" class="tr_bq">one<br>' +
'two<br></blockquote>');
var dom = field1.getEditableDomHelper();
var cursor = dom.getElement('quote').firstChild;
goog.dom.Range.createCaret(cursor, 3).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var elem = field1.getElement();
var secondBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[1];
assertHTMLEquals('two<br>', secondBlockquote.innerHTML);
// Ensures that standard text node split works as expected with the new
// change.
field1.setHtml(false,
'<blockquote id="quote" class="tr_bq">one<b>two</b><br>');
cursor = dom.getElement('quote').firstChild;
goog.dom.Range.createCaret(cursor, 3).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
secondBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[1];
assertHTMLEquals('<b>two</b><br>', secondBlockquote.innerHTML);
}
/**
* Tests that pressing enter in a blockquote doesn't create unnecessary
* DOM subtrees.
*
* @bug 1991539
* @bug 1991392
*/
function testEnterInBlockquoteRemovesExtraNodes() {
setUpFields(true);
// Let's assume we have the following DOM structure and the
// cursor is placed after the first numbered list item "one".
//
// <blockquote class="tr_bq">
// <div><div>a</div><ol><li>one|</li></div>
// <div>two</div>
// </blockquote>
//
// After pressing enter, we have the following structure.
//
// <blockquote class="tr_bq">
// <div><div>a</div><ol><li>one|</li></div>
// </blockquote>
// <div>&nbsp;</div>
// <blockquote class="tr_bq">
// <div><ol><li><span id=""></span></li></ol></div>
// <div>two</div>
// </blockquote>
//
// This appears to the user as an empty list. After the fix, the HTML
// will be
//
// <blockquote class="tr_bq">
// <div><div>a</div><ol><li>one|</li></div>
// </blockquote>
// <div>&nbsp;</div>
// <blockquote class="tr_bq">
// <div>two</div>
// </blockquote>
//
field1.setHtml(false,
'<blockquote class="tr_bq">' +
'<div><div>a</div><ol><li id="cursor">one</li></div>' +
'<div>b</div>' +
'</blockquote>');
var dom = field1.getEditableDomHelper();
goog.dom.Range.createCaret(dom.getElement('cursor').firstChild, 3).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var elem = field1.getElement();
var secondBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[1];
assertHTMLEquals('<div>b</div>', secondBlockquote.innerHTML);
// Ensure that we remove only unnecessary subtrees.
field1.setHtml(false,
'<blockquote class="tr_bq">' +
'<div><span>a</span><div id="cursor">one</div><div>two</div></div>' +
'<div><span>c</span></div>' +
'</blockquote>');
goog.dom.Range.createCaret(dom.getElement('cursor').firstChild, 3).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
secondBlockquote =
dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem)[1];
var expectedHTML = '<div><div>two</div></div>' +
'<div><span>c</span></div>';
assertHTMLEquals(expectedHTML, secondBlockquote.innerHTML);
// Place the cursor in the middle of a line.
field1.setHtml(false,
'<blockquote id="quote" class="tr_bq">' +
'<div>one</div><div>two</div>' +
'</blockquote>');
goog.dom.Range.createCaret(
dom.getElement('quote').firstChild.firstChild, 1).select();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var blockquotes = dom.getElementsByTagNameAndClass('BLOCKQUOTE', null, elem);
assertEquals(2, blockquotes.length);
assertHTMLEquals('<div>o</div>', blockquotes[0].innerHTML);
assertHTMLEquals('<div>ne</div><div>two</div>', blockquotes[1].innerHTML);
}
function testEnterInList() {
setUpFields(true);
// <enter> in a list should *never* be handled by custom code. Lists are
// just way too complicated to get right.
field1.setHtml(false,
'<ol><li>hi!<span id="field1cursor"></span></li></ol>');
if (goog.userAgent.OPERA) {
// Opera doesn't actually place the selection in the empty span
// unless we add a text node first.
var dom = field1.getEditableDomHelper();
dom.getElement('field1cursor').appendChild(dom.createTextNode(''));
}
var prevented = !selectNodeAndHitEnter(field1, 'field1cursor');
assertFalse('<enter> in a list should not be prevented', prevented);
}
function testEnterAtEndOfBlockInWebkit() {
setUpFields(true);
if (goog.userAgent.WEBKIT) {
field1.setHtml(false,
'<blockquote>hi!<span id="field1cursor"></span></blockquote>');
var cursor = field1.getEditableDomHelper().getElement('field1cursor');
goog.editor.range.placeCursorNextTo(cursor, false);
goog.dom.removeNode(cursor);
var prevented = !goog.testing.events.fireKeySequence(
field1.getElement(), goog.events.KeyCodes.ENTER);
waitForChangeEvents();
assertChangeFlags();
assert('event should have been prevented', prevented);
// Make sure that the block now has two brs.
var elem = field1.getElement();
assertEquals('should have inserted two br tags: ' + elem.innerHTML,
2, goog.dom.getElementsByTagNameAndClass('BR', null, elem).length);
}
}
/**
* Tests that deleting a BR that comes right before a block element works.
* @bug 1471096
* @bug 2056376
*/
function testDeleteBrBeforeBlock() {
setUpFields(true);
// This test only works on Gecko, because it's testing for manual deletion of
// BR tags, which is done only for Gecko. For other browsers we fall through
// and let the browser do the delete, which can only be tested with a robot
// test (see javascript/apps/editor/tests/delete_br_robot.html).
if (goog.userAgent.GECKO) {
field1.setHtml(false, 'one<br><br><div>two</div>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
helper.select(field1.getElement(), 2); // Between the two BR's.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted exactly one <br>',
'one<br><div>two</div>',
field1.getElement().innerHTML);
// We test the case where the BR has a previous sibling which is not
// a block level element.
field1.setHtml(false, 'one<br><ul><li>two</li></ul>');
helper.select(field1.getElement(), 1); // Between one and BR.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted the <br>',
'one<ul><li>two</li></ul>',
field1.getElement().innerHTML);
// Verify that the cursor is placed at the end of the text node "one".
var range = field1.getRange();
var focusNode = range.getFocusNode();
assertTrue('The selected range should be collapsed', range.isCollapsed());
assertTrue('The focus node should be the text node "one"',
focusNode.nodeType == goog.dom.NodeType.TEXT &&
focusNode.data == 'one');
assertEquals('The focus offset should be at the end of the text node "one"',
focusNode.length,
range.getFocusOffset());
assertTrue('The next sibling of the focus node should be the UL',
focusNode.nextSibling &&
focusNode.nextSibling.tagName == goog.dom.TagName.UL);
// We test the case where the previous sibling of the BR is a block
// level element.
field1.setHtml(false, '<div>foo</div><br><div><span>bar</span></div>');
helper.select(field1.getElement(), 1); // Before the BR.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted the <br>',
'<div>foo</div><div><span>bar</span></div>',
field1.getElement().innerHTML);
range = field1.getRange();
assertEquals('The selected range should be contained within the <span>',
goog.dom.TagName.SPAN,
range.getContainerElement().tagName);
assertTrue('The selected range should be collapsed', range.isCollapsed());
// Verify that the cursor is placed inside the span at the beginning of bar.
focusNode = range.getFocusNode();
assertTrue('The focus node should be the text node "bar"',
focusNode.nodeType == goog.dom.NodeType.TEXT &&
focusNode.data == 'bar');
assertEquals('The focus offset should be at the beginning ' +
'of the text node "bar"',
0,
range.getFocusOffset());
// We test the case where the BR does not have a previous sibling.
field1.setHtml(false, '<br><ul><li>one</li></ul>');
helper.select(field1.getElement(), 0); // Before the BR.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted the <br>',
'<ul><li>one</li></ul>',
field1.getElement().innerHTML);
range = field1.getRange();
// Verify that the cursor is placed inside the LI at the text node "one".
assertEquals('The selected range should be contained within the <li>',
goog.dom.TagName.LI,
range.getContainerElement().tagName);
assertTrue('The selected range should be collapsed', range.isCollapsed());
focusNode = range.getFocusNode();
assertTrue('The focus node should be the text node "one"',
(focusNode.nodeType == goog.dom.NodeType.TEXT &&
focusNode.data == 'one'));
assertEquals('The focus offset should be at the beginning of ' +
'the text node "one"',
0,
range.getFocusOffset());
// Testing deleting a BR followed by a block level element and preceded
// by a BR.
field1.setHtml(false, '<br><br><ul><li>one</li></ul>');
helper.select(field1.getElement(), 1); // Between the BR's.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted the <br>',
'<br><ul><li>one</li></ul>',
field1.getElement().innerHTML);
// Verify that the cursor is placed inside the LI at the text node "one".
range = field1.getRange();
assertEquals('The selected range should be contained within the <li>',
goog.dom.TagName.LI,
range.getContainerElement().tagName);
assertTrue('The selected range should be collapsed', range.isCollapsed());
focusNode = range.getFocusNode();
assertTrue('The focus node should be the text node "one"',
(focusNode.nodeType == goog.dom.NodeType.TEXT &&
focusNode.data == 'one'));
assertEquals('The focus offset should be at the beginning of ' +
'the text node "one"',
0,
range.getFocusOffset());
} // End if GECKO
}
/**
* Tests that deleting a BR before a blockquote doesn't remove quoted text.
* @bug 1471075
*/
function testDeleteBeforeBlockquote() {
setUpFields(true);
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<br><br><div><br><blockquote>foo</blockquote></div>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
helper.select(field1.getElement(), 0); // Before the first BR.
// Fire three deletes in quick succession.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted all the <br>\'s and the blockquote ' +
'isn\'t affected',
'<div><blockquote>foo</blockquote></div>',
field1.getElement().innerHTML);
var range = field1.getRange();
assertEquals('The selected range should be contained within the ' +
'<blockquote>',
goog.dom.TagName.BLOCKQUOTE,
range.getContainerElement().tagName);
assertTrue('The selected range should be collapsed', range.isCollapsed());
var focusNode = range.getFocusNode();
assertTrue('The focus node should be the text node "foo"',
(focusNode.nodeType == goog.dom.NodeType.TEXT &&
focusNode.data == 'foo'));
assertEquals('The focus offset should be at the ' +
'beginning of the text node "foo"',
0,
range.getFocusOffset());
}
}
/**
* Tests that deleting a BR is working normally (that the workaround for the
* bug is not causing double deletes).
* @bug 1471096
*/
function testDeleteBrNormal() {
setUpFields(true);
// This test only works on Gecko, because it's testing for manual deletion of
// BR tags, which is done only for Gecko. For other browsers we fall through
// and let the browser do the delete, which can only be tested with a robot
// test (see javascript/apps/editor/tests/delete_br_robot.html).
if (goog.userAgent.GECKO) {
field1.setHtml(false, 'one<br><br><br>two');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
helper.select(field1.getElement(), 2); // Between the first and second BR's.
field1.getElement().focus();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted exactly one <br>',
'one<br><br>two',
field1.getElement().innerHTML);
} // End if GECKO
}
/**
* Tests that deleteCursorSelectionW3C_ correctly recognizes visually
* collapsed selections in Opera even if they contain a <br>.
* See the deleteCursorSelectionW3C_ comment in enterhandler.js.
*/
function testCollapsedSelectionKeepsBrOpera() {
setUpFields(true);
if (goog.userAgent.OPERA) {
field1.setHtml(false, '<div><br id="pleasedontdeleteme"></div>');
field1.focus();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
assertNotNull('The <br> must not have been deleted',
goog.dom.getElement('pleasedontdeleteme'));
}
}
/**
* Selects the node at the given id, and simulates an ENTER keypress.
* @param {goog.editor.Field} field The field with the node.
* @param {string} id A DOM id.
* @return {boolean} Whether preventDefault was called on the event.
*/
function selectNodeAndHitEnter(field, id) {
var dom = field.getEditableDomHelper();
var cursor = dom.getElement(id);
goog.dom.Range.createFromNodeContents(cursor).select();
return goog.testing.events.fireKeySequence(
cursor, goog.events.KeyCodes.ENTER);
}
/**
* Creates a field with only the enter handler plugged in, for testing.
* @param {string} id A DOM id.
* @return {goog.editor.Field} A field.
*/
function makeField(id, classnameRequiredToSplitBlockquote) {
var field = new goog.editor.Field(id);
field.registerPlugin(new goog.editor.plugins.EnterHandler());
field.registerPlugin(new goog.editor.plugins.Blockquote(
classnameRequiredToSplitBlockquote));
goog.events.listen(field, goog.editor.Field.EventType.BEFORECHANGE,
function() {
// set the global flag that beforechange was fired.
firedBeforeChange = true;
});
goog.events.listen(field, goog.editor.Field.EventType.DELAYEDCHANGE,
function() {
// set the global flag that delayed change was fired.
firedDelayedChange = true;
});
return field;
}
/**
* Reset all the global flags related to change events.
*/
function resetChangeFlags() {
waitForChangeEvents();
firedBeforeChange = firedDelayedChange = false;
}
/**
* Asserts that both change flags were fired since the last reset.
*/
function assertChangeFlags() {
assert('Beforechange should have fired', firedBeforeChange);
assert('Delayedchange should have fired', firedDelayedChange);
}
/**
* Wait for delayedchange to propagate.
*/
function waitForChangeEvents() {
clock.tick(goog.editor.Field.DELAYED_CHANGE_FREQUENCY +
goog.editor.Field.CHANGE_FREQUENCY);
}
function getNbsp() {
// On WebKit (pre-528) and Opera, &nbsp; shows up as its unicode character in
// innerHTML under some circumstances.
return (goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('528')) ||
goog.userAgent.OPERA ? '\u00a0' : '&nbsp;';
}
function testPrepareContent() {
setUpFields(true);
assertPreparedContents('hi', 'hi');
assertPreparedContents(
goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES ? '<br>' : '', ' ');
}
/**
* Assert that the prepared contents matches the expected.
*/
function assertPreparedContents(expected, original) {
assertEquals(expected,
field1.reduceOp_(
goog.editor.Plugin.Op.PREPARE_CONTENTS_HTML, original));
}
// UTILITY FUNCTION TESTS.
function testDeleteW3CSimple() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML = '<div>abcd</div>';
var range = goog.dom.Range.createFromNodes(container.firstChild.firstChild,
1, container.firstChild.firstChild, 3);
range.select();
goog.editor.plugins.EnterHandler.deleteW3cRange_(range);
goog.testing.dom.assertHtmlContentsMatch('<div>ad</div>', container);
}
}
function testDeleteW3CAll() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML = '<div>abcd</div>';
var range = goog.dom.Range.createFromNodes(container.firstChild.firstChild,
0, container.firstChild.firstChild, 4);
range.select();
goog.editor.plugins.EnterHandler.deleteW3cRange_(range);
goog.testing.dom.assertHtmlContentsMatch('<div>&nbsp;</div>', container);
}
}
function testDeleteW3CPartialEnd() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML = '<div>ab</div><div>cd</div>';
var range = goog.dom.Range.createFromNodes(container.firstChild.firstChild,
1, container.lastChild.firstChild, 1);
range.select();
goog.editor.plugins.EnterHandler.deleteW3cRange_(range);
goog.testing.dom.assertHtmlContentsMatch('<div>ad</div>', container);
}
}
function testDeleteW3CNonPartialEnd() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML = '<div>ab</div><div>cd</div>';
var range = goog.dom.Range.createFromNodes(container.firstChild.firstChild,
1, container.lastChild.firstChild, 2);
range.select();
goog.editor.plugins.EnterHandler.deleteW3cRange_(range);
goog.testing.dom.assertHtmlContentsMatch('<div>a</div>', container);
}
}
function testIsInOneContainer() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML = '<div><br></div>';
var div = container.firstChild;
var range = goog.dom.Range.createFromNodes(div, 0, div, 1);
range.select();
assertTrue('Selection must be recognized as being in one container',
goog.editor.plugins.EnterHandler.isInOneContainerW3c_(range));
}
}
function testDeletingEndNodesWithNoNewLine() {
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
container.innerHTML =
'a<div>b</div><div><br></div><div>c</div><div>d</div>';
var range = goog.dom.Range.createFromNodes(
container.childNodes[2], 0, container.childNodes[4].childNodes[0], 1);
range.select();
var newRange = goog.editor.plugins.EnterHandler.deleteW3cRange_(range);
goog.testing.dom.assertHtmlContentsMatch('a<div>b</div>', container);
assertTrue(newRange.isCollapsed());
assertEquals(container, newRange.getStartNode());
assertEquals(2, newRange.getStartOffset());
}
}
@@ -0,0 +1,327 @@
// Copyright 2012 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview A plugin to enable the First Strong Bidi algorithm. The First
* Strong algorithm as a heuristic used to automatically set paragraph direction
* depending on its content.
*
* In the documentation below, a 'paragraph' is the local element which we
* evaluate as a whole for purposes of determining directionality. It may be a
* block-level element (e.g. &lt;div&gt;) or a whole list (e.g. &lt;ul&gt;).
*
* This implementation is based on, but is not identical to, the original
* First Strong algorithm defined in Unicode
* @see http://www.unicode.org/reports/tr9/
* The central difference from the original First Strong algorithm is that this
* implementation decides the paragraph direction based on the first strong
* character that is <em>typed</em> into the paragraph, regardless of its
* location in the paragraph, as opposed to the original algorithm where it is
* the first character in the paragraph <em>by location</em>, regardless of
* whether other strong characters already appear in the paragraph, further its
* start.
*
* <em>Please note</em> that this plugin does not perform the direction change
* itself. Rather, it fires editor commands upon the key up event when a
* direction change needs to be performed; {@code goog.editor.Command.DIR_RTL}
* or {@code goog.editor.Command.DIR_RTL}.
*
*/
goog.provide('goog.editor.plugins.FirstStrong');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.TagIterator');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Command');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.node');
goog.require('goog.editor.range');
goog.require('goog.i18n.bidi');
goog.require('goog.i18n.uChar');
goog.require('goog.iter');
goog.require('goog.userAgent');
/**
* First Strong plugin.
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.FirstStrong = function() {
goog.editor.plugins.FirstStrong.base(this, 'constructor');
/**
* Indicates whether or not the cursor is in a paragraph we have not yet
* finished evaluating for directionality. This is set to true whenever the
* cursor is moved, and set to false after seeing a strong character in the
* paragraph the cursor is currently in.
*
* @type {boolean}
* @private
*/
this.isNewBlock_ = true;
/**
* Indicates whether or not the current paragraph the cursor is in should be
* set to Right-To-Left directionality.
*
* @type {boolean}
* @private
*/
this.switchToRtl_ = false;
/**
* Indicates whether or not the current paragraph the cursor is in should be
* set to Left-To-Right directionality.
*
* @type {boolean}
* @private
*/
this.switchToLtr_ = false;
};
goog.inherits(goog.editor.plugins.FirstStrong, goog.editor.Plugin);
/** @override */
goog.editor.plugins.FirstStrong.prototype.getTrogClassId = function() {
return 'FirstStrong';
};
/** @override */
goog.editor.plugins.FirstStrong.prototype.queryCommandValue =
function(command) {
return false;
};
/** @override */
goog.editor.plugins.FirstStrong.prototype.handleSelectionChange =
function(e, node) {
this.isNewBlock_ = true;
return false;
};
/**
* The name of the attribute which records the input text.
*
* @type {string}
* @const
*/
goog.editor.plugins.FirstStrong.INPUT_ATTRIBUTE = 'fs-input';
/** @override */
goog.editor.plugins.FirstStrong.prototype.handleKeyPress = function(e) {
if (!this.isNewBlock_) {
return false; // We've already determined this paragraph's direction.
}
// Ignore non-character key press events.
if (e.ctrlKey || e.metaKey) {
return false;
}
var newInput = goog.i18n.uChar.fromCharCode(e.charCode);
// IME's may return 0 for the charCode, which is a legitimate, non-Strong
// charCode, or they may return an illegal charCode (for which newInput will
// be false).
if (!newInput || !e.charCode) {
var browserEvent = e.getBrowserEvent();
if (browserEvent) {
if (goog.userAgent.IE && browserEvent['getAttribute']) {
newInput = browserEvent['getAttribute'](
goog.editor.plugins.FirstStrong.INPUT_ATTRIBUTE);
} else {
newInput = browserEvent[
goog.editor.plugins.FirstStrong.INPUT_ATTRIBUTE];
}
}
}
if (!newInput) {
return false; // Unrecognized key.
}
var isLtr = goog.i18n.bidi.isLtrChar(newInput);
var isRtl = !isLtr && goog.i18n.bidi.isRtlChar(newInput);
if (!isLtr && !isRtl) {
return false; // This character cannot change anything (it is not Strong).
}
// This character is Strongly LTR or Strongly RTL. We might switch direction
// on it now, but in any case we do not need to check any more characters in
// this paragraph after it.
this.isNewBlock_ = false;
// Are there no Strong characters already in the paragraph?
if (this.isNeutralBlock_()) {
this.switchToRtl_ = isRtl;
this.switchToLtr_ = isLtr;
}
return false;
};
/**
* Calls the flip directionality commands. This is done here so things go into
* the redo-undo stack at the expected order; fist enter the input, then flip
* directionality.
* @override
*/
goog.editor.plugins.FirstStrong.prototype.handleKeyUp = function(e) {
if (this.switchToRtl_) {
var field = this.getFieldObject();
field.dispatchChange(true);
field.execCommand(goog.editor.Command.DIR_RTL);
this.switchToRtl_ = false;
} else if (this.switchToLtr_) {
var field = this.getFieldObject();
field.dispatchChange(true);
field.execCommand(goog.editor.Command.DIR_LTR);
this.switchToLtr_ = false;
}
return false;
};
/**
* @return {Element} The lowest Block element ancestor of the node where the
* next character will be placed.
* @private
*/
goog.editor.plugins.FirstStrong.prototype.getBlockAncestor_ = function() {
var start = this.getFieldObject().getRange().getStartNode();
// Go up in the DOM until we reach a Block element.
while (!goog.editor.plugins.FirstStrong.isBlock_(start)) {
start = start.parentNode;
}
return /** @type {Element} */ (start);
};
/**
* @return {boolean} Whether the paragraph where the next character will be
* entered contains only non-Strong characters.
* @private
*/
goog.editor.plugins.FirstStrong.prototype.isNeutralBlock_ = function() {
var root = this.getBlockAncestor_();
// The exact node with the cursor location. Simply calling getStartNode() on
// the range only returns the containing block node.
var cursor = goog.editor.range.getDeepEndPoint(
this.getFieldObject().getRange(), false).node;
// In FireFox the BR tag also represents a change in paragraph if not inside a
// list. So we need special handling to only look at the sub-block between
// BR elements.
var blockFunction = (goog.userAgent.GECKO &&
!this.isList_(root)) ?
goog.editor.plugins.FirstStrong.isGeckoBlock_ :
goog.editor.plugins.FirstStrong.isBlock_;
var paragraph = this.getTextAround_(root, cursor,
blockFunction);
// Not using {@code goog.i18n.bidi.isNeutralText} as it contains additional,
// unwanted checks to the content.
return !goog.i18n.bidi.hasAnyLtr(paragraph) &&
!goog.i18n.bidi.hasAnyRtl(paragraph);
};
/**
* Checks if an element is a list element ('UL' or 'OL').
*
* @param {Element} element The element to test.
* @return {boolean} Whether the element is a list element ('UL' or 'OL').
* @private
*/
goog.editor.plugins.FirstStrong.prototype.isList_ = function(element) {
if (!element) {
return false;
}
var tagName = element.tagName;
return tagName == goog.dom.TagName.UL || tagName == goog.dom.TagName.OL;
};
/**
* Returns the text within the local paragraph around the cursor.
* Notice that for GECKO a BR represents a pargraph change despite not being a
* block element.
*
* @param {Element} root The first block element ancestor of the node the cursor
* is in.
* @param {Node} cursorLocation Node where the cursor currently is, marking the
* paragraph whose text we will return.
* @param {function(Node): boolean} isParagraphBoundary The function to
* determine if a node represents the start or end of the paragraph.
* @return {string} the text in the paragraph around the cursor location.
* @private
*/
goog.editor.plugins.FirstStrong.prototype.getTextAround_ = function(root,
cursorLocation, isParagraphBoundary) {
// The buffer where we're collecting the text.
var buffer = [];
// Have we reached the cursor yet, or are we still before it?
var pastCursorLocation = false;
if (root && cursorLocation) {
goog.iter.some(new goog.dom.TagIterator(root), function(node) {
if (node == cursorLocation) {
pastCursorLocation = true;
} else if (isParagraphBoundary(node)) {
if (pastCursorLocation) {
// This is the end of the paragraph containing the cursor. We're done.
return true;
} else {
// All we collected so far does not count; it was in a previous
// paragraph that did not contain the cursor.
buffer = [];
}
}
if (node.nodeType == goog.dom.NodeType.TEXT) {
buffer.push(node.nodeValue);
}
return false; // Keep going.
});
}
return buffer.join('');
};
/**
* @param {Node} node Node to check.
* @return {boolean} Does the given node represent a Block element? Notice we do
* not consider list items as Block elements in the algorithm.
* @private
*/
goog.editor.plugins.FirstStrong.isBlock_ = function(node) {
return !!node && goog.editor.node.isBlockTag(node) &&
node.tagName != goog.dom.TagName.LI;
};
/**
* @param {Node} node Node to check.
* @return {boolean} Does the given node represent a Block element from the
* point of view of FireFox? Notice we do not consider list items as Block
* elements in the algorithm.
* @private
*/
goog.editor.plugins.FirstStrong.isGeckoBlock_ = function(node) {
return !!node && (node.tagName == goog.dom.TagName.BR ||
goog.editor.plugins.FirstStrong.isBlock_(node));
};
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<!--
Copyright 2012 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<html>
<head>
<meta charset="utf-8" />
<title>
Trogedit Unit Tests - goog.editor.plugins.FirstStrong
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.FirstStrongTest');
</script>
</head>
<body>
<div id="root">
<div id="field">
</div>
</div>
</body>
</html>
@@ -0,0 +1,406 @@
// Copyright 2012 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.FirstStrongTest');
goog.setTestOnly('goog.editor.plugins.FirstStrongTest');
goog.require('goog.dom.Range');
goog.require('goog.editor.Command');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.FirstStrong');
goog.require('goog.editor.range');
goog.require('goog.events.KeyCodes');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
// The key code for the Hebrew א, a strongly RTL letter.
var ALEPH_KEYCODE = 1488;
var field;
var fieldElement;
var dom;
var helper;
var triggeredCommand = null;
function setUp() {
field = new goog.editor.Field('field');
field.registerPlugin(new goog.editor.plugins.FirstStrong());
field.makeEditable();
fieldElement = field.getElement();
helper = new goog.testing.editor.TestHelper(fieldElement);
dom = field.getEditableDomHelper();
// Mock out execCommand to see if a direction change has been triggered.
field.execCommand = function(command) {
if (command == goog.editor.Command.DIR_LTR ||
command == goog.editor.Command.DIR_RTL)
triggeredCommand = command;
};
}
function tearDown() {
goog.dispose(field);
goog.dispose(helper);
triggeredCommand = null;
}
function testFirstCharacter_RTL() {
field.setHtml(false, '<div id="text">&nbsp;</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstCharacter_LTR() {
field.setHtml(false, '<div dir="rtl" id="text">&nbsp;</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
function testFirstStrongCharacter_RTL() {
field.setHtml(false, '<div id="text">123.7 3121, <b><++{}></b> - $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacter_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text">123.7 3121, <b><++{}></b> - $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
function testNotStrongCharacter_RTL() {
field.setHtml(false, '<div id="text">123.7 3121, - $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement, goog.events.KeyCodes.NINE);
assertNoCommand();
}
function testNotStrongCharacter_LTR() {
field.setHtml(false, '<div dir="rtl" id="text">123.7 3121 $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement, goog.events.KeyCodes.NINE);
assertNoCommand();
}
function testNotFirstStrongCharacter_RTL() {
field.setHtml(false, '<div id="text">123.7 3121, <b>English</b> - $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertNoCommand();
}
function testNotFirstStrongCharacter_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text">123.7 3121, <b>עברית</b> - $45</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertNoCommand();
}
function testFirstStrongCharacterWithInnerDiv_RTL() {
field.setHtml(false,
'<div id="text">123.7 3121, <b id="b"><++{}></b>' +
'<div id="inner">English</div>' +
'</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterWithInnerDiv_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text">123.7 3121, <b id="b"><++{}></b>' +
'<div id="inner">English</div>' +
'</div>');
field.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
/**
* Regression for {@link http://b/7549696}
*/
function testFirstStrongCharacterInNewLine_RTL() {
field.setHtml(false, '<div><b id="cur">English<br>1</b></div>');
goog.dom.Range.createCaret(dom.$('cur'), 2).select();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
// Only GECKO treats <br> as a new paragraph.
if (goog.userAgent.GECKO) {
assertRTL();
} else {
assertNoCommand();
}
}
function testFirstStrongCharacterInParagraph_RTL() {
field.setHtml(false,
'<div id="text1">1&gt; English</div>' +
'<div id="text2">2&gt;</div>' +
'<div id="text3">3&gt;</div>');
goog.dom.Range.createCaret(dom.$('text2'), 0).select();
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterInParagraph_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text1">1&gt; עברית</div>' +
'<div dir="rtl" id="text2">2&gt;</div>' +
'<div dir="rtl" id="text3">3&gt;</div>');
goog.dom.Range.createCaret(dom.$('text2'), 0).select();
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
function testFirstStrongCharacterInList_RTL() {
field.setHtml(false,
'<div id="text1">1&gt; English</div>' +
'<ul id="list">' +
'<li>10&gt;</li>' +
'<li id="li2"></li>' +
'<li>30</li>' +
'</ul>' +
'<div id="text3">3&gt;</div>');
goog.editor.range.placeCursorNextTo(dom.$('li2'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterInList_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text1">1&gt; English</div>' +
'<ul dir="rtl" id="list">' +
'<li>10&gt;</li>' +
'<li id="li2"></li>' +
'<li>30</li>' +
'</ul>' +
'<div dir="rtl" id="text3">3&gt;</div>');
goog.editor.range.placeCursorNextTo(dom.$('li2'), true);
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
function testNotFirstStrongCharacterInList_RTL() {
field.setHtml(false,
'<div id="text1">1</div>' +
'<ul id="list">' +
'<li>10&gt;</li>' +
'<li id="li2"></li>' +
'<li>30<b>3<i>Hidden English</i>32</b></li>' +
'</ul>' +
'<div id="text3">3&gt;</div>');
goog.editor.range.placeCursorNextTo(dom.$('li2'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertNoCommand();
}
function testNotFirstStrongCharacterInList_LTR() {
field.setHtml(false,
'<div dir="rtl" id="text1">1&gt; English</div>' +
'<ul dir="rtl" id="list">' +
'<li>10&gt;</li>' +
'<li id="li2"></li>' +
'<li>30<b>3<i>עברית סמויה</i>32</b></li>' +
'</ul>' +
'<div dir="rtl" id="text3">3&gt;</div>');
goog.editor.range.placeCursorNextTo(dom.$('li2'), true);
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertNoCommand();
}
function testFirstStrongCharacterWithBR_RTL() {
field.setHtml(false,
'<div id="container">' +
'<div id="text1">ABC</div>' +
'<div id="text2">' +
'1<br>' +
'2<b id="inner">3</b><i>4<u>5<br>' +
'6</u>7</i>8</b>9<br>' +
'10' +
'</div>' +
'<div id="text3">11</div>' +
'</div>');
goog.editor.range.placeCursorNextTo(dom.$('inner'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterWithBR_LTR() {
field.setHtml(false,
'<div dir="rtl" id="container">' +
'<div dir="rtl" id="text1">אבג</div>' +
'<div dir="rtl" id="text2">' +
'1<br>' +
'2<b id="inner">3</b><i>4<u>5<br>' +
'6</u>7</i>8</b>9<br>' +
'10' +
'</div>' +
'<div dir="rtl" id="text3">11</div>' +
'</div>');
goog.editor.range.placeCursorNextTo(dom.$('inner'), true);
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertLTR();
}
function testNotFirstStrongCharacterInBR_RTL() {
field.setHtml(false,
'<div id="container">' +
'<div id="text1">ABC</div>' +
'<div id="text2">' +
'1<br>' +
'2<b id="inner">3</b><i><em>4G</em><u>5<br>' +
'6</u>7</i>8</b>9<br>' +
'10' +
'</div>' +
'<div id="text3">11</div>' +
'</div>');
goog.editor.range.placeCursorNextTo(dom.$('inner'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertNoCommand();
}
function testNotFirstStrongCharacterInBR_LTR() {
field.setHtml(false,
'<div dir="rtl" id="container">' +
'<div dir="rtl" id="text1">ABC</div>' +
'<div dir="rtl" id="text2">' +
'1<br>' +
'2<b id="inner">3</b><i><em>4G</em><u>5<br>' +
'6</u>7</i>8</b>9<br>' +
'10' +
'</div>' +
'<div dir="rtl" id="text3">11</div>' +
'</div>');
goog.editor.range.placeCursorNextTo(dom.$('inner'), true);
goog.testing.events.fireKeySequence(fieldElement,
goog.events.KeyCodes.A);
assertNoCommand();
}
/**
* Regression for {@link http://b/7530985}
*/
function testFirstStrongCharacterWithPreviousBlockSibling_RTL() {
field.setHtml(false, '<div>Te<div>xt</div>1<b id="cur">2</b>3</div>');
goog.editor.range.placeCursorNextTo(dom.$('cur'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterWithPreviousBlockSibling_LTR() {
field.setHtml(
false, '<div dir="rtl">טק<div>סט</div>1<b id="cur">2</b>3</div>');
goog.editor.range.placeCursorNextTo(dom.$('cur'), true);
goog.testing.events.fireKeySequence(fieldElement, goog.events.KeyCodes.A);
assertLTR();
}
function testFirstStrongCharacterWithFollowingBlockSibling_RTL() {
field.setHtml(false, '<div>1<b id="cur">2</b>3<div>Te</div>xt</div>');
goog.editor.range.placeCursorNextTo(dom.$('cur'), true);
goog.testing.events.fireNonAsciiKeySequence(fieldElement,
goog.events.KeyCodes.T, ALEPH_KEYCODE);
assertRTL();
}
function testFirstStrongCharacterWithFollowingBlockSibling_RTL() {
field.setHtml(false, '<div dir="rtl">1<b id="cur">2</b>3<div>א</div>ב</div>');
goog.editor.range.placeCursorNextTo(dom.$('cur'), true);
goog.testing.events.fireKeySequence(fieldElement, goog.events.KeyCodes.A);
assertLTR();
}
function testFirstStrongCharacterFromIME_RTL() {
field.setHtml(false, '<div id="text">123.7 3121, </div>');
field.focusAndPlaceCursorAtStart();
var attributes = {};
attributes[goog.editor.plugins.FirstStrong.INPUT_ATTRIBUTE] = 'אבג';
goog.testing.events.fireNonAsciiKeySequence(fieldElement, 0, 0, attributes);
if (goog.userAgent.IE) {
// goog.testing.events.fireNonAsciiKeySequence doesn't send KEYPRESS event
// so no command is expected.
assertNoCommand();
} else {
assertRTL();
}
}
function testFirstCharacterFromIME_LTR() {
field.setHtml(false, '<div dir="rtl" id="text"> 1234 </div>');
field.focusAndPlaceCursorAtStart();
var attributes = {};
attributes[goog.editor.plugins.FirstStrong.INPUT_ATTRIBUTE] = 'ABC';
goog.testing.events.fireNonAsciiKeySequence(fieldElement, 0, 0, attributes);
if (goog.userAgent.IE) {
// goog.testing.events.fireNonAsciiKeySequence doesn't send KEYPRESS event
// so no command is expected.
assertNoCommand();
} else {
assertLTR();
}
}
function assertRTL() {
assertEquals(goog.editor.Command.DIR_RTL, triggeredCommand);
}
function assertLTR() {
assertEquals(goog.editor.Command.DIR_LTR, triggeredCommand);
}
function assertNoCommand() {
assertNull(triggeredCommand);
}
@@ -0,0 +1,96 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Handles applying header styles to text.
*
*/
goog.provide('goog.editor.plugins.HeaderFormatter');
goog.require('goog.editor.Command');
goog.require('goog.editor.Plugin');
goog.require('goog.userAgent');
/**
* Applies header styles to text.
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.HeaderFormatter = function() {
goog.editor.Plugin.call(this);
};
goog.inherits(goog.editor.plugins.HeaderFormatter, goog.editor.Plugin);
/** @override */
goog.editor.plugins.HeaderFormatter.prototype.getTrogClassId = function() {
return 'HeaderFormatter';
};
// TODO(user): Move execCommand functionality from basictextformatter into
// here for headers. I'm not doing this now because it depends on the
// switch statements in basictextformatter and we'll need to abstract that out
// in order to seperate out any of the functions from basictextformatter.
/**
* Commands that can be passed as the optional argument to execCommand.
* @enum {string}
*/
goog.editor.plugins.HeaderFormatter.HEADER_COMMAND = {
H1: 'H1',
H2: 'H2',
H3: 'H3',
H4: 'H4'
};
/**
* @override
*/
goog.editor.plugins.HeaderFormatter.prototype.handleKeyboardShortcut = function(
e, key, isModifierPressed) {
if (!isModifierPressed) {
return false;
}
var command = null;
switch (key) {
case '1':
command = goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H1;
break;
case '2':
command = goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H2;
break;
case '3':
command = goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H3;
break;
case '4':
command = goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H4;
break;
}
if (command) {
this.getFieldObject().execCommand(
goog.editor.Command.FORMAT_BLOCK, command);
// Prevent default isn't enough to cancel tab navigation in FF.
if (goog.userAgent.GECKO) {
e.stopPropagation();
}
return true;
}
return false;
};
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.HeaderFormatter Tests
</title>
<script type="text/javascript" src="../../base.js">
</script>
<script type="text/javascript">
goog.require('goog.editor.plugins.HeaderFormatterTest');
</script>
</head>
<body>
<div id="field">
</div>
</body>
</html>
@@ -0,0 +1,95 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.HeaderFormatterTest');
goog.setTestOnly('goog.editor.plugins.HeaderFormatterTest');
goog.require('goog.dom');
goog.require('goog.editor.Command');
goog.require('goog.editor.plugins.BasicTextFormatter');
goog.require('goog.editor.plugins.HeaderFormatter');
goog.require('goog.events.BrowserEvent');
goog.require('goog.testing.LooseMock');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var field;
var editableField;
var headerFormatter;
var btf;
var testHelper;
function setUpPage() {
field = goog.dom.getElement('field');
testHelper = new goog.testing.editor.TestHelper(field);
}
function setUp() {
testHelper.setUpEditableElement();
editableField = new goog.testing.editor.FieldMock();
headerFormatter = new goog.editor.plugins.HeaderFormatter();
headerFormatter.registerFieldObject(editableField);
btf = new goog.editor.plugins.BasicTextFormatter();
btf.registerFieldObject(editableField);
}
function tearDown() {
editableField = null;
headerFormatter.dispose();
testHelper.tearDownEditableElement();
}
function testHeaderShortcuts() {
field.innerHTML = 'myText';
var textNode = field.firstChild;
testHelper.select(textNode, 0, textNode, textNode.length);
editableField.getElement();
editableField.$anyTimes();
editableField.$returns(field);
editableField.getPluginByClassId('Bidi');
editableField.$anyTimes();
editableField.$returns(null);
editableField.execCommand(
goog.editor.Command.FORMAT_BLOCK,
goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H1);
// Bypass EditableField's execCommand and directly call
// basicTextFormatter's. Future version of headerformatter will include
// that code in its own execCommand.
editableField.$does(function() {
btf.execCommandInternal(
goog.editor.plugins.BasicTextFormatter.COMMAND.FORMAT_BLOCK,
goog.editor.plugins.HeaderFormatter.HEADER_COMMAND.H1); });
var event = new goog.testing.LooseMock(goog.events.BrowserEvent);
if (goog.userAgent.GECKO) {
event.stopPropagation();
}
editableField.$replay();
event.$replay();
assertTrue('Event handled',
headerFormatter.handleKeyboardShortcut(event, '1', true));
assertEquals('Field contains a header', 'H1', field.firstChild.nodeName);
editableField.$verify();
event.$verify();
}
@@ -0,0 +1,585 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Base class for bubble plugins.
*
*/
goog.provide('goog.editor.plugins.LinkBubble');
goog.provide('goog.editor.plugins.LinkBubble.Action');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Command');
goog.require('goog.editor.Link');
goog.require('goog.editor.plugins.AbstractBubblePlugin');
goog.require('goog.editor.range');
goog.require('goog.functions');
goog.require('goog.string');
goog.require('goog.style');
goog.require('goog.ui.editor.messages');
goog.require('goog.uri.utils');
goog.require('goog.window');
/**
* Property bubble plugin for links.
* @param {...!goog.editor.plugins.LinkBubble.Action} var_args List of
* extra actions supported by the bubble.
* @constructor
* @extends {goog.editor.plugins.AbstractBubblePlugin}
*/
goog.editor.plugins.LinkBubble = function(var_args) {
goog.editor.plugins.LinkBubble.base(this, 'constructor');
/**
* List of extra actions supported by the bubble.
* @type {Array<!goog.editor.plugins.LinkBubble.Action>}
* @private
*/
this.extraActions_ = goog.array.toArray(arguments);
/**
* List of spans corresponding to the extra actions.
* @type {Array<!Element>}
* @private
*/
this.actionSpans_ = [];
/**
* A list of whitelisted URL schemes which are safe to open.
* @type {Array<string>}
* @private
*/
this.safeToOpenSchemes_ = ['http', 'https', 'ftp'];
};
goog.inherits(goog.editor.plugins.LinkBubble,
goog.editor.plugins.AbstractBubblePlugin);
/**
* Element id for the link text.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.LINK_TEXT_ID_ = 'tr_link-text';
/**
* Element id for the test link span.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_ = 'tr_test-link-span';
/**
* Element id for the test link.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.TEST_LINK_ID_ = 'tr_test-link';
/**
* Element id for the change link span.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.CHANGE_LINK_SPAN_ID_ = 'tr_change-link-span';
/**
* Element id for the link.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.CHANGE_LINK_ID_ = 'tr_change-link';
/**
* Element id for the delete link span.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.DELETE_LINK_SPAN_ID_ = 'tr_delete-link-span';
/**
* Element id for the delete link.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.DELETE_LINK_ID_ = 'tr_delete-link';
/**
* Element id for the link bubble wrapper div.
* type {string}
* @private
*/
goog.editor.plugins.LinkBubble.LINK_DIV_ID_ = 'tr_link-div';
/**
* @desc Text label for link that lets the user click it to see where the link
* this bubble is for point to.
*/
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_TEST_LINK = goog.getMsg(
'Go to link: ');
/**
* @desc Label that pops up a dialog to change the link.
*/
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_CHANGE = goog.getMsg(
'Change');
/**
* @desc Label that allow the user to remove this link.
*/
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_REMOVE = goog.getMsg(
'Remove');
/**
* @desc Message shown in a link bubble when the link is not a valid url.
*/
goog.editor.plugins.LinkBubble.MSG_INVALID_URL_LINK_BUBBLE = goog.getMsg(
'invalid url');
/**
* Whether to stop leaking the page's url via the referrer header when the
* link text link is clicked.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkBubble.prototype.stopReferrerLeaks_ = false;
/**
* Whether to block opening links with a non-whitelisted URL scheme.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkBubble.prototype.blockOpeningUnsafeSchemes_ =
true;
/**
* Tells the plugin to stop leaking the page's url via the referrer header when
* the link text link is clicked. When the user clicks on a link, the
* browser makes a request for the link url, passing the url of the current page
* in the request headers. If the user wants the current url to be kept secret
* (e.g. an unpublished document), the owner of the url that was clicked will
* see the secret url in the request headers, and it will no longer be a secret.
* Calling this method will not send a referrer header in the request, just as
* if the user had opened a blank window and typed the url in themselves.
*/
goog.editor.plugins.LinkBubble.prototype.stopReferrerLeaks = function() {
// TODO(user): Right now only 2 plugins have this API to stop
// referrer leaks. If more plugins need to do this, come up with a way to
// enable the functionality in all plugins at once. Same thing for
// setBlockOpeningUnsafeSchemes and associated functionality.
this.stopReferrerLeaks_ = true;
};
/**
* Tells the plugin whether to block URLs with schemes not in the whitelist.
* If blocking is enabled, this plugin will not linkify the link in the bubble
* popup.
* @param {boolean} blockOpeningUnsafeSchemes Whether to block non-whitelisted
* schemes.
*/
goog.editor.plugins.LinkBubble.prototype.setBlockOpeningUnsafeSchemes =
function(blockOpeningUnsafeSchemes) {
this.blockOpeningUnsafeSchemes_ = blockOpeningUnsafeSchemes;
};
/**
* Sets a whitelist of allowed URL schemes that are safe to open.
* Schemes should all be in lowercase. If the plugin is set to block opening
* unsafe schemes, user-entered URLs will be converted to lowercase and checked
* against this list. The whitelist has no effect if blocking is not enabled.
* @param {Array<string>} schemes String array of URL schemes to allow (http,
* https, etc.).
*/
goog.editor.plugins.LinkBubble.prototype.setSafeToOpenSchemes =
function(schemes) {
this.safeToOpenSchemes_ = schemes;
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.getTrogClassId = function() {
return 'LinkBubble';
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.isSupportedCommand =
function(command) {
return command == goog.editor.Command.UPDATE_LINK_BUBBLE;
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.execCommandInternal =
function(command, var_args) {
if (command == goog.editor.Command.UPDATE_LINK_BUBBLE) {
this.updateLink_();
}
};
/**
* Updates the href in the link bubble with a new link.
* @private
*/
goog.editor.plugins.LinkBubble.prototype.updateLink_ = function() {
var targetEl = this.getTargetElement();
if (targetEl) {
this.closeBubble();
this.createBubble(targetEl);
}
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.getBubbleTargetFromSelection =
function(selectedElement) {
var bubbleTarget = goog.dom.getAncestorByTagNameAndClass(selectedElement,
goog.dom.TagName.A);
if (!bubbleTarget) {
// See if the selection is touching the right side of a link, and if so,
// show a bubble for that link. The check for "touching" is very brittle,
// and currently only guarantees that it will pop up a bubble at the
// position the cursor is placed at after the link dialog is closed.
// NOTE(robbyw): This assumes this method is always called with
// selected element = range.getContainerElement(). Right now this is true,
// but attempts to re-use this method for other purposes could cause issues.
// TODO(robbyw): Refactor this method to also take a range, and use that.
var range = this.getFieldObject().getRange();
if (range && range.isCollapsed() && range.getStartOffset() == 0) {
var startNode = range.getStartNode();
var previous = startNode.previousSibling;
if (previous && previous.tagName == goog.dom.TagName.A) {
bubbleTarget = previous;
}
}
}
return /** @type {Element} */ (bubbleTarget);
};
/**
* Set the optional function for getting the "test" link of a url.
* @param {function(string) : string} func The function to use.
*/
goog.editor.plugins.LinkBubble.prototype.setTestLinkUrlFn = function(func) {
this.testLinkUrlFn_ = func;
};
/**
* Returns the target element url for the bubble.
* @return {string} The url href.
* @protected
*/
goog.editor.plugins.LinkBubble.prototype.getTargetUrl = function() {
// Get the href-attribute through getAttribute() rather than the href property
// because Google-Toolbar on Firefox with "Send with Gmail" turned on
// modifies the href-property of 'mailto:' links but leaves the attribute
// untouched.
return this.getTargetElement().getAttribute('href') || '';
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.getBubbleType = function() {
return goog.dom.TagName.A;
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.getBubbleTitle = function() {
return goog.ui.editor.messages.MSG_LINK_CAPTION;
};
/**
* Returns the message to display for testing a link.
* @return {string} The message for testing a link.
* @protected
*/
goog.editor.plugins.LinkBubble.prototype.getTestLinkMessage = function() {
return goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_TEST_LINK;
};
/** @override */
goog.editor.plugins.LinkBubble.prototype.createBubbleContents = function(
bubbleContainer) {
var linkObj = this.getLinkToTextObj_();
// Create linkTextSpan, show plain text for e-mail address or truncate the
// text to <= 48 characters so that property bubbles don't grow too wide and
// create a link if URL. Only linkify valid links.
// TODO(robbyw): Repalce this color with a CSS class.
var color = linkObj.valid ? 'black' : 'red';
var shouldOpenUrl = this.shouldOpenUrl(linkObj.linkText);
var linkTextSpan;
if (goog.editor.Link.isLikelyEmailAddress(linkObj.linkText) ||
!linkObj.valid || !shouldOpenUrl) {
linkTextSpan = this.dom_.createDom(goog.dom.TagName.SPAN,
{
id: goog.editor.plugins.LinkBubble.LINK_TEXT_ID_,
style: 'color:' + color
}, this.dom_.createTextNode(linkObj.linkText));
} else {
var testMsgSpan = this.dom_.createDom(goog.dom.TagName.SPAN,
{id: goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_},
this.getTestLinkMessage());
linkTextSpan = this.dom_.createDom(goog.dom.TagName.SPAN,
{
id: goog.editor.plugins.LinkBubble.LINK_TEXT_ID_,
style: 'color:' + color
}, '');
var linkText = goog.string.truncateMiddle(linkObj.linkText, 48);
// Actually creates a pseudo-link that can't be right-clicked to open in a
// new tab, because that would avoid the logic to stop referrer leaks.
this.createLink(goog.editor.plugins.LinkBubble.TEST_LINK_ID_,
this.dom_.createTextNode(linkText).data,
this.testLink,
linkTextSpan);
}
var changeLinkSpan = this.createLinkOption(
goog.editor.plugins.LinkBubble.CHANGE_LINK_SPAN_ID_);
this.createLink(goog.editor.plugins.LinkBubble.CHANGE_LINK_ID_,
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_CHANGE,
this.showLinkDialog_, changeLinkSpan);
// This function is called multiple times - we have to reset the array.
this.actionSpans_ = [];
for (var i = 0; i < this.extraActions_.length; i++) {
var action = this.extraActions_[i];
var actionSpan = this.createLinkOption(action.spanId_);
this.actionSpans_.push(actionSpan);
this.createLink(action.linkId_, action.message_,
function() {
action.actionFn_(this.getTargetUrl());
},
actionSpan);
}
var removeLinkSpan = this.createLinkOption(
goog.editor.plugins.LinkBubble.DELETE_LINK_SPAN_ID_);
this.createLink(goog.editor.plugins.LinkBubble.DELETE_LINK_ID_,
goog.editor.plugins.LinkBubble.MSG_LINK_BUBBLE_REMOVE,
this.deleteLink_, removeLinkSpan);
this.onShow();
var bubbleContents = this.dom_.createDom(goog.dom.TagName.DIV,
{id: goog.editor.plugins.LinkBubble.LINK_DIV_ID_},
testMsgSpan || '', linkTextSpan, changeLinkSpan);
for (i = 0; i < this.actionSpans_.length; i++) {
bubbleContents.appendChild(this.actionSpans_[i]);
}
bubbleContents.appendChild(removeLinkSpan);
goog.dom.appendChild(bubbleContainer, bubbleContents);
};
/**
* Tests the link by opening it in a new tab/window. Should be used as the
* click event handler for the test pseudo-link.
* @protected
*/
goog.editor.plugins.LinkBubble.prototype.testLink = function() {
goog.window.open(this.getTestLinkAction_(),
{
'target': '_blank',
'noreferrer': this.stopReferrerLeaks_
}, this.getFieldObject().getAppWindow());
};
/**
* Returns whether the URL should be considered invalid. This always returns
* false in the base class, and should be overridden by subclasses that wish
* to impose validity rules on URLs.
* @param {string} url The url to check.
* @return {boolean} Whether the URL should be considered invalid.
*/
goog.editor.plugins.LinkBubble.prototype.isInvalidUrl = goog.functions.FALSE;
/**
* Gets the text to display for a link, based on the type of link
* @return {!Object} Returns an object of the form:
* {linkText: displayTextForLinkTarget, valid: ifTheLinkIsValid}.
* @private
*/
goog.editor.plugins.LinkBubble.prototype.getLinkToTextObj_ = function() {
var isError;
var targetUrl = this.getTargetUrl();
if (this.isInvalidUrl(targetUrl)) {
targetUrl = goog.editor.plugins.LinkBubble.MSG_INVALID_URL_LINK_BUBBLE;
isError = true;
} else if (goog.editor.Link.isMailto(targetUrl)) {
targetUrl = targetUrl.substring(7); // 7 == "mailto:".length
}
return {linkText: targetUrl, valid: !isError};
};
/**
* Shows the link dialog.
* @param {goog.events.BrowserEvent} e The event.
* @private
*/
goog.editor.plugins.LinkBubble.prototype.showLinkDialog_ = function(e) {
// Needed when this occurs due to an ENTER key event, else the newly created
// dialog manages to have its OK button pressed, causing it to disappear.
e.preventDefault();
this.getFieldObject().execCommand(goog.editor.Command.MODAL_LINK_EDITOR,
new goog.editor.Link(
/** @type {HTMLAnchorElement} */ (this.getTargetElement()),
false));
this.closeBubble();
};
/**
* Deletes the link associated with the bubble
* @private
*/
goog.editor.plugins.LinkBubble.prototype.deleteLink_ = function() {
this.getFieldObject().dispatchBeforeChange();
var link = this.getTargetElement();
var child = link.lastChild;
goog.dom.flattenElement(link);
goog.editor.range.placeCursorNextTo(child, false);
this.closeBubble();
this.getFieldObject().dispatchChange();
this.getFieldObject().focus();
};
/**
* Sets the proper state for the action links.
* @protected
* @override
*/
goog.editor.plugins.LinkBubble.prototype.onShow = function() {
var linkDiv = this.dom_.getElement(
goog.editor.plugins.LinkBubble.LINK_DIV_ID_);
if (linkDiv) {
var testLinkSpan = this.dom_.getElement(
goog.editor.plugins.LinkBubble.TEST_LINK_SPAN_ID_);
if (testLinkSpan) {
var url = this.getTargetUrl();
goog.style.setElementShown(testLinkSpan, !goog.editor.Link.isMailto(url));
}
for (var i = 0; i < this.extraActions_.length; i++) {
var action = this.extraActions_[i];
var actionSpan = this.dom_.getElement(action.spanId_);
if (actionSpan) {
goog.style.setElementShown(actionSpan, action.toShowFn_(
this.getTargetUrl()));
}
}
}
};
/**
* Gets the url for the bubble test link. The test link is the link in the
* bubble the user can click on to make sure the link they entered is correct.
* @return {string} The url for the bubble link href.
* @private
*/
goog.editor.plugins.LinkBubble.prototype.getTestLinkAction_ = function() {
var targetUrl = this.getTargetUrl();
return this.testLinkUrlFn_ ? this.testLinkUrlFn_(targetUrl) : targetUrl;
};
/**
* Checks whether the plugin should open the given url in a new window.
* @param {string} url The url to check.
* @return {boolean} If the plugin should open the given url in a new window.
* @protected
*/
goog.editor.plugins.LinkBubble.prototype.shouldOpenUrl = function(url) {
return !this.blockOpeningUnsafeSchemes_ || this.isSafeSchemeToOpen_(url);
};
/**
* Determines whether or not a url has a scheme which is safe to open.
* Schemes like javascript are unsafe due to the possibility of XSS.
* @param {string} url A url.
* @return {boolean} Whether the url has a safe scheme.
* @private
*/
goog.editor.plugins.LinkBubble.prototype.isSafeSchemeToOpen_ =
function(url) {
var scheme = goog.uri.utils.getScheme(url) || 'http';
return goog.array.contains(this.safeToOpenSchemes_, scheme.toLowerCase());
};
/**
* Constructor for extra actions that can be added to the link bubble.
* @param {string} spanId The ID for the span showing the action.
* @param {string} linkId The ID for the link showing the action.
* @param {string} message The text for the link showing the action.
* @param {function(string):boolean} toShowFn Test function to determine whether
* to show the action for the given URL.
* @param {function(string):void} actionFn Action function to run when the
* action is clicked. Takes the current target URL as a parameter.
* @constructor
* @final
*/
goog.editor.plugins.LinkBubble.Action = function(spanId, linkId, message,
toShowFn, actionFn) {
this.spanId_ = spanId;
this.linkId_ = linkId;
this.message_ = message;
this.toShowFn_ = toShowFn;
this.actionFn_ = actionFn;
};
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
@author tildahl@google.com (Michael Tildahl)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.LinkBubble Tests
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.LinkBubbleTest');
</script>
</head>
<body>
<div id="field"><a href="http://www.google.com/">Google</a></div>
</body>
</html>
@@ -0,0 +1,396 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.LinkBubbleTest');
goog.setTestOnly('goog.editor.plugins.LinkBubbleTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Command');
goog.require('goog.editor.Link');
goog.require('goog.editor.plugins.LinkBubble');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.Event');
goog.require('goog.events.EventType');
goog.require('goog.string');
goog.require('goog.style');
goog.require('goog.testing.FunctionMock');
goog.require('goog.testing.PropertyReplacer');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var fieldDiv;
var FIELDMOCK;
var linkBubble;
var link;
var mockWindowOpen;
var stubs;
var testHelper;
function setUpPage() {
fieldDiv = goog.dom.$('field');
stubs = new goog.testing.PropertyReplacer();
testHelper = new goog.testing.editor.TestHelper(goog.dom.getElement('field'));
}
function setUp() {
testHelper.setUpEditableElement();
FIELDMOCK = new goog.testing.editor.FieldMock();
linkBubble = new goog.editor.plugins.LinkBubble();
linkBubble.fieldObject = FIELDMOCK;
link = fieldDiv.firstChild;
mockWindowOpen = new goog.testing.FunctionMock('open');
stubs.set(window, 'open', mockWindowOpen);
}
function tearDown() {
linkBubble.closeBubble();
testHelper.tearDownEditableElement();
stubs.reset();
}
function testLinkSelected() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
goog.dom.Range.createFromNodeContents(link).select();
linkBubble.handleSelectionChange();
assertBubble();
FIELDMOCK.$verify();
}
function testLinkClicked() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
FIELDMOCK.$verify();
}
function testImageLink() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
link.setAttribute('imageanchor', 1);
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
FIELDMOCK.$verify();
}
function closeBox() {
var closeBox = goog.dom.getElementsByTagNameAndClass(goog.dom.TagName.DIV,
'tr_bubble_closebox');
assertEquals('Should find only one close box', 1, closeBox.length);
assertNotNull('Found close box', closeBox[0]);
goog.testing.events.fireClickSequence(closeBox[0]);
}
function testCloseBox() {
testLinkClicked();
closeBox();
assertNoBubble();
FIELDMOCK.$verify();
}
function testChangeClicked() {
FIELDMOCK.execCommand(goog.editor.Command.MODAL_LINK_EDITOR,
new goog.editor.Link(link, false));
FIELDMOCK.$registerArgumentListVerifier('execCommand', function(arr1, arr2) {
return arr1.length == arr2.length &&
arr1.length == 2 &&
arr1[0] == goog.editor.Command.MODAL_LINK_EDITOR &&
arr2[0] == goog.editor.Command.MODAL_LINK_EDITOR &&
arr1[1] instanceof goog.editor.Link &&
arr2[1] instanceof goog.editor.Link;
});
FIELDMOCK.$times(1);
FIELDMOCK.$returns(true);
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
goog.testing.events.fireClickSequence(
goog.dom.$(goog.editor.plugins.LinkBubble.CHANGE_LINK_ID_));
assertNoBubble();
FIELDMOCK.$verify();
}
function testDeleteClicked() {
FIELDMOCK.dispatchBeforeChange();
FIELDMOCK.$times(1);
FIELDMOCK.dispatchChange();
FIELDMOCK.$times(1);
FIELDMOCK.focus();
FIELDMOCK.$times(1);
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
goog.testing.events.fireClickSequence(
goog.dom.$(goog.editor.plugins.LinkBubble.DELETE_LINK_ID_));
var element = goog.userAgent.GECKO ? document.body : fieldDiv;
assertNotEquals('Link removed', element.firstChild.nodeName,
goog.dom.TagName.A);
assertNoBubble();
FIELDMOCK.$verify();
}
function testActionClicked() {
var SPAN = 'actionSpanId';
var LINK = 'actionLinkId';
var toShowCount = 0;
var actionCount = 0;
var linkAction = new goog.editor.plugins.LinkBubble.Action(
SPAN, LINK, 'message',
function() {
toShowCount++;
return toShowCount == 1; // Show it the first time.
},
function() {
actionCount++;
});
linkBubble = new goog.editor.plugins.LinkBubble(linkAction);
linkBubble.fieldObject = FIELDMOCK;
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
// The first time the bubble is shown, show our custom action.
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
assertEquals('Should check showing the action', 1, toShowCount);
assertEquals('Action should not have fired yet', 0, actionCount);
assertTrue('Action should be visible 1st time', goog.style.isElementShown(
goog.dom.$(SPAN)));
goog.testing.events.fireClickSequence(goog.dom.$(LINK));
assertEquals('Should not check showing again yet', 1, toShowCount);
assertEquals('Action should be fired', 1, actionCount);
closeBox();
assertNoBubble();
// The action won't be shown the second time around.
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
assertEquals('Should check showing again', 2, toShowCount);
assertEquals('Action should not fire again', 1, actionCount);
assertFalse('Action should not be shown 2nd time', goog.style.isElementShown(
goog.dom.$(SPAN)));
FIELDMOCK.$verify();
}
function testLinkTextClicked() {
mockWindowOpen('http://www.google.com/', '_blank', '');
mockWindowOpen.$replay();
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
goog.testing.events.fireClickSequence(
goog.dom.$(goog.editor.plugins.LinkBubble.TEST_LINK_ID_));
assertBubble();
mockWindowOpen.$verify();
FIELDMOCK.$verify();
}
function testLinkTextClickedCustomUrlFn() {
mockWindowOpen('http://images.google.com/', '_blank', '');
mockWindowOpen.$replay();
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
linkBubble.setTestLinkUrlFn(function(url) {
return url.replace('www', 'images');
});
linkBubble.handleSelectionChange(createMouseEvent(link));
assertBubble();
goog.testing.events.fireClickSequence(
goog.dom.$(goog.editor.plugins.LinkBubble.TEST_LINK_ID_));
assertBubble();
mockWindowOpen.$verify();
FIELDMOCK.$verify();
}
/**
* Urls with invalid schemes shouldn't be linkified.
* @bug 2585360
*/
function testDontLinkifyInvalidScheme() {
mockWindowOpen.$replay();
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
var badLink = document.createElement('a');
badLink.href = 'javascript:alert(1)';
badLink.innerHTML = 'bad link';
linkBubble.handleSelectionChange(createMouseEvent(badLink));
assertBubble();
// The link shouldn't exist at all
assertNull(goog.dom.$(goog.editor.plugins.LinkBubble.TEST_LINK_ID_));
assertBubble();
mockWindowOpen.$verify();
FIELDMOCK.$verify();
}
function testIsSafeSchemeToOpen() {
// Urls with no scheme at all are ok too since 'http://' will be prepended.
var good = [
'http://google.com', 'http://google.com/', 'https://google.com',
'null@google.com', 'http://www.google.com', 'http://site.com',
'google.com', 'google', 'http://google', 'HTTP://GOOGLE.COM',
'HtTp://www.google.com'
];
var bad = [
'javascript:google.com', 'httpp://google.com', 'data:foo',
'javascript:alert(\'hi\');', 'abc:def'
];
for (var i = 0; i < good.length; i++) {
assertTrue(good[i] + ' should have a safe scheme',
linkBubble.isSafeSchemeToOpen_(good[i]));
}
for (i = 0; i < bad.length; i++) {
assertFalse(bad[i] + ' should have an unsafe scheme',
linkBubble.isSafeSchemeToOpen_(bad[i]));
}
}
function testShouldOpenWithWhitelist() {
linkBubble.setSafeToOpenSchemes(['abc']);
assertTrue('Scheme should be safe',
linkBubble.shouldOpenUrl('abc://google.com'));
assertFalse('Scheme should be unsafe',
linkBubble.shouldOpenUrl('http://google.com'));
linkBubble.setBlockOpeningUnsafeSchemes(false);
assertTrue('Non-whitelisted should now be safe after disabling blocking',
linkBubble.shouldOpenUrl('http://google.com'));
}
/**
* @bug 763211
* @bug 2182147
*/
function testLongUrlTestLinkAnchorTextCorrect() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
var longUrl = 'http://www.reallylonglinkthatshouldbetruncated' +
'becauseitistoolong.com';
var truncatedLongUrl = goog.string.truncateMiddle(longUrl, 48);
var longLink = document.createElement('a');
longLink.href = longUrl;
longLink.innerHTML = 'Google';
fieldDiv.appendChild(longLink);
linkBubble.handleSelectionChange(createMouseEvent(longLink));
assertBubble();
var testLinkEl = goog.dom.$(goog.editor.plugins.LinkBubble.TEST_LINK_ID_);
assertEquals(
'The test link\'s anchor text should be the truncated URL.',
truncatedLongUrl,
testLinkEl.innerHTML);
fieldDiv.removeChild(longLink);
FIELDMOCK.$verify();
}
/**
* @bug 2416024
*/
function testOverridingCreateBubbleContentsDoesntNpeGetTargetUrl() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
stubs.set(linkBubble, 'createBubbleContents',
function(elem) {
// getTargetUrl would cause an NPE if urlUtil_ wasn't defined yet.
linkBubble.getTargetUrl();
});
assertNotThrows('Accessing this.urlUtil_ should not NPE',
goog.bind(linkBubble.handleSelectionChange,
linkBubble, createMouseEvent(link)));
FIELDMOCK.$verify();
}
/**
* @bug 15379294
*/
function testUpdateLinkCommandDoesNotTriggerAnException() {
FIELDMOCK.$replay();
linkBubble.enable(FIELDMOCK);
// At this point, the bubble was not created yet using its createBubble
// public method.
assertNotThrows(
'Executing goog.editor.Command.UPDATE_LINK_BUBBLE should not trigger ' +
'an exception even if the bubble was not created yet using its ' +
'createBubble method.',
goog.bind(linkBubble.execCommandInternal, linkBubble,
goog.editor.Command.UPDATE_LINK_BUBBLE));
FIELDMOCK.$verify();
}
function assertBubble() {
assertTrue('Link bubble visible', linkBubble.isVisible());
assertNotNull('Link bubble created',
goog.dom.$(goog.editor.plugins.LinkBubble.LINK_DIV_ID_));
}
function assertNoBubble() {
assertFalse('Link bubble not visible', linkBubble.isVisible());
assertNull('Link bubble not created',
goog.dom.$(goog.editor.plugins.LinkBubble.LINK_DIV_ID_));
}
function createMouseEvent(target) {
var eventObj = new goog.events.Event(goog.events.EventType.MOUSEUP, target);
eventObj.button = goog.events.BrowserEvent.MouseButton.LEFT;
return new goog.events.BrowserEvent(eventObj, target);
}
@@ -0,0 +1,438 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview A plugin for the LinkDialog.
*
* @author nicksantos@google.com (Nick Santos)
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.LinkDialogPlugin');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.editor.Command');
goog.require('goog.editor.plugins.AbstractDialogPlugin');
goog.require('goog.events.EventHandler');
goog.require('goog.functions');
goog.require('goog.ui.editor.AbstractDialog');
goog.require('goog.ui.editor.LinkDialog');
goog.require('goog.uri.utils');
/**
* A plugin that opens the link dialog.
* @constructor
* @extends {goog.editor.plugins.AbstractDialogPlugin}
*/
goog.editor.plugins.LinkDialogPlugin = function() {
goog.editor.plugins.LinkDialogPlugin.base(
this, 'constructor', goog.editor.Command.MODAL_LINK_EDITOR);
/**
* Event handler for this object.
* @type {goog.events.EventHandler<!goog.editor.plugins.LinkDialogPlugin>}
* @private
*/
this.eventHandler_ = new goog.events.EventHandler(this);
/**
* A list of whitelisted URL schemes which are safe to open.
* @type {Array<string>}
* @private
*/
this.safeToOpenSchemes_ = ['http', 'https', 'ftp'];
};
goog.inherits(goog.editor.plugins.LinkDialogPlugin,
goog.editor.plugins.AbstractDialogPlugin);
/**
* Link object that the dialog is editing.
* @type {goog.editor.Link}
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.currentLink_;
/**
* Optional warning to show about email addresses.
* @type {goog.html.SafeHtml}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.emailWarning_;
/**
* Whether to show a checkbox where the user can choose to have the link open in
* a new window.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.showOpenLinkInNewWindow_ = false;
/**
* Whether the "open link in new window" checkbox should be checked when the
* dialog is shown, and also whether it was checked last time the dialog was
* closed.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.isOpenLinkInNewWindowChecked_ =
false;
/**
* Weather to show a checkbox where the user can choose to add 'rel=nofollow'
* attribute added to the link.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.showRelNoFollow_ = false;
/**
* Whether to stop referrer leaks. Defaults to false.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.stopReferrerLeaks_ = false;
/**
* Whether to block opening links with a non-whitelisted URL scheme.
* @type {boolean}
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.blockOpeningUnsafeSchemes_ =
true;
/** @override */
goog.editor.plugins.LinkDialogPlugin.prototype.getTrogClassId =
goog.functions.constant('LinkDialogPlugin');
/**
* Tells the plugin whether to block URLs with schemes not in the whitelist.
* If blocking is enabled, this plugin will stop the 'Test Link' popup
* window from being created. Blocking doesn't affect link creation--if the
* user clicks the 'OK' button with an unsafe URL, the link will still be
* created as normal.
* @param {boolean} blockOpeningUnsafeSchemes Whether to block non-whitelisted
* schemes.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.setBlockOpeningUnsafeSchemes =
function(blockOpeningUnsafeSchemes) {
this.blockOpeningUnsafeSchemes_ = blockOpeningUnsafeSchemes;
};
/**
* Sets a whitelist of allowed URL schemes that are safe to open.
* Schemes should all be in lowercase. If the plugin is set to block opening
* unsafe schemes, user-entered URLs will be converted to lowercase and checked
* against this list. The whitelist has no effect if blocking is not enabled.
* @param {Array<string>} schemes String array of URL schemes to allow (http,
* https, etc.).
*/
goog.editor.plugins.LinkDialogPlugin.prototype.setSafeToOpenSchemes =
function(schemes) {
this.safeToOpenSchemes_ = schemes;
};
/**
* Tells the dialog to show a checkbox where the user can choose to have the
* link open in a new window.
* @param {boolean} startChecked Whether to check the checkbox the first
* time the dialog is shown. Subesquent times the checkbox will remember its
* previous state.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.showOpenLinkInNewWindow =
function(startChecked) {
this.showOpenLinkInNewWindow_ = true;
this.isOpenLinkInNewWindowChecked_ = startChecked;
};
/**
* Tells the dialog to show a checkbox where the user can choose to have
* 'rel=nofollow' attribute added to the link.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.showRelNoFollow = function() {
this.showRelNoFollow_ = true;
};
/**
* Returns whether the"open link in new window" checkbox was checked last time
* the dialog was closed.
* @return {boolean} Whether the"open link in new window" checkbox was checked
* last time the dialog was closed.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.
getOpenLinkInNewWindowCheckedState = function() {
return this.isOpenLinkInNewWindowChecked_;
};
/**
* Tells the plugin to stop leaking the page's url via the referrer header when
* the "test this link" link is clicked. When the user clicks on a link, the
* browser makes a request for the link url, passing the url of the current page
* in the request headers. If the user wants the current url to be kept secret
* (e.g. an unpublished document), the owner of the url that was clicked will
* see the secret url in the request headers, and it will no longer be a secret.
* Calling this method will not send a referrer header in the request, just as
* if the user had opened a blank window and typed the url in themselves.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.stopReferrerLeaks = function() {
this.stopReferrerLeaks_ = true;
};
/**
* Sets the warning message to show to users about including email addresses on
* public web pages.
* @param {!goog.html.SafeHtml} emailWarning Warning message to show users about
* including email addresses on the web.
*/
goog.editor.plugins.LinkDialogPlugin.prototype.setEmailWarning = function(
emailWarning) {
this.emailWarning_ = emailWarning;
};
/**
* Handles execCommand by opening the dialog.
* @param {string} command The command to execute.
* @param {*=} opt_arg {@link A goog.editor.Link} object representing the link
* being edited.
* @return {*} Always returns true, indicating the dialog was shown.
* @protected
* @override
*/
goog.editor.plugins.LinkDialogPlugin.prototype.execCommandInternal = function(
command, opt_arg) {
this.currentLink_ = /** @type {goog.editor.Link} */(opt_arg);
return goog.editor.plugins.LinkDialogPlugin.base(
this, 'execCommandInternal', command, opt_arg);
};
/**
* Handles when the dialog closes.
* @param {goog.events.Event} e The AFTER_HIDE event object.
* @override
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.handleAfterHide = function(e) {
goog.editor.plugins.LinkDialogPlugin.base(this, 'handleAfterHide', e);
this.currentLink_ = null;
};
/**
* @return {goog.events.EventHandler<T>} The event handler.
* @protected
* @this T
* @template T
*/
goog.editor.plugins.LinkDialogPlugin.prototype.getEventHandler = function() {
return this.eventHandler_;
};
/**
* @return {goog.editor.Link} The link being edited.
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.getCurrentLink = function() {
return this.currentLink_;
};
/**
* Creates a new instance of the dialog and registers for the relevant events.
* @param {goog.dom.DomHelper} dialogDomHelper The dom helper to be used to
* create the dialog.
* @param {*=} opt_link The target link (should be a goog.editor.Link).
* @return {!goog.ui.editor.LinkDialog} The dialog.
* @override
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.createDialog = function(
dialogDomHelper, opt_link) {
var dialog = new goog.ui.editor.LinkDialog(dialogDomHelper,
/** @type {goog.editor.Link} */ (opt_link));
if (this.emailWarning_) {
dialog.setEmailWarning(this.emailWarning_);
}
if (this.showOpenLinkInNewWindow_) {
dialog.showOpenLinkInNewWindow(this.isOpenLinkInNewWindowChecked_);
}
if (this.showRelNoFollow_) {
dialog.showRelNoFollow();
}
dialog.setStopReferrerLeaks(this.stopReferrerLeaks_);
this.eventHandler_.
listen(dialog, goog.ui.editor.AbstractDialog.EventType.OK,
this.handleOk).
listen(dialog, goog.ui.editor.AbstractDialog.EventType.CANCEL,
this.handleCancel_).
listen(dialog, goog.ui.editor.LinkDialog.EventType.BEFORE_TEST_LINK,
this.handleBeforeTestLink);
return dialog;
};
/** @override */
goog.editor.plugins.LinkDialogPlugin.prototype.disposeInternal = function() {
goog.editor.plugins.LinkDialogPlugin.base(this, 'disposeInternal');
this.eventHandler_.dispose();
};
/**
* Handles the OK event from the dialog by updating the link in the field.
* @param {goog.ui.editor.LinkDialog.OkEvent} e OK event object.
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.handleOk = function(e) {
// We're not restoring the original selection, so clear it out.
this.disposeOriginalSelection();
this.currentLink_.setTextAndUrl(e.linkText, e.linkUrl);
if (this.showOpenLinkInNewWindow_) {
// Save checkbox state for next time.
this.isOpenLinkInNewWindowChecked_ = e.openInNewWindow;
}
var anchor = this.currentLink_.getAnchor();
this.touchUpAnchorOnOk_(anchor, e);
var extraAnchors = this.currentLink_.getExtraAnchors();
for (var i = 0; i < extraAnchors.length; ++i) {
extraAnchors[i].href = anchor.href;
this.touchUpAnchorOnOk_(extraAnchors[i], e);
}
// Place cursor to the right of the modified link.
this.currentLink_.placeCursorRightOf();
this.getFieldObject().focus();
this.getFieldObject().dispatchSelectionChangeEvent();
this.getFieldObject().dispatchChange();
this.eventHandler_.removeAll();
};
/**
* Apply the necessary properties to a link upon Ok being clicked in the dialog.
* @param {HTMLAnchorElement} anchor The anchor to set properties on.
* @param {goog.events.Event} e Event object.
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.touchUpAnchorOnOk_ =
function(anchor, e) {
if (this.showOpenLinkInNewWindow_) {
if (e.openInNewWindow) {
anchor.target = '_blank';
} else {
if (anchor.target == '_blank') {
anchor.target = '';
}
// If user didn't indicate to open in a new window but the link already
// had a target other than '_blank', let's leave what they had before.
}
}
if (this.showRelNoFollow_) {
var alreadyPresent = goog.ui.editor.LinkDialog.hasNoFollow(anchor.rel);
if (alreadyPresent && !e.noFollow) {
anchor.rel = goog.ui.editor.LinkDialog.removeNoFollow(anchor.rel);
} else if (!alreadyPresent && e.noFollow) {
anchor.rel = anchor.rel ? anchor.rel + ' nofollow' : 'nofollow';
}
}
};
/**
* Handles the CANCEL event from the dialog by clearing the anchor if needed.
* @param {goog.events.Event} e Event object.
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.handleCancel_ = function(e) {
if (this.currentLink_.isNew()) {
goog.dom.flattenElement(this.currentLink_.getAnchor());
var extraAnchors = this.currentLink_.getExtraAnchors();
for (var i = 0; i < extraAnchors.length; ++i) {
goog.dom.flattenElement(extraAnchors[i]);
}
// Make sure listeners know the anchor was flattened out.
this.getFieldObject().dispatchChange();
}
this.eventHandler_.removeAll();
};
/**
* Handles the BeforeTestLink event fired when the 'test' link is clicked.
* @param {goog.ui.editor.LinkDialog.BeforeTestLinkEvent} e BeforeTestLink event
* object.
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.handleBeforeTestLink =
function(e) {
if (!this.shouldOpenUrl(e.url)) {
/** @desc Message when the user tries to test (preview) a link, but the
* link cannot be tested. */
var MSG_UNSAFE_LINK = goog.getMsg('This link cannot be tested.');
alert(MSG_UNSAFE_LINK);
e.preventDefault();
}
};
/**
* Checks whether the plugin should open the given url in a new window.
* @param {string} url The url to check.
* @return {boolean} If the plugin should open the given url in a new window.
* @protected
*/
goog.editor.plugins.LinkDialogPlugin.prototype.shouldOpenUrl = function(url) {
return !this.blockOpeningUnsafeSchemes_ || this.isSafeSchemeToOpen_(url);
};
/**
* Determines whether or not a url has a scheme which is safe to open.
* Schemes like javascript are unsafe due to the possibility of XSS.
* @param {string} url A url.
* @return {boolean} Whether the url has a safe scheme.
* @private
*/
goog.editor.plugins.LinkDialogPlugin.prototype.isSafeSchemeToOpen_ =
function(url) {
var scheme = goog.uri.utils.getScheme(url) || 'http';
return goog.array.contains(this.safeToOpenSchemes_, scheme.toLowerCase());
};
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<!--
Copyright 2010 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<!--
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.LinkDialogPlugin Tests
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.ui.editor.plugins.LinkDialogTest');
</script>
<link rel="stylesheet" href="../../css/dialog.css" />
<link rel="stylesheet" href="../../css/editor/dialog.css" />
<link rel="stylesheet" href="../../css/editor/linkdialog.css" />
</head>
<body>
<div id="test">
</div>
</body>
</html>
@@ -0,0 +1,749 @@
// Copyright 2010 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.ui.editor.plugins.LinkDialogTest');
goog.setTestOnly('goog.ui.editor.plugins.LinkDialogTest');
goog.require('goog.dom');
goog.require('goog.dom.DomHelper');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Command');
goog.require('goog.editor.Field');
goog.require('goog.editor.Link');
goog.require('goog.editor.plugins.LinkDialogPlugin');
goog.require('goog.string');
goog.require('goog.string.Unicode');
goog.require('goog.testing.MockControl');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.editor.dom');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.testing.mockmatchers');
goog.require('goog.ui.editor.AbstractDialog');
goog.require('goog.ui.editor.LinkDialog');
goog.require('goog.userAgent');
var plugin;
var anchorElem;
var extraAnchors;
var isNew;
var testDiv;
var mockCtrl;
var mockField;
var mockLink;
var mockAlert;
var OLD_LINK_TEXT = 'old text';
var OLD_LINK_URL = 'http://old.url/';
var NEW_LINK_TEXT = 'My Link Text';
var NEW_LINK_URL = 'http://my.link/url/';
var fieldElem;
var fieldObj;
var linkObj;
function setUp() {
testDiv = goog.dom.getDocument().getElementById('test');
testDiv.innerHTML = 'Some preceeding text';
anchorElem = goog.dom.createElement(goog.dom.TagName.A);
anchorElem.href = 'http://www.google.com/';
anchorElem.innerHTML = 'anchor text';
goog.dom.appendChild(testDiv, anchorElem);
extraAnchors = [];
mockCtrl = new goog.testing.MockControl();
mockField = new goog.testing.editor.FieldMock();
mockCtrl.addMock(mockField);
mockLink = mockCtrl.createLooseMock(goog.editor.Link);
mockAlert = mockCtrl.createGlobalFunctionMock('alert');
isNew = false;
mockLink.isNew().$anyTimes().$does(function() {
return isNew;
});
mockLink.
setTextAndUrl(goog.testing.mockmatchers.isString,
goog.testing.mockmatchers.isString).
$anyTimes().
$does(function(text, url) {
anchorElem.innerHTML = text;
anchorElem.href = url;
});
mockLink.getAnchor().$anyTimes().$returns(anchorElem);
mockLink.getExtraAnchors().$anyTimes().$returns(extraAnchors);
}
function tearDown() {
plugin.dispose();
tearDownRealEditableField();
testDiv.innerHTML = '';
mockCtrl.$tearDown();
}
function setUpAnchor(text, href, opt_isNew, opt_target, opt_rel) {
setUpGivenAnchor(anchorElem, text, href, opt_isNew, opt_target, opt_rel);
}
function setUpGivenAnchor(
anchor, text, href, opt_isNew, opt_target, opt_rel) {
anchor.innerHTML = text;
anchor.href = href;
isNew = !!opt_isNew;
if (opt_target) {
anchor.target = opt_target;
}
if (opt_rel) {
anchor.rel = opt_rel;
}
}
/**
* Tests that the plugin's dialog is properly created.
*/
function testCreateDialog() {
// Note: this tests simply creating the dialog because that's the only
// functionality added to this class. Opening or closing effects (editing
// the actual link) is tested in linkdialog_test.html, but should be moved
// here if that functionality gets refactored from the dialog to the plugin.
mockCtrl.$replayAll();
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
assertTrue('Dialog should be of type goog.ui.editor.LinkDialog',
dialog instanceof goog.ui.editor.LinkDialog);
mockCtrl.$verifyAll();
}
/**
* Tests that when the OK event fires the link is properly updated.
*/
function testOk() {
mockLink.placeCursorRightOf();
mockField.dispatchSelectionChangeEvent();
mockField.dispatchChange();
mockField.focus();
mockCtrl.$replayAll();
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL);
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + clicking OK without actually opening the dialog.
plugin.currentLink_ = mockLink;
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(NEW_LINK_TEXT,
NEW_LINK_URL));
assertEquals('Display text incorrect',
NEW_LINK_TEXT,
anchorElem.innerHTML);
assertEquals('Anchor element href incorrect',
NEW_LINK_URL,
anchorElem.href);
mockCtrl.$verifyAll();
}
/**
* Tests that when the Cancel event fires the link is unchanged.
*/
function testCancel() {
mockCtrl.$replayAll();
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL);
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + cancel without actually opening the dialog.
plugin.currentLink_ = mockLink;
dialog.dispatchEvent(goog.ui.editor.AbstractDialog.EventType.CANCEL);
assertEquals('Display text should not be changed',
OLD_LINK_TEXT,
anchorElem.innerHTML);
assertEquals('Anchor element href should not be changed',
OLD_LINK_URL,
anchorElem.href);
mockCtrl.$verifyAll();
}
/**
* Tests that when the Cancel event fires for a new link it gets removed.
*/
function testCancelNew() {
mockField.dispatchChange(); // Should be fired because link was removed.
mockCtrl.$replayAll();
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL, true);
var prevSib = anchorElem.previousSibling;
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + cancel without actually opening the dialog.
plugin.currentLink_ = mockLink;
dialog.dispatchEvent(goog.ui.editor.AbstractDialog.EventType.CANCEL);
assertNotEquals('Anchor element should be removed from document body',
testDiv,
anchorElem.parentNode);
var newElem = prevSib.nextSibling;
assertEquals('Link should be replaced by text node',
goog.dom.NodeType.TEXT,
newElem.nodeType);
assertEquals('Original text should be left behind',
OLD_LINK_TEXT,
newElem.nodeValue);
mockCtrl.$verifyAll();
}
/**
* Tests that when the Cancel event fires for a new link it gets removed.
*/
function testCancelNewMultiple() {
mockField.dispatchChange(); // Should be fired because link was removed.
mockCtrl.$replayAll();
var anchorElem1 = anchorElem;
var parent1 = goog.dom.createDom(goog.dom.TagName.DIV, null,
anchorElem1);
goog.dom.appendChild(testDiv, parent1);
setUpGivenAnchor(anchorElem1, OLD_LINK_TEXT + '1', OLD_LINK_URL + '1',
true);
anchorElem2 = goog.dom.createDom(goog.dom.TagName.A);
var parent2 = goog.dom.createDom(goog.dom.TagName.DIV, null,
anchorElem2);
goog.dom.appendChild(testDiv, parent2);
setUpGivenAnchor(anchorElem2, OLD_LINK_TEXT + '2', OLD_LINK_URL + '2',
true);
extraAnchors.push(anchorElem2);
anchorElem3 = goog.dom.createDom(goog.dom.TagName.A);
var parent3 = goog.dom.createDom(goog.dom.TagName.DIV, null,
anchorElem3);
goog.dom.appendChild(testDiv, parent3);
setUpGivenAnchor(anchorElem3, OLD_LINK_TEXT + '3', OLD_LINK_URL + '3',
true);
extraAnchors.push(anchorElem3);
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + cancel without actually opening the dialog.
plugin.currentLink_ = mockLink;
dialog.dispatchEvent(goog.ui.editor.AbstractDialog.EventType.CANCEL);
assertNotEquals('Anchor 1 element should be removed from document body',
parent1,
anchorElem1.parentNode);
assertNotEquals('Anchor 2 element should be removed from document body',
parent2,
anchorElem2.parentNode);
assertNotEquals('Anchor 3 element should be removed from document body',
parent3,
anchorElem3.parentNode);
assertEquals('Link 1 should be replaced by text node',
goog.dom.NodeType.TEXT,
parent1.firstChild.nodeType);
assertEquals('Link 2 should be replaced by text node',
goog.dom.NodeType.TEXT,
parent2.firstChild.nodeType);
assertEquals('Link 3 should be replaced by text node',
goog.dom.NodeType.TEXT,
parent3.firstChild.nodeType);
assertEquals('Original text 1 should be left behind',
OLD_LINK_TEXT + '1',
parent1.firstChild.nodeValue);
assertEquals('Original text 2 should be left behind',
OLD_LINK_TEXT + '2',
parent2.firstChild.nodeValue);
assertEquals('Original text 3 should be left behind',
OLD_LINK_TEXT + '3',
parent3.firstChild.nodeValue);
mockCtrl.$verifyAll();
}
/**
* Tests that when the Cancel event fires for a new link it gets removed.
*/
function testOkNewMultiple() {
mockLink.placeCursorRightOf();
mockField.dispatchSelectionChangeEvent();
mockField.dispatchChange();
mockField.focus();
mockCtrl.$replayAll();
var anchorElem1 = anchorElem;
setUpGivenAnchor(anchorElem1, OLD_LINK_TEXT + '1', OLD_LINK_URL + '1',
true);
anchorElem2 = goog.dom.createElement(goog.dom.TagName.A);
goog.dom.appendChild(testDiv, anchorElem2);
setUpGivenAnchor(anchorElem2, OLD_LINK_TEXT + '2', OLD_LINK_URL + '2',
true);
extraAnchors.push(anchorElem2);
anchorElem3 = goog.dom.createElement(goog.dom.TagName.A);
goog.dom.appendChild(testDiv, anchorElem3);
setUpGivenAnchor(anchorElem3, OLD_LINK_TEXT + '3', OLD_LINK_URL + '3',
true);
extraAnchors.push(anchorElem3);
var prevSib = anchorElem1.previousSibling;
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + clicking OK without actually opening the dialog.
plugin.currentLink_ = mockLink;
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(NEW_LINK_TEXT,
NEW_LINK_URL));
assertEquals('Display text 1 must update', NEW_LINK_TEXT,
anchorElem1.innerHTML);
assertEquals('Display text 2 must not update', OLD_LINK_TEXT + '2',
anchorElem2.innerHTML);
assertEquals('Display text 3 must not update', OLD_LINK_TEXT + '3',
anchorElem3.innerHTML);
assertEquals('Anchor element 1 href must update', NEW_LINK_URL,
anchorElem1.href);
assertEquals('Anchor element 2 href must update', NEW_LINK_URL,
anchorElem2.href);
assertEquals('Anchor element 3 href must update', NEW_LINK_URL,
anchorElem3.href);
mockCtrl.$verifyAll();
}
/**
* Tests the anchor's target is correctly modified with the "open in new
* window" feature on.
*/
function testOkOpenInNewWindow() {
mockLink.placeCursorRightOf().$anyTimes();
mockField.dispatchSelectionChangeEvent().$anyTimes();
mockField.dispatchChange().$anyTimes();
mockField.focus().$anyTimes();
mockCtrl.$replayAll();
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
plugin.showOpenLinkInNewWindow(false);
plugin.currentLink_ = mockLink;
// Edit a link that doesn't open in a new window and leave it as such.
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(
NEW_LINK_TEXT, NEW_LINK_URL, false, false));
assertEquals(
'Target should not be set for link that doesn\'t open in new window',
'', anchorElem.target);
assertFalse('Checked state should stay false',
plugin.getOpenLinkInNewWindowCheckedState());
// Edit a link that doesn't open in a new window and toggle it on.
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL);
dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(
NEW_LINK_TEXT, NEW_LINK_URL, true));
assertEquals(
'Target should be set to _blank for link that opens in new window',
'_blank', anchorElem.target);
assertTrue('Checked state should be true after toggling a link on',
plugin.getOpenLinkInNewWindowCheckedState());
// Edit a link that doesn't open in a named window and don't touch it.
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL, false, 'named');
dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(
NEW_LINK_TEXT, NEW_LINK_URL, false));
assertEquals(
'Target should keep its original value',
'named', anchorElem.target);
assertFalse('Checked state should be false again',
plugin.getOpenLinkInNewWindowCheckedState());
// Edit a link that opens in a new window and toggle it off.
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL, false, '_blank');
dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(
NEW_LINK_TEXT, NEW_LINK_URL, false));
assertEquals(
'Target should not be set for link that doesn\'t open in new window',
'', anchorElem.target);
mockCtrl.$verifyAll();
}
function testOkNoFollowEnabled() {
verifyRelNoFollow(true, null, 'nofollow');
}
function testOkNoFollowInUppercase() {
verifyRelNoFollow(true, 'NOFOLLOW', 'NOFOLLOW');
}
function testOkNoFollowEnabledHasMoreRelValues() {
verifyRelNoFollow(true, 'author', 'author nofollow');
}
function testOkNoFollowDisabled() {
verifyRelNoFollow(false, null, '');
}
function testOkNoFollowDisabledHasMoreRelValues() {
verifyRelNoFollow(false, 'author', 'author');
}
function testOkNoFollowDisabledHasMoreRelValues() {
verifyRelNoFollow(false, 'author nofollow', 'author ');
}
function testOkNoFollowInUppercaseWithMoreValues() {
verifyRelNoFollow(true, 'NOFOLLOW author', 'NOFOLLOW author');
}
function verifyRelNoFollow(noFollow, originalRel, expectedRel) {
mockLink.placeCursorRightOf();
mockField.dispatchSelectionChangeEvent();
mockField.dispatchChange();
mockField.focus();
mockCtrl.$replayAll();
plugin = new goog.editor.plugins.LinkDialogPlugin();
plugin.registerFieldObject(mockField);
plugin.showRelNoFollow();
plugin.currentLink_ = mockLink;
setUpAnchor(OLD_LINK_TEXT, OLD_LINK_URL, true, null, originalRel);
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
dialog.dispatchEvent(new goog.ui.editor.LinkDialog.OkEvent(
NEW_LINK_TEXT, NEW_LINK_URL, false, noFollow));
assertEquals(expectedRel, anchorElem.rel);
mockCtrl.$verifyAll();
}
/**
* Tests that the selection is cleared when the dialog opens and is
* correctly restored after cancel is clicked.
*/
function testRestoreSelectionOnOk() {
setUpAnchor('12345', '/');
setUpRealEditableField();
var elem = fieldObj.getElement();
var helper = new goog.testing.editor.TestHelper(elem);
helper.select('12345', 1, '12345', 4); // Selects '234'.
assertEquals('Incorrect text selected before dialog is opened',
'234',
fieldObj.getRange().getText());
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
if (!goog.userAgent.IE && !goog.userAgent.OPERA) {
// IE returns some bogus range when field doesn't have selection.
// You can't remove the selection from a whitebox field in Opera.
assertNull('There should be no selection while dialog is open',
fieldObj.getRange());
}
goog.testing.events.fireClickSequence(
plugin.dialog_.getOkButtonElement());
assertEquals('No text should be selected after clicking ok',
'',
fieldObj.getRange().getText());
// Test that the caret is placed at the end of the link text.
goog.testing.editor.dom.assertRangeBetweenText(
// If the browser gets stuck in links, an nbsp was added after the link
// to avoid that, otherwise we just look for the 5.
goog.editor.BrowserFeature.GETS_STUCK_IN_LINKS ?
goog.string.Unicode.NBSP : '5',
'',
fieldObj.getRange());
// NOTE(user): The functionality to avoid getting stuck in links is
// tested in editablelink_test.html::testPlaceCursorRightOf().
}
/**
* Tests that the selection is cleared when the dialog opens and is
* correctly restored after cancel is clicked.
* @param {boolean=} opt_isNew Whether to test behavior when creating a new
* link (cancelling will flatten it).
*/
function testRestoreSelectionOnCancel(opt_isNew) {
setUpAnchor('12345', '/', opt_isNew);
setUpRealEditableField();
var elem = fieldObj.getElement();
var helper = new goog.testing.editor.TestHelper(elem);
helper.select('12345', 1, '12345', 4); // Selects '234'.
assertEquals('Incorrect text selected before dialog is opened',
'234',
fieldObj.getRange().getText());
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
if (!goog.userAgent.IE && !goog.userAgent.OPERA) {
// IE returns some bogus range when field doesn't have selection.
// You can't remove the selection from a whitebox field in Opera.
assertNull('There should be no selection while dialog is open',
fieldObj.getRange());
}
goog.testing.events.fireClickSequence(
plugin.dialog_.getCancelButtonElement());
assertEquals('Incorrect text selected after clicking cancel',
'234',
fieldObj.getRange().getText());
}
/**
* Tests that the selection is cleared when the dialog opens and is
* correctly restored after cancel is clicked and the new link is removed.
*/
function testRestoreSelectionOnCancelNew() {
testRestoreSelectionOnCancel(true);
}
/**
* Tests that the BeforeTestLink event is suppressed for invalid url schemes.
*/
function testTestLinkDisabledForInvalidScheme() {
mockAlert(goog.testing.mockmatchers.isString);
mockCtrl.$replayAll();
var invalidUrl = 'javascript:document.write(\'hello\');';
plugin = new goog.editor.plugins.LinkDialogPlugin();
var dialog = plugin.createDialog(new goog.dom.DomHelper(), mockLink);
// Mock of execCommand + clicking test without actually opening the dialog.
var dispatched = dialog.dispatchEvent(
new goog.ui.editor.LinkDialog.BeforeTestLinkEvent(invalidUrl));
assertFalse(dispatched);
mockCtrl.$verifyAll();
}
function testIsSafeSchemeToOpen() {
plugin = new goog.editor.plugins.LinkDialogPlugin();
// Urls with no scheme at all are ok too since 'http://' will be prepended.
var good = [
'http://google.com', 'http://google.com/', 'https://google.com',
'null@google.com', 'http://www.google.com', 'http://site.com',
'google.com', 'google', 'http://google', 'HTTP://GOOGLE.COM',
'HtTp://www.google.com'
];
var bad = [
'javascript:google.com', 'httpp://google.com', 'data:foo',
'javascript:alert(\'hi\');', 'abc:def'
];
for (var i = 0; i < good.length; i++) {
assertTrue(good[i] + ' should have a safe scheme',
plugin.isSafeSchemeToOpen_(good[i]));
}
for (i = 0; i < bad.length; i++) {
assertFalse(bad[i] + ' should have an unsafe scheme',
plugin.isSafeSchemeToOpen_(bad[i]));
}
}
function testShouldOpenWithWhitelist() {
plugin.setSafeToOpenSchemes(['abc']);
assertTrue('Scheme should be safe',
plugin.shouldOpenUrl('abc://google.com'));
assertFalse('Scheme should be unsafe',
plugin.shouldOpenUrl('http://google.com'));
plugin.setBlockOpeningUnsafeSchemes(false);
assertTrue('Non-whitelisted should now be safe after disabling blocking',
plugin.shouldOpenUrl('http://google.com'));
}
/**
* Regression test for http://b/issue?id=1607766 . Without the fix, this
* should give an Invalid Argument error in IE, because the editable field
* caches a selection util that has a reference to the node of the link text
* before it is edited (which gets replaced by a new node for the new text
* after editing).
*/
function testBug1607766() {
setUpAnchor('abc', 'def');
setUpRealEditableField();
var elem = fieldObj.getElement();
var helper = new goog.testing.editor.TestHelper(elem);
helper.select('abc', 1, 'abc', 2); // Selects 'b'.
// Dispatching a selection event causes the field to cache a selection
// util, which is the root of the bug.
plugin.fieldObject.dispatchSelectionChangeEvent();
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.TEXT_TO_DISPLAY).value = 'Abc';
goog.testing.events.fireClickSequence(plugin.dialog_.getOkButtonElement());
// In IE the unit test somehow doesn't cause a browser focus event, so we
// need to manually invoke this, which is where the bug happens.
plugin.fieldObject.dispatchFocus_();
}
/**
* Regression test for http://b/issue?id=2215546 .
*/
function testBug2215546() {
setUpRealEditableField();
var elem = fieldObj.getElement();
fieldObj.setHtml(false, '<div><a href="/"></a></div>');
anchorElem = elem.firstChild.firstChild;
linkObj = new goog.editor.Link(anchorElem, true);
var helper = new goog.testing.editor.TestHelper(elem);
// Select "</a>" in a way, simulating what IE does if you hit enter twice,
// arrow up into the blank line and open the link dialog.
helper.select(anchorElem, 0, elem.firstChild, 1);
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.TEXT_TO_DISPLAY).value = 'foo';
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.ON_WEB_INPUT).value = 'foo';
var okButton = plugin.dialog_.getOkButtonElement();
okButton.disabled = false;
goog.testing.events.fireClickSequence(okButton);
assertEquals('Link text should have been inserted',
'foo', anchorElem.innerHTML);
}
/**
* Test that link insertion doesn't scroll the field to the top
* after clicking Cancel or OK.
*/
function testBug7279077ScrollOnFocus() {
if (goog.userAgent.IE) {
return; // TODO(user): take this out once b/7279077 fixed for IE too.
}
setUpAnchor('12345', '/');
setUpRealEditableField();
// Make the field scrollable and kinda small.
var elem = fieldObj.getElement();
elem.style.overflow = 'auto';
elem.style.height = '40px';
elem.style.width = '200px';
elem.style.contenteditable = 'true';
// Add a bunch of text before the anchor tag.
var longTextElem = document.createElement('span');
longTextElem.innerHTML = goog.string.repeat('All work and no play.<p>', 20);
elem.insertBefore(longTextElem, elem.firstChild);
var helper = new goog.testing.editor.TestHelper(elem);
helper.select('12345', 1, '12345', 4); // Selects '234'.
// Scroll down.
elem.scrollTop = 60;
// Bring up the link insertion dialog, then cancel.
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.TEXT_TO_DISPLAY).value = 'foo';
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.ON_WEB_INPUT).value = 'foo';
var cancelButton = plugin.dialog_.getCancelButtonElement();
goog.testing.events.fireClickSequence(cancelButton);
assertEquals('Field should not have scrolled after cancel',
60, elem.scrollTop);
// Now let's try it with clicking the OK button.
plugin.execCommand(goog.editor.Command.MODAL_LINK_EDITOR, linkObj);
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.TEXT_TO_DISPLAY).value = 'foo';
goog.dom.getElement(
goog.ui.editor.LinkDialog.Id_.ON_WEB_INPUT).value = 'foo';
var okButton = plugin.dialog_.getOkButtonElement();
goog.testing.events.fireClickSequence(okButton);
assertEquals('Field should not have scrolled after OK',
60, elem.scrollTop);
}
/**
* Setup a real editable field (instead of a mock) and register the plugin to
* it.
*/
function setUpRealEditableField() {
fieldElem = document.createElement('div');
fieldElem.id = 'myField';
document.body.appendChild(fieldElem);
fieldElem.appendChild(anchorElem);
fieldObj = new goog.editor.Field('myField', document);
fieldObj.makeEditable();
linkObj = new goog.editor.Link(fieldObj.getElement().firstChild, isNew);
// Register the plugin to that field.
plugin = new goog.editor.plugins.LinkDialogPlugin();
fieldObj.registerPlugin(plugin);
}
/**
* Tear down the real editable field.
*/
function tearDownRealEditableField() {
if (fieldObj) {
fieldObj.makeUneditable();
fieldObj.dispose();
fieldObj = null;
}
goog.dom.removeNode(fieldElem);
}
@@ -0,0 +1,62 @@
// Copyright 2011 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Adds a keyboard shortcut for the link command.
*
*/
goog.provide('goog.editor.plugins.LinkShortcutPlugin');
goog.require('goog.editor.Command');
goog.require('goog.editor.Plugin');
/**
* Plugin to add a keyboard shortcut for the link command
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.LinkShortcutPlugin = function() {
goog.editor.plugins.LinkShortcutPlugin.base(this, 'constructor');
};
goog.inherits(goog.editor.plugins.LinkShortcutPlugin, goog.editor.Plugin);
/** @override */
goog.editor.plugins.LinkShortcutPlugin.prototype.getTrogClassId = function() {
return 'LinkShortcutPlugin';
};
/**
* @override
*/
goog.editor.plugins.LinkShortcutPlugin.prototype.handleKeyboardShortcut =
function(e, key, isModifierPressed) {
var command;
if (isModifierPressed && key == 'k' && !e.shiftKey) {
var link = /** @type {goog.editor.Link?} */ (
this.getFieldObject().execCommand(goog.editor.Command.LINK));
if (link) {
link.finishLinkCreation(this.getFieldObject());
}
return true;
}
return false;
};
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<!--
Copyright 2011 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.LinkShortcutPlugin Tests
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.LinkShortcutPluginTest');
</script>
</head>
<body>
<div id="cleanup">
<div id="field">
http://www.google.com/
</div>
</div>
</body>
</html>
@@ -0,0 +1,65 @@
// Copyright 2011 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.LinkShortcutPluginTest');
goog.setTestOnly('goog.editor.plugins.LinkShortcutPluginTest');
goog.require('goog.dom');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.BasicTextFormatter');
goog.require('goog.editor.plugins.LinkBubble');
goog.require('goog.editor.plugins.LinkShortcutPlugin');
goog.require('goog.events.KeyCodes');
goog.require('goog.testing.PropertyReplacer');
goog.require('goog.testing.dom');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
var propertyReplacer;
function setUp() {
propertyReplacer = new goog.testing.PropertyReplacer();
}
function tearDown() {
propertyReplacer.reset();
var field = document.getElementById('cleanup');
goog.dom.removeChildren(field);
field.innerHTML = '<div id="field">http://www.google.com/</div>';
}
function testShortcutCreatesALink() {
propertyReplacer.set(window, 'prompt', function() {
return 'http://www.google.com/'; });
var linkBubble = new goog.editor.plugins.LinkBubble();
var formatter = new goog.editor.plugins.BasicTextFormatter();
var plugin = new goog.editor.plugins.LinkShortcutPlugin();
var fieldEl = document.getElementById('field');
var field = new goog.editor.Field('field');
field.registerPlugin(formatter);
field.registerPlugin(linkBubble);
field.registerPlugin(plugin);
field.makeEditable();
field.focusAndPlaceCursorAtStart();
var textNode = goog.testing.dom.findTextNode('http://www.google.com/',
fieldEl);
goog.testing.events.fireKeySequence(
field.getElement(), goog.events.KeyCodes.K, { ctrlKey: true });
var href = field.getElement().getElementsByTagName('A')[0];
assertEquals('http://www.google.com/', href.href);
var bubbleLink =
document.getElementById(goog.editor.plugins.LinkBubble.TEST_LINK_ID_);
assertEquals('http://www.google.com/', bubbleLink.innerHTML);
}
@@ -0,0 +1,68 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Editor plugin to handle tab keys in lists to indent and
* outdent.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.ListTabHandler');
goog.require('goog.dom');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Command');
goog.require('goog.editor.plugins.AbstractTabHandler');
goog.require('goog.iter');
/**
* Plugin to handle tab keys in lists to indent and outdent.
* @constructor
* @extends {goog.editor.plugins.AbstractTabHandler}
* @final
*/
goog.editor.plugins.ListTabHandler = function() {
goog.editor.plugins.AbstractTabHandler.call(this);
};
goog.inherits(goog.editor.plugins.ListTabHandler,
goog.editor.plugins.AbstractTabHandler);
/** @override */
goog.editor.plugins.ListTabHandler.prototype.getTrogClassId = function() {
return 'ListTabHandler';
};
/** @override */
goog.editor.plugins.ListTabHandler.prototype.handleTabKey = function(e) {
var range = this.getFieldObject().getRange();
if (goog.dom.getAncestorByTagNameAndClass(range.getContainerElement(),
goog.dom.TagName.LI) ||
goog.iter.some(range, function(node) {
return node.tagName == goog.dom.TagName.LI;
})) {
this.getFieldObject().execCommand(e.shiftKey ?
goog.editor.Command.OUTDENT :
goog.editor.Command.INDENT);
e.preventDefault();
return true;
}
return false;
};
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<!--
@author robbyw@google.com (Robby Walker)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.ListTabHandler
</title>
<script src="../../base.js">
</script>
<script src="../../deps.js">
</script>
<script>
goog.require('goog.editor.plugins.ListTabHandlerTest');
</script>
</head>
<body>
<div id="field">
</div>
</body>
</html>
@@ -0,0 +1,167 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.ListTabHandlerTest');
goog.setTestOnly('goog.editor.plugins.ListTabHandlerTest');
goog.require('goog.dom');
goog.require('goog.editor.Command');
goog.require('goog.editor.plugins.ListTabHandler');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.KeyCodes');
goog.require('goog.functions');
goog.require('goog.testing.StrictMock');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
var field;
var editableField;
var tabHandler;
var testHelper;
function setUpPage() {
field = goog.dom.getElement('field');
}
function setUp() {
editableField = new goog.testing.editor.FieldMock();
// Modal mode behavior tested as part of AbstractTabHandler tests.
editableField.inModalMode = goog.functions.FALSE;
tabHandler = new goog.editor.plugins.ListTabHandler();
tabHandler.registerFieldObject(editableField);
testHelper = new goog.testing.editor.TestHelper(field);
testHelper.setUpEditableElement();
}
function tearDown() {
editableField = null;
testHelper.tearDownEditableElement();
tabHandler.dispose();
}
function testListIndentInLi() {
field.innerHTML = '<ul><li>Text</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(testText, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.execCommand(goog.editor.Command.INDENT);
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event must be handled',
tabHandler.handleKeyboardShortcut(event, '', false));
editableField.$verify();
event.$verify();
}
function testListIndentContainLi() {
field.innerHTML = '<ul><li>Text</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(field.firstChild, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.execCommand(goog.editor.Command.INDENT);
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event must be handled',
tabHandler.handleKeyboardShortcut(event, '', false));
editableField.$verify();
event.$verify();
}
function testListOutdentInLi() {
field.innerHTML = '<ul><li>Text</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(testText, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = true;
editableField.execCommand(goog.editor.Command.OUTDENT);
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event must be handled',
tabHandler.handleKeyboardShortcut(event, '', false));
editableField.$verify();
event.$verify();
}
function testListOutdentContainLi() {
field.innerHTML = '<ul><li>Text</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(field.firstChild, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = true;
editableField.execCommand(goog.editor.Command.OUTDENT);
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event must be handled',
tabHandler.handleKeyboardShortcut(event, '', false));
editableField.$verify();
event.$verify();
}
function testNoOp() {
field.innerHTML = 'Text';
var testText = field.firstChild;
testHelper.select(testText, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = true;
editableField.$replay();
event.$replay();
assertFalse('Event must not be handled',
tabHandler.handleKeyboardShortcut(event, '', false));
editableField.$verify();
event.$verify();
}
@@ -0,0 +1,192 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview A plugin that fills the field with lorem ipsum text when it's
* empty and does not have the focus. Applies to both editable and uneditable
* fields.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.plugins.LoremIpsum');
goog.require('goog.asserts');
goog.require('goog.dom');
goog.require('goog.editor.Command');
goog.require('goog.editor.Field');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.node');
goog.require('goog.functions');
goog.require('goog.userAgent');
/**
* A plugin that manages lorem ipsum state of editable fields.
* @param {string} message The lorem ipsum message.
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.LoremIpsum = function(message) {
goog.editor.Plugin.call(this);
/**
* The lorem ipsum message.
* @type {string}
* @private
*/
this.message_ = message;
};
goog.inherits(goog.editor.plugins.LoremIpsum, goog.editor.Plugin);
/** @override */
goog.editor.plugins.LoremIpsum.prototype.getTrogClassId =
goog.functions.constant('LoremIpsum');
/** @override */
goog.editor.plugins.LoremIpsum.prototype.activeOnUneditableFields =
goog.functions.TRUE;
/**
* Whether the field is currently filled with lorem ipsum text.
* @type {boolean}
* @private
*/
goog.editor.plugins.LoremIpsum.prototype.usingLorem_ = false;
/**
* Handles queryCommandValue.
* @param {string} command The command to query.
* @return {boolean} The result.
* @override
*/
goog.editor.plugins.LoremIpsum.prototype.queryCommandValue = function(command) {
return command == goog.editor.Command.USING_LOREM && this.usingLorem_;
};
/**
* Handles execCommand.
* @param {string} command The command to execute.
* Should be CLEAR_LOREM or UPDATE_LOREM.
* @param {*=} opt_placeCursor Whether to place the cursor in the field
* after clearing lorem. Should be a boolean.
* @override
*/
goog.editor.plugins.LoremIpsum.prototype.execCommand = function(command,
opt_placeCursor) {
if (command == goog.editor.Command.CLEAR_LOREM) {
this.clearLorem_(!!opt_placeCursor);
} else if (command == goog.editor.Command.UPDATE_LOREM) {
this.updateLorem_();
}
};
/** @override */
goog.editor.plugins.LoremIpsum.prototype.isSupportedCommand =
function(command) {
return command == goog.editor.Command.CLEAR_LOREM ||
command == goog.editor.Command.UPDATE_LOREM ||
command == goog.editor.Command.USING_LOREM;
};
/**
* Set the lorem ipsum text in a goog.editor.Field if needed.
* @private
*/
goog.editor.plugins.LoremIpsum.prototype.updateLorem_ = function() {
// Try to apply lorem ipsum if:
// 1) We have lorem ipsum text
// 2) There's not a dialog open, as that screws
// with the dialog's ability to properly restore the selection
// on dialog close (since the DOM nodes would get clobbered in FF)
// 3) We're not using lorem already
// 4) The field is not currently active (doesn't have focus).
var fieldObj = this.getFieldObject();
if (!this.usingLorem_ &&
!fieldObj.inModalMode() &&
goog.editor.Field.getActiveFieldId() != fieldObj.id) {
var field = fieldObj.getElement();
if (!field) {
// Fallback on the original element. This is needed by
// fields managed by click-to-edit.
field = fieldObj.getOriginalElement();
}
goog.asserts.assert(field);
if (goog.editor.node.isEmpty(field)) {
this.usingLorem_ = true;
// Save the old font style so it can be restored when we
// clear the lorem ipsum style.
this.oldFontStyle_ = field.style.fontStyle;
field.style.fontStyle = 'italic';
fieldObj.setHtml(true, this.message_, true);
}
}
};
/**
* Clear an EditableField's lorem ipsum and put in initial text if needed.
*
* If using click-to-edit mode (where Trogedit manages whether the field
* is editable), this works for both editable and uneditable fields.
*
* TODO(user): Is this really necessary? See TODO below.
* @param {boolean=} opt_placeCursor Whether to place the cursor in the field
* after clearing lorem.
* @private
*/
goog.editor.plugins.LoremIpsum.prototype.clearLorem_ = function(
opt_placeCursor) {
// Don't mess with lorem state when a dialog is open as that screws
// with the dialog's ability to properly restore the selection
// on dialog close (since the DOM nodes would get clobbered)
var fieldObj = this.getFieldObject();
if (this.usingLorem_ && !fieldObj.inModalMode()) {
var field = fieldObj.getElement();
if (!field) {
// Fallback on the original element. This is needed by
// fields managed by click-to-edit.
field = fieldObj.getOriginalElement();
}
goog.asserts.assert(field);
this.usingLorem_ = false;
field.style.fontStyle = this.oldFontStyle_;
fieldObj.setHtml(true, null, true);
// TODO(nicksantos): I'm pretty sure that this is a hack, but talk to
// Julie about why this is necessary and what to do with it. Really,
// we need to figure out where it's necessary and remove it where it's
// not. Safari never places the cursor on its own willpower.
if (opt_placeCursor && fieldObj.isLoaded()) {
if (goog.userAgent.WEBKIT) {
goog.dom.getOwnerDocument(fieldObj.getElement()).body.focus();
fieldObj.focusAndPlaceCursorAtStart();
} else if (goog.userAgent.OPERA) {
fieldObj.placeCursorAtStart();
}
}
}
};
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
@author nicksantos@google.com (Nick Santos)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.LoremIpsum Tests
</title>
<script type="text/javascript" src="../../base.js">
</script>
<script type="text/javascript">
goog.require('goog.editor.plugins.LoremIpsumTest');
</script>
</head>
<body>
<div id="root">
<div id="field">
</div>
</div>
</body>
</html>
@@ -0,0 +1,156 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.LoremIpsumTest');
goog.setTestOnly('goog.editor.plugins.LoremIpsumTest');
goog.require('goog.dom');
goog.require('goog.editor.Command');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.LoremIpsum');
goog.require('goog.string.Unicode');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var FIELD;
var PLUGIN;
var HTML;
var UPPERCASE_CONTENTS = '<P>THE OWLS ARE NOT WHAT THEY SEEM.</P>';
function setUp() {
HTML = goog.dom.getElement('root').innerHTML;
FIELD = new goog.editor.Field('field');
PLUGIN = new goog.editor.plugins.LoremIpsum(
'The owls are not what they seem.');
FIELD.registerPlugin(PLUGIN);
}
function tearDown() {
FIELD.dispose();
goog.dom.getElement('root').innerHTML = HTML;
}
function testQueryUsingLorem() {
FIELD.makeEditable();
assertTrue(FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
FIELD.setHtml(true, 'fresh content', false, true);
assertFalse(FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
}
function testUpdateLoremIpsum() {
goog.dom.getElement('field').innerHTML = 'stuff';
var loremPlugin = FIELD.getPluginByClassId('LoremIpsum');
FIELD.makeEditable();
var content = '<div>foo</div>';
FIELD.setHtml(false, '', false, /* Don't update lorem */ false);
assertFalse('Field started with content, lorem must not be enabled.',
FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
FIELD.execCommand(goog.editor.Command.UPDATE_LOREM);
assertTrue('Field was set to empty, update must turn on lorem ipsum',
FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
FIELD.unregisterPlugin(loremPlugin);
FIELD.setHtml(false, content, false,
/* Update (turn off) lorem */ true);
FIELD.setHtml(false, '', false, /* Don't update lorem */ false);
FIELD.execCommand(goog.editor.Command.UPDATE_LOREM);
assertFalse('Field with no lorem message must not use lorem ipsum',
FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
FIELD.registerPlugin(loremPlugin);
FIELD.setHtml(false, content, false, true);
FIELD.setHtml(false, '', false, false);
goog.editor.Field.setActiveFieldId(FIELD.id);
FIELD.execCommand(goog.editor.Command.UPDATE_LOREM);
assertFalse('Active field must not use lorem ipsum',
FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
goog.editor.Field.setActiveFieldId(null);
FIELD.setHtml(false, content, false, true);
FIELD.setHtml(false, '', false, false);
FIELD.setModalMode(true);
FIELD.execCommand(goog.editor.Command.UPDATE_LOREM);
assertFalse('Must not turn on lorem ipsum while a dialog is open.',
FIELD.queryCommandValue(goog.editor.Command.USING_LOREM));
FIELD.setModalMode(true);
FIELD.dispose();
}
function testLoremIpsumAndGetCleanContents() {
goog.dom.getElement('field').innerHTML = 'This is a field';
FIELD.makeEditable();
// test direct getCleanContents
assertEquals('field reported wrong contents', 'This is a field',
FIELD.getCleanContents());
// test indirect getCleanContents
var contents = FIELD.getCleanContents();
assertEquals('field reported wrong contents', 'This is a field', contents);
// set field html, but explicitly forbid converting to lorem ipsum text
FIELD.setHtml(false, '&nbsp;', true, false /* no lorem */);
assertEquals('field contains unexpected contents', getNbsp(),
FIELD.getElement().innerHTML);
assertEquals('field reported wrong contents', getNbsp(),
FIELD.getCleanContents());
// now set field html allowing lorem
FIELD.setHtml(false, '&nbsp;', true, true /* lorem */);
assertEquals('field reported wrong contents', goog.string.Unicode.NBSP,
FIELD.getCleanContents());
assertEquals('field contains unexpected contents', UPPERCASE_CONTENTS,
FIELD.getElement().innerHTML.toUpperCase());
}
function testLoremIpsumAndGetCleanContents2() {
// make a field blank before we make it editable, and then check
// that making it editable activates lorem.
assert('field is editable', FIELD.isUneditable());
goog.dom.getElement('field').innerHTML = ' ';
FIELD.makeEditable();
assertEquals('field contains unexpected contents',
UPPERCASE_CONTENTS, FIELD.getElement().innerHTML.toUpperCase());
FIELD.makeUneditable();
assertEquals('field contains unexpected contents',
UPPERCASE_CONTENTS, goog.dom.getElement('field').innerHTML.toUpperCase());
}
function testLoremIpsumInClickToEditMode() {
// in click-to-edit mode, trogedit manages the editable state of the editor,
// so we must manage lorem ipsum in uneditable mode too.
FIELD.makeEditable();
assertEquals('field contains unexpected contents',
UPPERCASE_CONTENTS, FIELD.getElement().innerHTML.toUpperCase());
FIELD.makeUneditable();
assertEquals('field contains unexpected contents',
UPPERCASE_CONTENTS, goog.dom.getElement('field').innerHTML.toUpperCase());
}
function getNbsp() {
// On WebKit (pre-528) and Opera, &nbsp; shows up as its unicode character in
// innerHTML under some circumstances.
return (goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('528')) ||
goog.userAgent.OPERA ? '\u00a0' : '&nbsp;';
}
@@ -0,0 +1,780 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// All Rights Reserved.
/**
* @fileoverview Plugin to handle Remove Formatting.
*
*/
goog.provide('goog.editor.plugins.RemoveFormatting');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.node');
goog.require('goog.editor.range');
goog.require('goog.string');
goog.require('goog.userAgent');
/**
* A plugin to handle removing formatting from selected text.
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.RemoveFormatting = function() {
goog.editor.Plugin.call(this);
/**
* Optional function to perform remove formatting in place of the
* provided removeFormattingWorker_.
* @type {?function(string): string}
* @private
*/
this.optRemoveFormattingFunc_ = null;
};
goog.inherits(goog.editor.plugins.RemoveFormatting, goog.editor.Plugin);
/**
* The editor command this plugin in handling.
* @type {string}
*/
goog.editor.plugins.RemoveFormatting.REMOVE_FORMATTING_COMMAND =
'+removeFormat';
/**
* Regular expression that matches a block tag name.
* @type {RegExp}
* @private
*/
goog.editor.plugins.RemoveFormatting.BLOCK_RE_ =
/^(DIV|TR|LI|BLOCKQUOTE|H\d|PRE|XMP)/;
/**
* Appends a new line to a string buffer.
* @param {Array<string>} sb The string buffer to add to.
* @private
*/
goog.editor.plugins.RemoveFormatting.appendNewline_ = function(sb) {
sb.push('<br>');
};
/**
* Create a new range delimited by the start point of the first range and
* the end point of the second range.
* @param {goog.dom.AbstractRange} startRange Use the start point of this
* range as the beginning of the new range.
* @param {goog.dom.AbstractRange} endRange Use the end point of this
* range as the end of the new range.
* @return {!goog.dom.AbstractRange} The new range.
* @private
*/
goog.editor.plugins.RemoveFormatting.createRangeDelimitedByRanges_ = function(
startRange, endRange) {
return goog.dom.Range.createFromNodes(
startRange.getStartNode(), startRange.getStartOffset(),
endRange.getEndNode(), endRange.getEndOffset());
};
/** @override */
goog.editor.plugins.RemoveFormatting.prototype.getTrogClassId = function() {
return 'RemoveFormatting';
};
/** @override */
goog.editor.plugins.RemoveFormatting.prototype.isSupportedCommand = function(
command) {
return command ==
goog.editor.plugins.RemoveFormatting.REMOVE_FORMATTING_COMMAND;
};
/** @override */
goog.editor.plugins.RemoveFormatting.prototype.execCommandInternal =
function(command, var_args) {
if (command ==
goog.editor.plugins.RemoveFormatting.REMOVE_FORMATTING_COMMAND) {
this.removeFormatting_();
}
};
/** @override */
goog.editor.plugins.RemoveFormatting.prototype.handleKeyboardShortcut =
function(e, key, isModifierPressed) {
if (!isModifierPressed) {
return false;
}
if (key == ' ') {
this.getFieldObject().execCommand(
goog.editor.plugins.RemoveFormatting.REMOVE_FORMATTING_COMMAND);
return true;
}
return false;
};
/**
* Removes formatting from the current selection. Removes basic formatting
* (B/I/U) using the browser's execCommand. Then extracts the html from the
* selection to convert, calls either a client's specified removeFormattingFunc
* callback or trogedit's general built-in removeFormattingWorker_,
* and then replaces the current selection with the converted text.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.removeFormatting_ = function() {
var range = this.getFieldObject().getRange();
if (range.isCollapsed()) {
return;
}
// Get the html to format and send it off for formatting. Built in
// removeFormat only strips some inline elements and some inline CSS styles
var convFunc = this.optRemoveFormattingFunc_ ||
goog.bind(this.removeFormattingWorker_, this);
this.convertSelectedHtmlText_(convFunc);
// Do the execCommand last as it needs block elements removed to work
// properly on background/fontColor in FF. There are, unfortunately, still
// cases where background/fontColor are not removed here.
var doc = this.getFieldDomHelper().getDocument();
doc.execCommand('RemoveFormat', false, undefined);
if (goog.editor.BrowserFeature.ADDS_NBSPS_IN_REMOVE_FORMAT) {
// WebKit converts spaces to non-breaking spaces when doing a RemoveFormat.
// See: https://bugs.webkit.org/show_bug.cgi?id=14062
this.convertSelectedHtmlText_(function(text) {
// This loses anything that might have legitimately been a non-breaking
// space, but that's better than the alternative of only having non-
// breaking spaces.
// Old versions of WebKit (Safari 3, Chrome 1) incorrectly match /u00A0
// and newer versions properly match &nbsp;.
var nbspRegExp =
goog.userAgent.isVersionOrHigher('528') ? /&nbsp;/g : /\u00A0/g;
return text.replace(nbspRegExp, ' ');
});
}
};
/**
* Finds the nearest ancestor of the node that is a table.
* @param {Node} nodeToCheck Node to search from.
* @return {Node} The table, or null if one was not found.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.getTableAncestor_ = function(
nodeToCheck) {
var fieldElement = this.getFieldObject().getElement();
while (nodeToCheck && nodeToCheck != fieldElement) {
if (nodeToCheck.tagName == goog.dom.TagName.TABLE) {
return nodeToCheck;
}
nodeToCheck = nodeToCheck.parentNode;
}
return null;
};
/**
* Replaces the contents of the selection with html. Does its best to maintain
* the original selection. Also does its best to result in a valid DOM.
*
* TODO(user): See if there's any way to make this work on Ranges, and then
* move it into goog.editor.range. The Firefox implementation uses execCommand
* on the document, so must work on the actual selection.
*
* @param {string} html The html string to insert into the range.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.pasteHtml_ = function(html) {
var range = this.getFieldObject().getRange();
var dh = this.getFieldDomHelper();
// Use markers to set the extent of the selection so that we can reselect it
// afterwards. This works better than builtin range manipulation in FF and IE
// because their implementations are so self-inconsistent and buggy.
var startSpanId = goog.string.createUniqueString();
var endSpanId = goog.string.createUniqueString();
html = '<span id="' + startSpanId + '"></span>' + html +
'<span id="' + endSpanId + '"></span>';
var dummyNodeId = goog.string.createUniqueString();
var dummySpanText = '<span id="' + dummyNodeId + '"></span>';
if (goog.editor.BrowserFeature.HAS_IE_RANGES) {
// IE's selection often doesn't include the outermost tags.
// We want to use pasteHTML to replace the range contents with the newly
// unformatted text, so we have to check to make sure we aren't just
// pasting into some stray tags. To do this, we first clear out the
// contents of the range and then delete all empty nodes parenting the now
// empty range. This way, the pasted contents are never re-embedded into
// formated nodes. Pasting purely empty html does not work, since IE moves
// the selection inside the next node, so we insert a dummy span.
var textRange = range.getTextRange(0).getBrowserRangeObject();
textRange.pasteHTML(dummySpanText);
var parent;
while ((parent = textRange.parentElement()) &&
goog.editor.node.isEmpty(parent) &&
!goog.editor.node.isEditableContainer(parent)) {
var tag = parent.nodeName;
// We can't remove these table tags as it will invalidate the table dom.
if (tag == goog.dom.TagName.TD ||
tag == goog.dom.TagName.TR ||
tag == goog.dom.TagName.TH) {
break;
}
goog.dom.removeNode(parent);
}
textRange.pasteHTML(html);
var dummySpan = dh.getElement(dummyNodeId);
// If we entered the while loop above, the node has already been removed
// since it was a child of parent and parent was removed.
if (dummySpan) {
goog.dom.removeNode(dummySpan);
}
} else if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
// insertHtml and range.insertNode don't merge blocks correctly.
// (e.g. if your selection spans two paragraphs)
dh.getDocument().execCommand('insertImage', false, dummyNodeId);
var dummyImageNodePattern = new RegExp('<[^<]*' + dummyNodeId + '[^>]*>');
var parent = this.getFieldObject().getRange().getContainerElement();
if (parent.nodeType == goog.dom.NodeType.TEXT) {
// Opera sometimes returns a text node here.
// TODO(user): perhaps we should modify getParentContainer?
parent = parent.parentNode;
}
// We have to search up the DOM because in some cases, notably when
// selecting li's within a list, execCommand('insertImage') actually splits
// tags in such a way that parent that used to contain the selection does
// not contain inserted image.
while (!dummyImageNodePattern.test(parent.innerHTML)) {
parent = parent.parentNode;
}
// Like the IE case above, sometimes the selection does not include the
// outermost tags. For Gecko, we have already expanded the range so that
// it does, so we can just replace the dummy image with the final html.
// For WebKit, we use the same approach as we do with IE - we
// inject a dummy span where we will eventually place the contents, and
// remove parentNodes of the span while they are empty.
if (goog.userAgent.GECKO) {
goog.editor.node.replaceInnerHtml(parent,
parent.innerHTML.replace(dummyImageNodePattern, html));
} else {
goog.editor.node.replaceInnerHtml(parent,
parent.innerHTML.replace(dummyImageNodePattern, dummySpanText));
var dummySpan = dh.getElement(dummyNodeId);
parent = dummySpan;
while ((parent = dummySpan.parentNode) &&
goog.editor.node.isEmpty(parent) &&
!goog.editor.node.isEditableContainer(parent)) {
var tag = parent.nodeName;
// We can't remove these table tags as it will invalidate the table dom.
if (tag == goog.dom.TagName.TD ||
tag == goog.dom.TagName.TR ||
tag == goog.dom.TagName.TH) {
break;
}
// We can't just remove parent since dummySpan is inside it, and we need
// to keep dummy span around for the replacement. So we move the
// dummySpan up as we go.
goog.dom.insertSiblingAfter(dummySpan, parent);
goog.dom.removeNode(parent);
}
goog.editor.node.replaceInnerHtml(parent,
parent.innerHTML.replace(new RegExp(dummySpanText, 'i'), html));
}
}
var startSpan = dh.getElement(startSpanId);
var endSpan = dh.getElement(endSpanId);
goog.dom.Range.createFromNodes(startSpan, 0, endSpan,
endSpan.childNodes.length).select();
goog.dom.removeNode(startSpan);
goog.dom.removeNode(endSpan);
};
/**
* Gets the html inside the selection to send off for further processing.
*
* TODO(user): Make this general so that it can be moved into
* goog.editor.range. The main reason it can't be moved is becuase we need to
* get the range before we do the execCommand and continue to operate on that
* same range (reasons are documented above).
*
* @param {goog.dom.AbstractRange} range The selection.
* @return {string} The html string to format.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.getHtmlText_ = function(range) {
var div = this.getFieldDomHelper().createDom('div');
var textRange = range.getBrowserRangeObject();
if (goog.editor.BrowserFeature.HAS_W3C_RANGES) {
// Get the text to convert.
div.appendChild(textRange.cloneContents());
} else if (goog.editor.BrowserFeature.HAS_IE_RANGES) {
// Trim the whitespace on the ends of the range, so that it the container
// will be the container of only the text content that we are changing.
// This gets around issues in IE where the spaces are included in the
// selection, but ignored sometimes by execCommand, and left orphaned.
var rngText = range.getText();
// BRs get reported as \r\n, but only count as one character for moves.
// Adjust the string so our move counter is correct.
rngText = rngText.replace(/\r\n/g, '\r');
var rngTextLength = rngText.length;
var left = rngTextLength - goog.string.trimLeft(rngText).length;
var right = rngTextLength - goog.string.trimRight(rngText).length;
textRange.moveStart('character', left);
textRange.moveEnd('character', -right);
var htmlText = textRange.htmlText;
// Check if in pretag and fix up formatting so that new lines are preserved.
if (textRange.queryCommandValue('formatBlock') == 'Formatted') {
htmlText = goog.string.newLineToBr(textRange.htmlText);
}
div.innerHTML = htmlText;
}
// Get the innerHTML of the node instead of just returning the text above
// so that its properly html escaped.
return div.innerHTML;
};
/**
* Move the range so that it doesn't include any partially selected tables.
* @param {goog.dom.AbstractRange} range The range to adjust.
* @param {Node} startInTable Table node that the range starts in.
* @param {Node} endInTable Table node that the range ends in.
* @return {!goog.dom.SavedCaretRange} Range to use to restore the
* selection after we run our custom remove formatting.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.adjustRangeForTables_ =
function(range, startInTable, endInTable) {
// Create placeholders for the current selection so we can restore it
// later.
var savedCaretRange = goog.editor.range.saveUsingNormalizedCarets(range);
var startNode = range.getStartNode();
var startOffset = range.getStartOffset();
var endNode = range.getEndNode();
var endOffset = range.getEndOffset();
var dh = this.getFieldDomHelper();
// Move start after the table.
if (startInTable) {
var textNode = dh.createTextNode('');
goog.dom.insertSiblingAfter(textNode, startInTable);
startNode = textNode;
startOffset = 0;
}
// Move end before the table.
if (endInTable) {
var textNode = dh.createTextNode('');
goog.dom.insertSiblingBefore(textNode, endInTable);
endNode = textNode;
endOffset = 0;
}
goog.dom.Range.createFromNodes(startNode, startOffset,
endNode, endOffset).select();
return savedCaretRange;
};
/**
* Remove a caret from the dom and hide it in a safe place, so it can
* be restored later via restoreCaretsFromCave.
* @param {goog.dom.SavedCaretRange} caretRange The caret range to
* get the carets from.
* @param {boolean} isStart Whether this is the start or end caret.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.putCaretInCave_ = function(
caretRange, isStart) {
var cavedCaret = goog.dom.removeNode(caretRange.getCaret(isStart));
if (isStart) {
this.startCaretInCave_ = cavedCaret;
} else {
this.endCaretInCave_ = cavedCaret;
}
};
/**
* Restore carets that were hidden away by adding them back into the dom.
* Note: this does not restore to the original dom location, as that
* will likely have been modified with remove formatting. The only
* guarentees here are that start will still be before end, and that
* they will be in the editable region. This should only be used when
* you don't actually intend to USE the caret again.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.restoreCaretsFromCave_ =
function() {
// To keep start before end, we put the end caret at the bottom of the field
// and the start caret at the start of the field.
var field = this.getFieldObject().getElement();
if (this.startCaretInCave_) {
field.insertBefore(this.startCaretInCave_, field.firstChild);
this.startCaretInCave_ = null;
}
if (this.endCaretInCave_) {
field.appendChild(this.endCaretInCave_);
this.endCaretInCave_ = null;
}
};
/**
* Gets the html inside the current selection, passes it through the given
* conversion function, and puts it back into the selection.
*
* @param {function(string): string} convertFunc A conversion function that
* transforms an html string to new html string.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.convertSelectedHtmlText_ =
function(convertFunc) {
var range = this.getFieldObject().getRange();
// For multiple ranges, it is really hard to do our custom remove formatting
// without invalidating other ranges. So instead of always losing the
// content, this solution at least lets the browser do its own remove
// formatting which works correctly most of the time.
if (range.getTextRangeCount() > 1) {
return;
}
if (goog.userAgent.GECKO) {
// Determine if we need to handle tables, since they are special cases.
// If the selection is entirely within a table, there is no extra
// formatting removal we can do. If a table is fully selected, we will
// just blow it away. If a table is only partially selected, we can
// perform custom remove formatting only on the non table parts, since we
// we can't just remove the parts and paste back into it (eg. we can't
// inject html where a TR used to be).
// If the selection contains the table and more, this is automatically
// handled, but if just the table is selected, it can be tricky to figure
// this case out, because of the numerous ways selections can be formed -
// ex. if a table has a single tr with a single td with a single text node
// in it, and the selection is (textNode: 0), (textNode: nextNode.length)
// then the entire table is selected, even though the start and end aren't
// the table itself. We are truly inside a table if the expanded endpoints
// are still inside the table.
// Expand the selection to include any outermost tags that weren't included
// in the selection, but have the same visible selection. Stop expanding
// if we reach the top level field.
var expandedRange = goog.editor.range.expand(range,
this.getFieldObject().getElement());
var startInTable = this.getTableAncestor_(expandedRange.getStartNode());
var endInTable = this.getTableAncestor_(expandedRange.getEndNode());
if (startInTable || endInTable) {
if (startInTable == endInTable) {
// We are fully contained in the same table, there is no extra
// remove formatting that we can do, just return and run browser
// formatting only.
return;
}
// Adjust the range to not contain any partially selected tables, since
// we don't want to run our custom remove formatting on them.
var savedCaretRange = this.adjustRangeForTables_(range,
startInTable, endInTable);
// Hack alert!!
// If start is not in a table, then the saved caret will get sent out
// for uber remove formatting, and it will get blown away. This is
// fine, except that we need to be able to re-create a range from the
// savedCaretRange later on. So, we just remove it from the dom, and
// put it back later so we can create a range later (not exactly in the
// same spot, but don't worry we don't actually try to use it later)
// and then it will be removed when we dispose the range.
if (!startInTable) {
this.putCaretInCave_(savedCaretRange, true);
}
if (!endInTable) {
this.putCaretInCave_(savedCaretRange, false);
}
// Re-fetch the range, and re-expand it, since we just modified it.
range = this.getFieldObject().getRange();
expandedRange = goog.editor.range.expand(range,
this.getFieldObject().getElement());
}
expandedRange.select();
range = expandedRange;
}
// Convert the selected text to the format-less version, paste back into
// the selection.
var text = this.getHtmlText_(range);
this.pasteHtml_(convertFunc(text));
if (goog.userAgent.GECKO && savedCaretRange) {
// If we moved the selection, move it back so the user can't tell we did
// anything crazy and so the browser removeFormat that we call next
// will operate on the entire originally selected range.
range = this.getFieldObject().getRange();
this.restoreCaretsFromCave_();
var realSavedCaretRange = savedCaretRange.toAbstractRange();
var startRange = startInTable ? realSavedCaretRange : range;
var endRange = endInTable ? realSavedCaretRange : range;
var restoredRange =
goog.editor.plugins.RemoveFormatting.createRangeDelimitedByRanges_(
startRange, endRange);
restoredRange.select();
savedCaretRange.dispose();
}
};
/**
* Does a best-effort attempt at clobbering all formatting that the
* browser's execCommand couldn't clobber without being totally inefficient.
* Attempts to convert visual line breaks to BRs. Leaves anchors that contain an
* href and images.
* Adapted from Gmail's MessageUtil's htmlToPlainText. http://go/messageutil.js
* @param {string} html The original html of the message.
* @return {string} The unformatted html, which is just text, br's, anchors and
* images.
* @private
*/
goog.editor.plugins.RemoveFormatting.prototype.removeFormattingWorker_ =
function(html) {
var el = goog.dom.createElement('div');
el.innerHTML = html;
// Put everything into a string buffer to avoid lots of expensive string
// concatenation along the way.
var sb = [];
var stack = [el.childNodes, 0];
// Keep separate stacks for places where we need to keep track of
// how deeply embedded we are. These are analogous to the general stack.
var preTagStack = [];
var preTagLevel = 0; // Length of the prestack.
var tableStack = [];
var tableLevel = 0;
// sp = stack pointer, pointing to the stack array.
// decrement by 2 since the stack alternates node lists and
// processed node counts
for (var sp = 0; sp >= 0; sp -= 2) {
// Check if we should pop the table level.
var changedLevel = false;
while (tableLevel > 0 && sp <= tableStack[tableLevel - 1]) {
tableLevel--;
changedLevel = true;
}
if (changedLevel) {
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
}
// Check if we should pop the <pre>/<xmp> level.
changedLevel = false;
while (preTagLevel > 0 && sp <= preTagStack[preTagLevel - 1]) {
preTagLevel--;
changedLevel = true;
}
if (changedLevel) {
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
}
// The list of of nodes to process at the current stack level.
var nodeList = stack[sp];
// The number of nodes processed so far, stored in the stack immediately
// following the node list for that stack level.
var numNodesProcessed = stack[sp + 1];
while (numNodesProcessed < nodeList.length) {
var node = nodeList[numNodesProcessed++];
var nodeName = node.nodeName;
var formatted = this.getValueForNode(node);
if (goog.isDefAndNotNull(formatted)) {
sb.push(formatted);
continue;
}
// TODO(user): Handle case 'EMBED' and case 'OBJECT'.
switch (nodeName) {
case '#text':
// Note that IE does not preserve whitespace in the dom
// values, even in a pre tag, so this is useless for IE.
var nodeValue = preTagLevel > 0 ?
node.nodeValue :
goog.string.stripNewlines(node.nodeValue);
nodeValue = goog.string.htmlEscape(nodeValue);
sb.push(nodeValue);
continue;
case goog.dom.TagName.P:
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
break; // break (not continue) so that child nodes are processed.
case goog.dom.TagName.BR:
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
continue;
case goog.dom.TagName.TABLE:
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
tableStack[tableLevel++] = sp;
break;
case goog.dom.TagName.PRE:
case 'XMP':
// This doesn't fully handle xmp, since
// it doesn't actually ignore tags within the xmp tag.
preTagStack[preTagLevel++] = sp;
break;
case goog.dom.TagName.STYLE:
case goog.dom.TagName.SCRIPT:
case goog.dom.TagName.SELECT:
continue;
case goog.dom.TagName.A:
if (node.href && node.href != '') {
sb.push("<a href='");
sb.push(node.href);
sb.push("'>");
sb.push(this.removeFormattingWorker_(node.innerHTML));
sb.push('</a>');
continue; // Children taken care of.
} else {
break; // Take care of the children.
}
case goog.dom.TagName.IMG:
sb.push("<img src='");
sb.push(node.src);
sb.push("'");
// border=0 is a common way to not show a blue border around an image
// that is wrapped by a link. If we remove that, the blue border will
// show up, which to the user looks like adding format, not removing.
if (node.border == '0') {
sb.push(" border='0'");
}
sb.push('>');
continue;
case goog.dom.TagName.TD:
// Don't add a space for the first TD, we only want spaces to
// separate td's.
if (node.previousSibling) {
sb.push(' ');
}
break;
case goog.dom.TagName.TR:
// Don't add a newline for the first TR.
if (node.previousSibling) {
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
}
break;
case goog.dom.TagName.DIV:
var parent = node.parentNode;
if (parent.firstChild == node &&
goog.editor.plugins.RemoveFormatting.BLOCK_RE_.test(
parent.tagName)) {
// If a DIV is the first child of another element that itself is a
// block element, the DIV does not add a new line.
break;
}
// Otherwise, the DIV does add a new line. Fall through.
default:
if (goog.editor.plugins.RemoveFormatting.BLOCK_RE_.test(nodeName)) {
goog.editor.plugins.RemoveFormatting.appendNewline_(sb);
}
}
// Recurse down the node.
var children = node.childNodes;
if (children.length > 0) {
// Push the current state on the stack.
stack[sp++] = nodeList;
stack[sp++] = numNodesProcessed;
// Iterate through the children nodes.
nodeList = children;
numNodesProcessed = 0;
}
}
}
// Replace &nbsp; with white space.
return goog.string.normalizeSpaces(sb.join(''));
};
/**
* Handle per node special processing if neccessary. If this function returns
* null then standard cleanup is applied. Otherwise this node and all children
* are assumed to be cleaned.
* NOTE(user): If an alternate RemoveFormatting processor is provided
* (setRemoveFormattingFunc()), this will no longer work.
* @param {Element} node The node to clean.
* @return {?string} The HTML strig representation of the cleaned data.
*/
goog.editor.plugins.RemoveFormatting.prototype.getValueForNode = function(
node) {
return null;
};
/**
* Sets a function to be used for remove formatting.
* @param {function(string): string} removeFormattingFunc - A function that
* takes a string of html and returns a string of html that does any other
* formatting changes desired. Use this only if trogedit's behavior doesn't
* meet your needs.
*/
goog.editor.plugins.RemoveFormatting.prototype.setRemoveFormattingFunc =
function(removeFormattingFunc) {
this.optRemoveFormattingFunc_ = removeFormattingFunc;
};
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<!--
This test has not yet been updated to run on IE8. See http://b/hotlist?id=36311
-->
<!--meta http-equiv="X-UA-Compatible" content="IE=edge"-->
<meta http-equiv="X-UA-Compatible" content="IE=EmulateIE7" />
<title>
goog.editor.plugins.RemoveFormatting Tests
</title>
<script type="text/javascript" src="../../base.js">
</script>
<script type="text/javascript">
goog.require('goog.editor.plugins.RemoveFormattingTest');
</script>
</head>
<body>
<!--
This wrapper table is outside the mock editor and only used ensure that it is
ignored by the tests.
-->
<table>
<tr>
<td>
<div id="html">
</div>
</td>
</tr>
</table>
<div id="abcde">abcde</div>
</body>
</html>
@@ -0,0 +1,955 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.RemoveFormattingTest');
goog.setTestOnly('goog.editor.plugins.RemoveFormattingTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.plugins.RemoveFormatting');
goog.require('goog.string');
goog.require('goog.testing.ExpectedFailures');
goog.require('goog.testing.dom');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var SAVED_HTML;
var FIELDMOCK;
var FORMATTER;
var testHelper;
var WEBKIT_BEFORE_CHROME_8;
var WEBKIT_AFTER_CHROME_16;
var WEBKIT_AFTER_CHROME_21;
var insertImageBoldGarbage = '';
var insertImageFontGarbage = '';
var controlHtml;
var controlCleanHtml;
var expectedFailures;
function setUpPage() {
WEBKIT_BEFORE_CHROME_8 = goog.userAgent.WEBKIT &&
!goog.userAgent.isVersionOrHigher('534.10');
WEBKIT_AFTER_CHROME_16 = goog.userAgent.WEBKIT &&
goog.userAgent.isVersionOrHigher('535.7');
WEBKIT_AFTER_CHROME_21 = goog.userAgent.WEBKIT &&
goog.userAgent.isVersionOrHigher('537.1');
// On Chrome 16, execCommand('insertImage') inserts a garbage BR
// after the image that we insert. We use this command to paste HTML
// in-place, because it has better paragraph-preserving semantics.
//
// TODO(nicksantos): Figure out if there are better chrome APIs that we
// should be using, or if insertImage should just be fixed.
if (WEBKIT_AFTER_CHROME_21) {
insertImageBoldGarbage = '<br>';
insertImageFontGarbage = '<br>';
} else if (WEBKIT_AFTER_CHROME_16) {
insertImageBoldGarbage = '<b><br/></b>';
insertImageFontGarbage = '<font size="1"><br/></font>';
}
// Extra html to add to test html to make sure removeformatting is actually
// getting called when you're testing if it leaves certain styles alone
// (instead of not even running at all due to some other bug). However, adding
// this extra text into the node to be selected screws up IE.
// (e.g. <a><img></a><b>t</b> --> <a></a><a><img></a>t )
// TODO(user): Remove this special casing once http://b/3131117 is
// fixed.
controlHtml = goog.userAgent.IE ? '' : '<u>control</u>';
controlCleanHtml = goog.userAgent.IE ? '' : 'control';
expectedFailures = new goog.testing.ExpectedFailures();
}
function setUp() {
testHelper = new goog.testing.editor.TestHelper(
document.getElementById('html'));
testHelper.setUpEditableElement();
FIELDMOCK = new goog.testing.editor.FieldMock();
FIELDMOCK.getElement();
FIELDMOCK.$anyTimes();
FIELDMOCK.$returns(document.getElementById('html'));
FORMATTER = new goog.editor.plugins.RemoveFormatting();
FORMATTER.fieldObject = FIELDMOCK;
FIELDMOCK.$replay();
}
function tearDown() {
expectedFailures.handleTearDown();
testHelper.tearDownEditableElement();
}
function setUpTableTests() {
var div = document.getElementById('html');
div.innerHTML = '<table><tr> <th> head1</th><th id= "outerTh">' +
'<span id="emptyTh">head2</span></th> </tr><tr> <td> one </td> <td>' +
'two </td> </tr><tr><td> three</td><td id="outerTd"> ' +
'<span id="emptyTd"><strong>four</strong></span></td></tr>' +
'<tr id="outerTr"><td><span id="emptyTr"> five </span></td></tr>' +
'<tr id="outerTr2"><td id="cell1"><b>seven</b></td><td id="cell2">' +
'<u>eight</u><span id="cellspan2"> foo</span></td></tr></table>';
}
function testTableTagsAreNotRemoved() {
setUpTableTests();
var span;
// TD
span = document.getElementById('emptyTd');
goog.dom.Range.createFromNodeContents(span).select();
FORMATTER.removeFormatting_();
var elem = document.getElementById('outerTd');
assert('TD should not be removed', !!elem);
if (!goog.userAgent.WEBKIT) {
// webkit seems to have an Apple-style-span
assertEquals('TD should be clean', 'four',
goog.string.trim(elem.innerHTML));
}
// TR
span = document.getElementById('outerTr');
goog.dom.Range.createFromNodeContents(span).select();
FORMATTER.removeFormatting_();
var elem = document.getElementById('outerTr');
assert('TR should not be removed', !!elem);
// TH
span = document.getElementById('emptyTh');
goog.dom.Range.createFromNodeContents(span).select();
FORMATTER.removeFormatting_();
var elem = document.getElementById('outerTh');
assert('TH should not be removed', !!elem);
if (!goog.userAgent.WEBKIT) {
// webkit seems to have an Apple-style-span
assertEquals('TH should be clean', 'head2', elem.innerHTML);
}
}
/**
* We select two cells from the table and then make sure that there is no
* data loss and basic formatting is removed from each cell.
*/
function testTableDataIsNotRemoved() {
setUpTableTests();
if (goog.userAgent.IE) {
// IE returns an "unspecified error" which seems to be beyond
// ExpectedFailures' ability to catch.
return;
}
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'The content moves out of the table in WEBKIT.');
if (goog.userAgent.IE) {
// Not used since we bail out early for IE, but this is there so that
// developers can easily reproduce IE error.
goog.dom.Range.createFromNodeContents(
document.getElementById('outerTr2')).select();
} else {
var selection = window.getSelection();
if (selection.rangeCount > 0) selection.removeAllRanges();
var range = document.createRange();
range.selectNode(document.getElementById('cell1'));
selection.addRange(range);
range = document.createRange();
range.selectNode(document.getElementById('cell2'));
selection.addRange(range);
}
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
span = document.getElementById('outerTr2');
assertEquals('Table data should not be removed',
'<td id="cell1">seven</td><td id="cell2">eight foo</td>',
span.innerHTML);
});
}
function testLinksAreNotRemoved() {
expectedFailures.expectFailureFor(WEBKIT_BEFORE_CHROME_8,
'WebKit\'s removeFormatting command removes links.');
var anchor;
var div = document.getElementById('html');
div.innerHTML = 'Foo<span id="link">Pre<a href="http://www.google.com">' +
'Outside Span<span style="font-size:15pt">Inside Span' +
'</span></a></span>';
anchor = document.getElementById('link');
goog.dom.Range.createFromNodeContents(anchor).select();
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
assertHTMLEquals('link should not be removed',
'FooPre<a href="http://www.google.com/">Outside SpanInside Span</a>',
div.innerHTML);
});
}
/**
* A short formatting removal function for use with the RemoveFormatting
* plugin. Does enough that we can tell this function was run over the
* document.
* @param {string} text The HTML in from the document.
* @return {string} The "cleaned" HTML out.
*/
function replacementFormattingFunc(text) {
// Really basic so that we can just see this is executing.
return text.replace(/Foo/gi, 'Bar').replace(/<[\/]*span[^>]*>/gi, '');
}
function testAlternateRemoveFormattingFunction() {
var div = document.getElementById('html');
div.innerHTML = 'Start<span id="remFormat">Foo<pre>Bar</pre>Baz</span>';
FORMATTER.setRemoveFormattingFunc(replacementFormattingFunc);
var area = document.getElementById('remFormat');
goog.dom.Range.createFromNodeContents(area).select();
FORMATTER.removeFormatting_();
// Webkit will change all tags to non-formatted ones anyway.
// Make sure 'Foo' was changed to 'Bar'
if (WEBKIT_BEFORE_CHROME_8) {
assertHTMLEquals('regular cleaner should not have run',
'StartBar<br>Bar<br>Baz',
div.innerHTML);
} else {
assertHTMLEquals('regular cleaner should not have run',
'StartBar<pre>Bar</pre>Baz',
div.innerHTML);
}
}
function testGetValueForNode() {
// Override getValueForNode to keep bold tags.
var oldGetValue =
goog.editor.plugins.RemoveFormatting.prototype.getValueForNode;
goog.editor.plugins.RemoveFormatting.prototype.getValueForNode =
function(node) {
if (node.nodeName == goog.dom.TagName.B) {
return '<b>' + this.removeFormattingWorker_(node.innerHTML) + '</b>';
}
return null;
};
var html = FORMATTER.removeFormattingWorker_('<div>foo<b>bar</b></div>');
assertHTMLEquals('B tags should remain', 'foo<b>bar</b>', html);
// Override getValueForNode to throw out bold tags, and their contents.
goog.editor.plugins.RemoveFormatting.prototype.getValueForNode =
function(node) {
if (node.nodeName == goog.dom.TagName.B) {
return '';
}
return null;
};
html = FORMATTER.removeFormattingWorker_('<div>foo<b>bar</b></div>');
assertHTMLEquals('B tag and its contents should be removed', 'foo', html);
FIELDMOCK.$verify();
goog.editor.plugins.RemoveFormatting.prototype.getValueForNode =
oldGetValue;
}
function testRemoveFormattingAddsNoNbsps() {
var div = document.getElementById('html');
div.innerHTML = '"<span id="toStrip">Twin <b>Cinema</b></span>"';
var span = document.getElementById('toStrip');
goog.dom.Range.createFromNodeContents(span).select();
FORMATTER.removeFormatting_();
assertEquals('Text should be the same, with no non-breaking spaces',
'"Twin Cinema"', div.innerHTML);
FIELDMOCK.$verify();
}
/**
* @bug 992795
*/
function testRemoveFormattingNestedDivs() {
var html = FORMATTER.removeFormattingWorker_(
'<div>1</div><div><div>2</div></div>');
goog.testing.dom.assertHtmlMatches('1<br>2', html);
}
/**
* Test that when we perform remove formatting on an entire table,
* that the visual look is similiar to as if there was a table there.
*/
function testRemoveFormattingForTableFormatting() {
// We preserve the table formatting as much as possible.
// Spaces separate TD's, <br>'s separate TR's.
// <br>'s separate the start and end of a table.
var html = '<table><tr><td>cell00</td><td>cell01</td></tr>' +
'<tr><td>cell10</td><td>cell11</td></tr></table>';
html = FORMATTER.removeFormattingWorker_(html);
assertHTMLEquals('<br>cell00 cell01<br>cell10 cell11<br>', html);
}
/**
* @bug 1319715
*/
function testRemoveFormattingDoesNotShrinkSelection() {
var div = document.getElementById('html');
div.innerHTML = '<div>l </div><div><br><b>a</b>foo bar</div>';
var div2 = div.lastChild;
goog.dom.Range.createFromNodes(div2.firstChild, 0,
div2.lastChild, 7).select();
FORMATTER.removeFormatting_();
var range = goog.dom.Range.createFromWindow();
assertEquals('Correct text should be selected', 'afoo bar',
range.getText());
// We have to trim out the leading BR in IE due to execCommand issues,
// so it isn't sent off to the removeFormattingWorker.
// Workaround for broken removeFormat in old webkit added an extra
// <br> to the end of the html.
var html = '<div>l </div><br class="GECKO WEBKIT">afoo bar' +
(goog.editor.BrowserFeature.ADDS_NBSPS_IN_REMOVE_FORMAT ? '<br>' : '');
goog.testing.dom.assertHtmlContentsMatch(html, div);
FIELDMOCK.$verify();
}
/**
* @bug 1447374
*/
function testInsideListRemoveFormat() {
var div = document.getElementById('html');
div.innerHTML = '<ul><li>one</li><li><b>two</b></li><li>three</li></ul>';
var twoLi = div.firstChild.childNodes[1];
goog.dom.Range.createFromNodeContents(twoLi).select();
expectedFailures.expectFailureFor(goog.userAgent.IE,
'IE adds the "two" to the "three" li, and leaves empty B tags.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'WebKit leave the "two" orphaned outside of an li but ' +
'inside the ul (invalid HTML).');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Test that we split the list.
assertHTMLEquals('<ul><li>one</li></ul><br>two<ul><li>three</li></ul>',
div.innerHTML);
FIELDMOCK.$verify();
});
}
function testFullListRemoveFormat() {
var div = document.getElementById('html');
div.innerHTML =
'<ul><li>one</li><li><b>two</b></li><li>three</li></ul>after';
goog.dom.Range.createFromNodeContents(div.firstChild).select();
// Note: This may just be a createFromNodeContents issue, as
// I can't ever make this happen with real user selection.
expectedFailures.expectFailureFor(goog.userAgent.IE,
'IE combines everything into a single LI and leaves the UL.');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Test that we completely remove the list.
assertHTMLEquals('<br>one<br>two<br>threeafter',
div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 1440935
*/
function testPartialListRemoveFormat() {
var div = document.getElementById('html');
div.innerHTML =
'<ul><li>one</li><li>two</li><li>three</li></ul>after';
// Select "two three after".
goog.dom.Range.createFromNodes(div.firstChild.childNodes[1], 0,
div.lastChild, 5).select();
expectedFailures.expectFailureFor(goog.userAgent.IE,
'IE leaves behind an empty LI.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'WebKit completely loses the "one".');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Test that we leave the list start alone.
assertHTMLEquals('<ul><li>one</li></ul><br>two<br>threeafter',
div.innerHTML);
FIELDMOCK.$verify();
});
}
function testBasicRemoveFormatting() {
// IE will clobber the editable div.
// Note: I can't repro this using normal user selections.
if (goog.userAgent.IE) {
return;
}
var div = document.getElementById('html');
div.innerHTML = '<b>bold<i>italic</i></b>';
goog.dom.Range.createFromNodeContents(div).select();
expectedFailures.expectFailureFor(
goog.editor.BrowserFeature.ADDS_NBSPS_IN_REMOVE_FORMAT,
'The workaround for the nbsp bug adds an extra br at the end.');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
assertHTMLEquals('bolditalic' + insertImageBoldGarbage,
div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 1480260
*/
function testPartialBasicRemoveFormatting() {
var div = document.getElementById('html');
div.innerHTML = '<b>bold<i>italic</i></b>';
goog.dom.Range.createFromNodes(div.firstChild.firstChild, 2,
div.firstChild.lastChild.firstChild, 3).select();
expectedFailures.expectFailureFor(WEBKIT_BEFORE_CHROME_8,
'WebKit just gets this all wrong. Everything stays bold and ' +
'"lditalic" gets italicised.');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
assertHTMLEquals('<b>bo</b>ldita<b><i>lic</i></b>',
div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 3075557
*/
function testRemoveFormattingLinkedImageBorderZero() {
var testHtml = '<a href="http://www.google.com/">' +
'<img src="http://www.google.com/images/logo.gif" border="0"></a>';
var div = document.getElementById('html');
div.innerHTML = testHtml + controlHtml;
goog.dom.Range.createFromNodeContents(div).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'WebKit removes the image entirely, see ' +
'https://bugs.webkit.org/show_bug.cgi?id=13125 .');
expectedFailures.run(function() {
assertHTMLEquals(
'Image\'s border=0 should not be removed during remove formatting',
testHtml + controlCleanHtml, div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 3075557
*/
function testRemoveFormattingLinkedImageBorderNonzero() {
var testHtml = '<a href="http://www.google.com/">' +
'<img src="http://www.google.com/images/logo.gif" border="1"></a>';
var div = document.getElementById('html');
div.innerHTML = testHtml + controlHtml;
goog.dom.Range.createFromNodeContents(div).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'WebKit removes the image entirely, see ' +
'https://bugs.webkit.org/show_bug.cgi?id=13125 .');
expectedFailures.run(function() {
assertHTMLEquals(
'Image\'s border should be removed during remove formatting' +
' if non-zero',
testHtml.replace(' border="1"', '') + controlCleanHtml,
div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 3075557
*/
function testRemoveFormattingUnlinkedImage() {
var testHtml =
'<img src="http://www.google.com/images/logo.gif" border="0">';
var div = document.getElementById('html');
div.innerHTML = testHtml + controlHtml;
goog.dom.Range.createFromNodeContents(div).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'WebKit removes the image entirely, see ' +
'https://bugs.webkit.org/show_bug.cgi?id=13125 .');
expectedFailures.run(function() {
assertHTMLEquals(
'Image\'s border=0 should not be removed during remove formatting' +
' even if not wrapped by a link',
testHtml + controlCleanHtml, div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* @bug 3075557
*/
function testRemoveFormattingLinkedImageDeep() {
var testHtml = '<a href="http://www.google.com/"><b>hello' +
'<img src="http://www.google.com/images/logo.gif" border="0">' +
'world</b></a>';
var div = document.getElementById('html');
div.innerHTML = testHtml + controlHtml;
goog.dom.Range.createFromNodeContents(div).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(WEBKIT_BEFORE_CHROME_8,
'WebKit removes the image entirely, see ' +
'https://bugs.webkit.org/show_bug.cgi?id=13125 .');
expectedFailures.run(function() {
assertHTMLEquals(
'Image\'s border=0 should not be removed during remove formatting' +
' even if deep inside anchor tag',
testHtml.replace(/<\/?b>/g, '') +
controlCleanHtml + insertImageBoldGarbage,
div.innerHTML);
FIELDMOCK.$verify();
});
}
function testFullTableRemoveFormatting() {
// Something goes horrible wrong in case 1 below. It was crashing all
// WebKit browsers, and now seems to be giving errors as it is trying
// to perform remove formatting on the little expected failures window
// instead of the dom we select. WTF. Since I'm gutting this code,
// I'm not going to look into this anymore right now. For what its worth,
// I can't repro any issues in standalone TrogEdit.
if (goog.userAgent.WEBKIT) {
return;
}
var div = document.getElementById('html');
// WebKit has an extra BR in case 2.
expectedFailures.expectFailureFor(goog.userAgent.IE,
'IE clobbers the editable node in case 2 (can\'t repro with real ' +
'user selections). IE doesn\'t remove the table in case 1.');
expectedFailures.run(function() {
// When a full table is selected, we remove it completely.
div.innerHTML = 'foo<table><tr><td>bar</td></tr></table>baz1';
goog.dom.Range.createFromNodeContents(div.childNodes[1]).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('foo<br>bar<br>baz1', div.innerHTML);
FIELDMOCK.$verify();
// Remove the full table when it is selected with additional
// contents too.
div.innerHTML = 'foo<table><tr><td>bar</td></tr></table>baz2';
goog.dom.Range.createFromNodes(div.firstChild, 0,
div.lastChild, 1).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('foo<br>bar<br>baz2', div.innerHTML);
FIELDMOCK.$verify();
// We should still remove the table, even if the selection is inside the
// table and it is fully selected.
div.innerHTML = 'foo<table><tr><td id=\'td\'>bar</td></tr></table>baz3';
goog.dom.Range.createFromNodeContents(
goog.dom.getElement('td').firstChild).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('foo<br>bar<br>baz3', div.innerHTML);
FIELDMOCK.$verify();
});
}
function testInsideTableRemoveFormatting() {
var div = document.getElementById('html');
div.innerHTML =
'<table><tr><td><b id="b">foo</b></td></tr><tr><td>ba</td></tr></table>';
goog.dom.Range.createFromNodeContents(goog.dom.getElement('b')).select();
// Webkit adds some apple style span crap during execCommand("removeFormat")
// Our workaround for the nbsp bug removes these, but causes worse problems.
// See bugs.webkit.org/show_bug.cgi?id=29164 for more details.
expectedFailures.expectFailureFor(
WEBKIT_BEFORE_CHROME_8 &&
!goog.editor.BrowserFeature.ADDS_NBSPS_IN_REMOVE_FORMAT,
'Extra apple-style-spans');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Only remove styling from inside tables.
assertHTMLEquals(
'<table><tr><td>foo' + insertImageBoldGarbage +
'</td></tr><tr><td>ba</td></tr></table>',
div.innerHTML);
FIELDMOCK.$verify();
});
}
function testPartialTableRemoveFormatting() {
if (goog.userAgent.IE) {
// IE returns an "unspecified error" which seems to be beyond
// ExpectedFailures' ability to catch.
return;
}
var div = document.getElementById('html');
div.innerHTML = 'bar<table><tr><td><b id="b">foo</b></td></tr>' +
'<tr><td><i>banana</i></td></tr></table><div id="baz">' +
'baz</div>';
// Select from the "oo" inside the b tag to the end of "baz".
goog.dom.Range.createFromNodes(goog.dom.getElement('b').firstChild, 1,
goog.dom.getElement('baz').firstChild, 3).select();
// All browsers currently clobber the table cells that are selected.
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT);
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Only remove styling from inside tables.
assertHTMLEquals('bar<table><tr><td><b id="b">f</b>oo</td></tr>' +
'<tr><td>banana</td></tr></table>baz', div.innerHTML);
FIELDMOCK.$verify();
});
}
// Runs tests knowing some browsers will fail, because the new
// table functionality hasn't been implemented in them yet.
function runExpectingFailuresForUnimplementedBrowsers(func) {
if (goog.userAgent.IE) {
// IE returns an "unspecified error" which seems to be beyond
// ExpectedFailures' ability to catch.
return;
}
expectedFailures.expectFailureFor(goog.userAgent.IE,
'Proper behavior not yet implemented for IE.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'Proper behavior not yet implemented for WebKit.');
expectedFailures.run(func);
}
function testTwoTablesSelectedFullyRemoveFormatting() {
runExpectingFailuresForUnimplementedBrowsers(function() {
var div = document.getElementById('html');
// When two tables are fully selected, we remove them completely.
div.innerHTML = '<table><tr><td>foo</td></tr></table>' +
'<table><tr><td>bar</td></tr></table>';
goog.dom.Range.createFromNodes(div.firstChild, 0,
div.lastChild, 1).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('<br>foo<br><br>bar<br>', div.innerHTML);
FIELDMOCK.$verify();
});
}
function testTwoTablesSelectedFullyInsideRemoveFormatting() {
if (goog.userAgent.WEBKIT) {
// Something goes very wrong here, but it did before
// Julie started writing v2. Will address when converting
// safari to v2.
return;
}
runExpectingFailuresForUnimplementedBrowsers(function() {
var div = document.getElementById('html');
// When two tables are selected from inside but fully,
// also remove them completely.
div.innerHTML = '<table><tr><td id="td1">foo</td></tr></table>' +
'<table><tr><td id="td2">bar</td></tr></table>';
goog.dom.Range.createFromNodes(goog.dom.getElement('td1').firstChild, 0,
goog.dom.getElement('td2').firstChild, 3).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('<br>foo<br><br>bar<br>', div.innerHTML);
FIELDMOCK.$verify();
});
}
function testTwoTablesSelectedFullyAndPartiallyRemoveFormatting() {
runExpectingFailuresForUnimplementedBrowsers(function() {
var div = document.getElementById('html');
// Two tables selected, one fully, one partially. Remove
// only the fully selected one and remove styles only from
// partially selected one.
div.innerHTML = '<table><tr><td id="td1">foo</td></tr></table>' +
'<table><tr><td id="td2"><b>bar<b></td></tr></table>';
goog.dom.Range.createFromNodes(goog.dom.getElement('td1').firstChild, 0,
goog.dom.getElement('td2').firstChild.firstChild, 2).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('<br>foo<br>' +
'<table><tr><td id="td2">ba<b>r</b></td></tr></table>',
div.innerHTML);
FIELDMOCK.$verify();
});
}
function testTwoTablesSelectedPartiallyRemoveFormatting() {
runExpectingFailuresForUnimplementedBrowsers(function() {
var div = document.getElementById('html');
// Two tables selected, both partially. Don't remove tables,
// but remove styles.
div.innerHTML = '<table><tr><td id="td1">f<i>o</i>o</td></tr></table>' +
'<table><tr><td id="td2">b<b>a</b>r</td></tr></table>';
goog.dom.Range.createFromNodes(goog.dom.getElement('td1').firstChild, 1,
goog.dom.getElement('td2').childNodes[1], 1).select();
FORMATTER.removeFormatting_();
assertHTMLEquals('<table><tr><td id="td1">foo</td></tr></table>' +
'<table><tr><td id="td2">bar</td></tr></table>',
div.innerHTML);
FIELDMOCK.$verify();
});
}
/**
* Test a random snippet from Google News (Google News has complicated
* dom structure, including tables, links, images, etc).
*/
function testRandomGoogleNewsSnippetRemoveFormatting() {
if (goog.userAgent.IE) {
// IE returns an "unspecified error" which seems to be beyond
// ExpectedFailures' ability to catch.
return;
}
var div = document.getElementById('html');
div.innerHTML =
'<font size="-3"><br></font><table align="right" border="0" ' +
'cellpadding="0" cellspacing="0"><tbody><tr><td style="padding-left:' +
'6px;" valign="top" width="80" align="center"><a href="http://www.wash' +
'ingtonpost.com/wp-dyn/content/article/2008/11/11/AR2008111101090.htm' +
'l" + id="s-skHRvWH7ryqkcA4caGv0QQ:u-AFQjCNG3vx1HJOxKxMQPzCvYOVRE0JUDe' +
'Q:r-1-0i_1268233361_6_H0_MH20_PL60"><img src="http://news.google.com/' +
'news?imgefp=4LFiNNP62TgJ&amp;imgurl=media3.washingtonpost.com/wp-dyn/' +
'content/photo/2008/11/11/PH2008111101091.jpg" alt="" width="60" ' +
'border="1" height="80"><br><font size="-2">Washington Post</font></a>' +
'</td></tr></tbody></table><a href="http://www.nme.com/news/britney-' +
'spears/40995" id="s-xZUO-t0c1IpsVjyJj0rgxw:u-AFQjCNEZAMQCseEW6uTgXI' +
'iPvAMHe_0B4A:r-1-0_1268233361_6_H0_MH20_PL60"><b>Britney\'s son ' +
'released from hospital</b></a><br><font size="-1"><b><font color=' +
'"#6f6f6f">NME.com&nbsp;-</font> <nobr>53 minutes ago</nobr></b>' +
'</font><br><font size="-1">Britney Spears youngest son Jayden James ' +
'has been released from hospital, having been admitted on Sunday after' +
' suffering a severe reaction to something he ingested.</font><br><fon' +
'tsize="-1"><a href="http://www.celebrity-gossip.net/celebrities/holly' +
'wood/britney-and-jamie-lynn-spears-alligator-alley-208944/" id="s-nM' +
'PzHclcMG0J2WZkw9gnVQ:u-AFQjCNHal08usOQ5e5CAQsck2yGsTYeGVQ">Britney ' +
'and Jamie Lynn Spears: Alligator Alley!</a> <font size="-1" color=' +
'"#6f6f6f"><nobr>The Gossip Girls</nobr></font></font><br><font size=' +
'"-1"><a href="http://foodconsumer.org/7777/8888/Other_N_ews_51/111101' +
'362008_Allergy_incident_could_spell_custody_trouble_for_Britney_Spear' +
's.shtml" id="s-2lMNDY4joOprVvkkY_b-6A:u-AFQjCNGAeFNutMEbSg5zAvrh5reBF' +
'lqUmA">Allergy incident could spell trouble for Britney Spears</a> ' +
'<font size="-1" color="#6f6f6f"><nobr>Food Consumer</nobr></font>' +
'</font><br><font class="p" size="-1"><a href="http://www.people.com/' +
'people/article/0,,20239458,00.html" id="s-x9thwVUYVET0ZJOnkkcsjw:u-A' +
'FQjCNE99eijVIrezr9AFRjLkmo5j_Jr7A"><nobr>People Magazine</nobr></a>&nb' +
'sp;- <a href="http://www.eonline.com/uberblog/b68226_hospital_run_cou' +
'ld_cost_britney_custody.html" id="s-kYt5LHDhlDnhUL9kRLuuwA:u-AFQjCNF8' +
'8eOy2utriYuF0icNrZQPzwK8gg"><nobr>E! Online</nobr></a>&nbsp;- <a href' +
'="http://justjared.buzznet.com/2008/11/11/britney-spears-alligator-fa' +
'rm/" id="s--VDy1fyacNvaRo_aXb02Dw:u-AFQjCNEn0Rz3wg0PMwDdzKTDug-9k5W6y' +
'g"><nobr>Just Jared</nobr></a>&nbsp;- <a href="http://www.efluxmedia.' +
'com/news_Britney_Spears_Son_Released_from_Hospital_28696.html" id="s-' +
'8oX6hVDe4Qbcl1x5Rua_EA:u-AFQjCNEpn3nOHA8EB0pxJAPf6diOicMRDg"><nobr>eF' +
'luxMedia</nobr></a></font><br><font class="p" size="-1"><a class="p" ' +
'href="http://news.google.com/news?ncl=1268233361&amp;hl=en"><nobr><b>' +
'all 950 news articles&nbsp;</b></nobr></a></font>';
// Select it all.
goog.dom.Range.createFromNodeContents(div).select();
expectedFailures.expectFailureFor(WEBKIT_BEFORE_CHROME_8,
'WebKit barfs apple-style-spans all over the place, and removes links.');
expectedFailures.run(function() {
FORMATTER.removeFormatting_();
// Leave links and images alone, remove all other formatting.
assertHTMLEquals('<br><br><a href="http://www.washingtonpost.com/wp-dyn/' +
'content/article/2008/11/11/AR2008111101090.html"><img src="http://n' +
'ews.google.com/news?imgefp=4LFiNNP62TgJ&amp;imgurl=media3.washingto' +
'npost.com/wp-dyn/content/photo/2008/11/11/PH2008111101091.jpg"><br>' +
'Washington Post</a><br><a href="http://www.nme.com/news/britney-spe' +
'ars/40995">Britney\'s son released from hospital</a><br>NME.com - 5' +
'3 minutes ago<br>Britney Spears youngest son Jayden James has been' +
' released from hospital, having been admitted on Sunday after suffe' +
'ring a severe reaction to something he ingested.<br><a href="http:/' +
'/www.celebrity-gossip.net/celebrities/hollywood/britney-and-jamie-l' +
'ynn-spears-alligator-alley-208944/">Britney and Jamie Lynn Spears: ' +
'Alligator Alley!</a> The Gossip Girls<br><a href="http://foodconsum' +
'er.org/7777/8888/Other_N_ews_51/111101362008_Allergy_incident_could' +
'_spell_custody_trouble_for_Britney_Spears.shtml">Allergy incident c' +
'ould spell trouble for Britney Spears</a> Food Consumer<br><a href=' +
'"http://www.people.com/people/article/0,,20239458,00.html">People M' +
'agazine</a> - <a href="http://www.eonline.com/uberblog/b68226_hospi' +
'tal_run_could_cost_britney_custody.html">E! Online</a> - <a href="h' +
'ttp://justjared.buzznet.com/2008/11/11/britney-spears-alligator-far' +
'm/">Just Jared</a> - <a href="http://www.efluxmedia.com/news_Britne' +
'y_Spears_Son_Released_from_Hospital_28696.html">eFluxMedia</a><br><' +
'a href="http://news.google.com/news?ncl=1268233361&amp;hl=en">all 9' +
'50 news articles </a>' +
insertImageFontGarbage, div.innerHTML);
FIELDMOCK.$verify();
});
}
function testRangeDelimitedByRanges() {
var abcde = goog.dom.getElement('abcde').firstChild;
var start = goog.dom.Range.createFromNodes(abcde, 1, abcde, 2);
var end = goog.dom.Range.createFromNodes(abcde, 3, abcde, 4);
goog.testing.dom.assertRangeEquals(abcde, 1, abcde, 4,
goog.editor.plugins.RemoveFormatting.createRangeDelimitedByRanges_(
start, end));
}
function testGetTableAncestor() {
var div = document.getElementById('html');
div.innerHTML = 'foo<table><tr><td>foo</td></tr></table>bar';
assertTrue('Full table is in table',
!!FORMATTER.getTableAncestor_(div.childNodes[1]));
assertFalse('Outside of table',
!!FORMATTER.getTableAncestor_(div.firstChild));
assertTrue('Table cell is in table',
!!FORMATTER.getTableAncestor_(
div.childNodes[1].firstChild.firstChild.firstChild));
div.innerHTML = 'foo';
assertNull('No table inside field.',
FORMATTER.getTableAncestor_(div.childNodes[0]));
}
/**
* @bug 1272905
*/
function testHardReturnsInHeadersPreserved() {
var div = document.getElementById('html');
div.innerHTML = '<h1>abcd</h1><h2>efgh</h2><h3>ijkl</h3>';
// Select efgh.
goog.dom.Range.createFromNodeContents(div.childNodes[1]).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.IE,
'Proper behavior not yet implemented for IE.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'Proper behavior not yet implemented for WebKit.');
expectedFailures.run(function() {
assertHTMLEquals('<h1>abcd</h1><br>efgh<h3>ijkl</h3>', div.innerHTML);
});
// Select ijkl.
goog.dom.Range.createFromNodeContents(div.lastChild).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.IE,
'Proper behavior not yet implemented for IE.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'Proper behavior not yet implemented for WebKit.');
expectedFailures.run(function() {
assertHTMLEquals('<h1>abcd</h1><br>efgh<br>ijkl', div.innerHTML);
});
// Select abcd.
goog.dom.Range.createFromNodeContents(div.firstChild).select();
FORMATTER.removeFormatting_();
expectedFailures.expectFailureFor(goog.userAgent.IE,
'Proper behavior not yet implemented for IE.');
expectedFailures.expectFailureFor(goog.userAgent.WEBKIT,
'Proper behavior not yet implemented for WebKit.');
expectedFailures.run(function() {
assertHTMLEquals('<br>abcd<br>efgh<br>ijkl', div.innerHTML);
});
}
function testKeyboardShortcut_space() {
FIELDMOCK.$reset();
FIELDMOCK.execCommand(
goog.editor.plugins.RemoveFormatting.REMOVE_FORMATTING_COMMAND);
FIELDMOCK.$replay();
var e = {};
var key = ' ';
var result = FORMATTER.handleKeyboardShortcut(e, key, true);
assertTrue(result);
FIELDMOCK.$verify();
}
function testKeyboardShortcut_other() {
FIELDMOCK.$reset();
FIELDMOCK.$replay();
var e = {};
var key = 'a';
var result = FORMATTER.handleKeyboardShortcut(e, key, true);
assertFalse(result);
FIELDMOCK.$verify();
}
@@ -0,0 +1,92 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Editor plugin to handle tab keys not in lists to add 4 spaces.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.SpacesTabHandler');
goog.require('goog.dom.TagName');
goog.require('goog.editor.plugins.AbstractTabHandler');
goog.require('goog.editor.range');
/**
* Plugin to handle tab keys when not in lists to add 4 spaces.
* @constructor
* @extends {goog.editor.plugins.AbstractTabHandler}
* @final
*/
goog.editor.plugins.SpacesTabHandler = function() {
goog.editor.plugins.AbstractTabHandler.call(this);
};
goog.inherits(goog.editor.plugins.SpacesTabHandler,
goog.editor.plugins.AbstractTabHandler);
/** @override */
goog.editor.plugins.SpacesTabHandler.prototype.getTrogClassId = function() {
return 'SpacesTabHandler';
};
/** @override */
goog.editor.plugins.SpacesTabHandler.prototype.handleTabKey = function(e) {
var dh = this.getFieldDomHelper();
var range = this.getFieldObject().getRange();
if (!goog.editor.range.intersectsTag(range, goog.dom.TagName.LI)) {
// In the shift + tab case we don't want to insert spaces, but we don't
// want focus to move either so skip the spacing logic and just prevent
// default.
if (!e.shiftKey) {
// Not in a list but we want to insert 4 spaces.
// Stop change events while we make multiple field changes.
this.getFieldObject().stopChangeEvents(true, true);
// Inserting nodes below completely messes up the selection, doing the
// deletion here before it's messed up. Only delete if text is selected,
// otherwise we would remove the character to the right of the cursor.
if (!range.isCollapsed()) {
dh.getDocument().execCommand('delete', false, null);
// Safari 3 has some DOM exceptions if we don't reget the range here,
// doing it all the time just to be safe.
range = this.getFieldObject().getRange();
}
// Emulate tab by removing selection and inserting 4 spaces
// Two breaking spaces in a row can be collapsed by the browser into one
// space. Inserting the string below because it is guaranteed to never
// collapse to less than four spaces, regardless of what is adjacent to
// the inserted spaces. This might make line wrapping slightly
// sub-optimal around a grouping of non-breaking spaces.
var elem = dh.createDom('span', null, '\u00a0\u00a0 \u00a0');
elem = range.insertNode(elem, false);
this.getFieldObject().dispatchChange();
goog.editor.range.placeCursorNextTo(elem, false);
this.getFieldObject().dispatchSelectionChangeEvent();
}
e.preventDefault();
return true;
}
return false;
};
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<!--
@author robbyw@google.com (Robby Walker)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.plugins.SpacesTabHandler
</title>
<script src="../../base.js">
</script>
<script src="../../deps.js">
</script>
<script>
goog.require('goog.editor.plugins.SpacesTabHandlerTest');
</script>
</head>
<body>
<div id="field">
</div>
</body>
</html>
@@ -0,0 +1,174 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.SpacesTabHandlerTest');
goog.setTestOnly('goog.editor.plugins.SpacesTabHandlerTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.editor.plugins.SpacesTabHandler');
goog.require('goog.events.BrowserEvent');
goog.require('goog.events.KeyCodes');
goog.require('goog.functions');
goog.require('goog.testing.StrictMock');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
var field;
var editableField;
var tabHandler;
var testHelper;
function setUp() {
field = goog.dom.getElement('field');
editableField = new goog.testing.editor.FieldMock();
// Modal mode behavior tested in AbstractTabHandler.
editableField.inModalMode = goog.functions.FALSE;
testHelper = new goog.testing.editor.TestHelper(field);
testHelper.setUpEditableElement();
tabHandler = new goog.editor.plugins.SpacesTabHandler();
tabHandler.registerFieldObject(editableField);
}
function tearDown() {
editableField = null;
testHelper.tearDownEditableElement();
tabHandler.dispose();
}
function testSelectedTextIndent() {
field.innerHTML = 'Test';
var testText = field.firstChild;
testHelper.select(testText, 0, testText, 4);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.stopChangeEvents(true, true);
editableField.dispatchChange();
editableField.dispatchSelectionChangeEvent();
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event marked as handled',
tabHandler.handleKeyboardShortcut(event, '', false));
var contents = field.textContent || field.innerText;
// Chrome doesn't treat \u00a0 as a space.
assertTrue('Text should be replaced with 4 spaces but was: "' +
contents + '"',
/^(\s|\u00a0){4}$/.test(contents));
editableField.$verify();
event.$verify();
}
function testCursorIndent() {
field.innerHTML = 'Test';
var testText = field.firstChild;
testHelper.select(testText, 2, testText, 2);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.stopChangeEvents(true, true);
editableField.dispatchChange();
editableField.dispatchSelectionChangeEvent();
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event marked as handled',
tabHandler.handleKeyboardShortcut(event, '', false));
var contents = field.textContent || field.innerText;
assertTrue('Expected contents "Te st" but was: "' + contents + '"',
/Te[\s|\u00a0]{4}st/.test(contents));
editableField.$verify();
event.$verify();
}
function testShiftTabNoOp() {
field.innerHTML = 'Test';
range = goog.dom.Range.createFromNodeContents(field);
range.collapse();
range.select();
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = true;
event.preventDefault();
editableField.$replay();
event.$replay();
assertTrue('Event marked as handled',
tabHandler.handleKeyboardShortcut(event, '', false));
var contents = field.textContent || field.innerText;
assertEquals('Shift+tab should not change contents', 'Test', contents);
editableField.$verify();
event.$verify();
}
function testInListNoOp() {
field.innerHTML = '<ul><li>Test</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(testText, 2, testText, 2);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.$replay();
event.$replay();
assertFalse('Event must not be handled when selection inside list.',
tabHandler.handleKeyboardShortcut(event, '', false));
testHelper.assertHtmlMatches('<ul><li>Test</li></ul>');
editableField.$verify();
event.$verify();
}
function testContainsListNoOp() {
field.innerHTML = '<ul><li>Test</li></ul>';
var testText = field.firstChild.firstChild.firstChild; // div ul li Test
testHelper.select(field.firstChild, 0, testText, 2);
var event = new goog.testing.StrictMock(goog.events.BrowserEvent);
event.keyCode = goog.events.KeyCodes.TAB;
event.shiftKey = false;
editableField.$replay();
event.$replay();
assertFalse('Event must not be handled when selection inside list.',
tabHandler.handleKeyboardShortcut(event, '', false));
testHelper.assertHtmlMatches('<ul><li>Test</li></ul>');
editableField.$verify();
event.$verify();
}
@@ -0,0 +1,475 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Plugin that enables table editing.
*
* @see ../../demos/editor/tableeditor.html
*/
goog.provide('goog.editor.plugins.TableEditor');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.Table');
goog.require('goog.editor.node');
goog.require('goog.editor.range');
goog.require('goog.object');
goog.require('goog.userAgent');
/**
* Plugin that adds support for table creation and editing commands.
* @constructor
* @extends {goog.editor.Plugin}
* @final
*/
goog.editor.plugins.TableEditor = function() {
goog.editor.plugins.TableEditor.base(this, 'constructor');
/**
* The array of functions that decide whether a table element could be
* editable by the user or not.
* @type {Array<function(Element):boolean>}
* @private
*/
this.isTableEditableFunctions_ = [];
/**
* The pre-bound function that decides whether a table element could be
* editable by the user or not overall.
* @type {function(Node):boolean}
* @private
*/
this.isUserEditableTableBound_ = goog.bind(this.isUserEditableTable_, this);
};
goog.inherits(goog.editor.plugins.TableEditor, goog.editor.Plugin);
/** @override */
// TODO(user): remove this once there's a sensible default
// implementation in the base Plugin.
goog.editor.plugins.TableEditor.prototype.getTrogClassId = function() {
return String(goog.getUid(this.constructor));
};
/**
* Commands supported by goog.editor.plugins.TableEditor.
* @enum {string}
*/
goog.editor.plugins.TableEditor.COMMAND = {
TABLE: '+table',
INSERT_ROW_AFTER: '+insertRowAfter',
INSERT_ROW_BEFORE: '+insertRowBefore',
INSERT_COLUMN_AFTER: '+insertColumnAfter',
INSERT_COLUMN_BEFORE: '+insertColumnBefore',
REMOVE_ROWS: '+removeRows',
REMOVE_COLUMNS: '+removeColumns',
SPLIT_CELL: '+splitCell',
MERGE_CELLS: '+mergeCells',
REMOVE_TABLE: '+removeTable'
};
/**
* Inverse map of execCommand strings to
* {@link goog.editor.plugins.TableEditor.COMMAND} constants. Used to
* determine whether a string corresponds to a command this plugin handles
* in O(1) time.
* @type {Object}
* @private
*/
goog.editor.plugins.TableEditor.SUPPORTED_COMMANDS_ =
goog.object.transpose(goog.editor.plugins.TableEditor.COMMAND);
/**
* Whether the string corresponds to a command this plugin handles.
* @param {string} command Command string to check.
* @return {boolean} Whether the string corresponds to a command
* this plugin handles.
* @override
*/
goog.editor.plugins.TableEditor.prototype.isSupportedCommand =
function(command) {
return command in goog.editor.plugins.TableEditor.SUPPORTED_COMMANDS_;
};
/** @override */
goog.editor.plugins.TableEditor.prototype.enable = function(fieldObject) {
goog.editor.plugins.TableEditor.base(this, 'enable', fieldObject);
// enableObjectResizing is supported only for Gecko.
// You can refer to http://qooxdoo.org/contrib/project/htmlarea/html_editing
// for a compatibility chart.
if (goog.userAgent.GECKO) {
var doc = this.getFieldDomHelper().getDocument();
doc.execCommand('enableObjectResizing', false, 'true');
}
};
/**
* Returns the currently selected table.
* @return {Element?} The table in which the current selection is
* contained, or null if there isn't such a table.
* @private
*/
goog.editor.plugins.TableEditor.prototype.getCurrentTable_ = function() {
var selectedElement = this.getFieldObject().getRange().getContainer();
return this.getAncestorTable_(selectedElement);
};
/**
* Finds the first user-editable table element in the input node's ancestors.
* @param {Node?} node The node to start with.
* @return {Element?} The table element that is closest ancestor of the node.
* @private
*/
goog.editor.plugins.TableEditor.prototype.getAncestorTable_ = function(node) {
var ancestor = goog.dom.getAncestor(node, this.isUserEditableTableBound_,
true);
if (goog.editor.node.isEditable(ancestor)) {
return /** @type {Element?} */(ancestor);
} else {
return null;
}
};
/**
* Returns the current value of a given command. Currently this plugin
* only returns a value for goog.editor.plugins.TableEditor.COMMAND.TABLE.
* @override
*/
goog.editor.plugins.TableEditor.prototype.queryCommandValue =
function(command) {
if (command == goog.editor.plugins.TableEditor.COMMAND.TABLE) {
return !!this.getCurrentTable_();
}
};
/** @override */
goog.editor.plugins.TableEditor.prototype.execCommandInternal = function(
command, opt_arg) {
var result = null;
// TD/TH in which to place the cursor, if the command destroys the current
// cursor position.
var cursorCell = null;
var range = this.getFieldObject().getRange();
if (command == goog.editor.plugins.TableEditor.COMMAND.TABLE) {
// Don't create a table if the cursor isn't in an editable region.
if (!goog.editor.range.isEditable(range)) {
return null;
}
// Create the table.
var tableProps = opt_arg || {width: 4, height: 2};
var doc = this.getFieldDomHelper().getDocument();
var table = goog.editor.Table.createDomTable(
doc, tableProps.width, tableProps.height);
range.replaceContentsWithNode(table);
// In IE, replaceContentsWithNode uses pasteHTML, so we lose our reference
// to the inserted table.
// TODO(user): use the reference to the table element returned from
// replaceContentsWithNode.
if (!goog.userAgent.IE) {
cursorCell = table.getElementsByTagName('td')[0];
}
} else {
var cellSelection = new goog.editor.plugins.TableEditor.CellSelection_(
range, goog.bind(this.getAncestorTable_, this));
var table = cellSelection.getTable();
if (!table) {
return null;
}
switch (command) {
case goog.editor.plugins.TableEditor.COMMAND.INSERT_ROW_BEFORE:
table.insertRow(cellSelection.getFirstRowIndex());
break;
case goog.editor.plugins.TableEditor.COMMAND.INSERT_ROW_AFTER:
table.insertRow(cellSelection.getLastRowIndex() + 1);
break;
case goog.editor.plugins.TableEditor.COMMAND.INSERT_COLUMN_BEFORE:
table.insertColumn(cellSelection.getFirstColumnIndex());
break;
case goog.editor.plugins.TableEditor.COMMAND.INSERT_COLUMN_AFTER:
table.insertColumn(cellSelection.getLastColumnIndex() + 1);
break;
case goog.editor.plugins.TableEditor.COMMAND.REMOVE_ROWS:
var startRow = cellSelection.getFirstRowIndex();
var endRow = cellSelection.getLastRowIndex();
if (startRow == 0 && endRow == (table.rows.length - 1)) {
// Instead of deleting all rows, delete the entire table.
return this.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_TABLE);
}
var startColumn = cellSelection.getFirstColumnIndex();
var rowCount = (endRow - startRow) + 1;
for (var i = 0; i < rowCount; i++) {
table.removeRow(startRow);
}
if (table.rows.length > 0) {
// Place cursor in the previous/first row.
var closestRow = Math.min(startRow, table.rows.length - 1);
cursorCell = table.rows[closestRow].columns[startColumn].element;
}
break;
case goog.editor.plugins.TableEditor.COMMAND.REMOVE_COLUMNS:
var startCol = cellSelection.getFirstColumnIndex();
var endCol = cellSelection.getLastColumnIndex();
if (startCol == 0 && endCol == (table.rows[0].columns.length - 1)) {
// Instead of deleting all columns, delete the entire table.
return this.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_TABLE);
}
var startRow = cellSelection.getFirstRowIndex();
var removeCount = (endCol - startCol) + 1;
for (var i = 0; i < removeCount; i++) {
table.removeColumn(startCol);
}
var currentRow = table.rows[startRow];
if (currentRow) {
// Place cursor in the previous/first column.
var closestCol = Math.min(startCol, currentRow.columns.length - 1);
cursorCell = currentRow.columns[closestCol].element;
}
break;
case goog.editor.plugins.TableEditor.COMMAND.MERGE_CELLS:
if (cellSelection.isRectangle()) {
table.mergeCells(cellSelection.getFirstRowIndex(),
cellSelection.getFirstColumnIndex(),
cellSelection.getLastRowIndex(),
cellSelection.getLastColumnIndex());
}
break;
case goog.editor.plugins.TableEditor.COMMAND.SPLIT_CELL:
if (cellSelection.containsSingleCell()) {
table.splitCell(cellSelection.getFirstRowIndex(),
cellSelection.getFirstColumnIndex());
}
break;
case goog.editor.plugins.TableEditor.COMMAND.REMOVE_TABLE:
table.element.parentNode.removeChild(table.element);
break;
default:
}
}
if (cursorCell) {
range = goog.dom.Range.createFromNodeContents(cursorCell);
range.collapse(false);
range.select();
}
return result;
};
/**
* Checks whether the element is a table editable by the user.
* @param {Node} element The element in question.
* @return {boolean} Whether the element is a table editable by the user.
* @private
*/
goog.editor.plugins.TableEditor.prototype.isUserEditableTable_ =
function(element) {
// Default implementation.
if (element.tagName != goog.dom.TagName.TABLE) {
return false;
}
// Check for extra user-editable filters.
return goog.array.every(this.isTableEditableFunctions_, function(func) {
return func(/** @type {Element} */ (element));
});
};
/**
* Adds a function to filter out non-user-editable tables.
* @param {function(Element):boolean} func A function to decide whether the
* table element could be editable by the user or not.
*/
goog.editor.plugins.TableEditor.prototype.addIsTableEditableFunction =
function(func) {
goog.array.insert(this.isTableEditableFunctions_, func);
};
/**
* Class representing the selected cell objects within a single table.
* @param {goog.dom.AbstractRange} range Selected range from which to calculate
* selected cells.
* @param {function(Element):Element?} getParentTableFunction A function that
* finds the user-editable table from a given element.
* @constructor
* @private
*/
goog.editor.plugins.TableEditor.CellSelection_ =
function(range, getParentTableFunction) {
this.cells_ = [];
// Mozilla lets users select groups of cells, with each cell showing
// up as a separate range in the selection. goog.dom.Range doesn't
// currently support this.
// TODO(user): support this case in range.js
var selectionContainer = range.getContainerElement();
var elementInSelection = function(node) {
// TODO(user): revert to the more liberal containsNode(node, true),
// which will match partially-selected cells. We're using
// containsNode(node, false) at the moment because otherwise it's
// broken in WebKit due to a closure range bug.
return selectionContainer == node ||
selectionContainer.parentNode == node ||
range.containsNode(node, false);
};
var parentTableElement = selectionContainer &&
getParentTableFunction(selectionContainer);
if (!parentTableElement) {
return;
}
var parentTable = new goog.editor.Table(parentTableElement);
// It's probably not possible to select a table with no cells, but
// do a sanity check anyway.
if (!parentTable.rows.length || !parentTable.rows[0].columns.length) {
return;
}
// Loop through cells to calculate dimensions for this CellSelection.
for (var i = 0, row; row = parentTable.rows[i]; i++) {
for (var j = 0, cell; cell = row.columns[j]; j++) {
if (elementInSelection(cell.element)) {
// Update dimensions based on cell.
if (!this.cells_.length) {
this.firstRowIndex_ = cell.startRow;
this.lastRowIndex_ = cell.endRow;
this.firstColIndex_ = cell.startCol;
this.lastColIndex_ = cell.endCol;
} else {
this.firstRowIndex_ = Math.min(this.firstRowIndex_, cell.startRow);
this.lastRowIndex_ = Math.max(this.lastRowIndex_, cell.endRow);
this.firstColIndex_ = Math.min(this.firstColIndex_, cell.startCol);
this.lastColIndex_ = Math.max(this.lastColIndex_, cell.endCol);
}
this.cells_.push(cell);
}
}
}
this.parentTable_ = parentTable;
};
/**
* Returns the EditableTable object of which this selection's cells are a
* subset.
* @return {!goog.editor.Table} the table.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getTable =
function() {
return this.parentTable_;
};
/**
* Returns the row index of the uppermost cell in this selection.
* @return {number} The row index.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getFirstRowIndex =
function() {
return this.firstRowIndex_;
};
/**
* Returns the row index of the lowermost cell in this selection.
* @return {number} The row index.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getLastRowIndex =
function() {
return this.lastRowIndex_;
};
/**
* Returns the column index of the farthest left cell in this selection.
* @return {number} The column index.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getFirstColumnIndex =
function() {
return this.firstColIndex_;
};
/**
* Returns the column index of the farthest right cell in this selection.
* @return {number} The column index.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getLastColumnIndex =
function() {
return this.lastColIndex_;
};
/**
* Returns the cells in this selection.
* @return {!Array<Element>} Cells in this selection.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.getCells = function() {
return this.cells_;
};
/**
* Returns a boolean value indicating whether or not the cells in this
* selection form a rectangle.
* @return {boolean} Whether the selection forms a rectangle.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.isRectangle =
function() {
// TODO(user): check for missing cells. Right now this returns
// whether all cells in the selection are in the rectangle, but doesn't
// verify that every expected cell is present.
if (!this.cells_.length) {
return false;
}
var firstCell = this.cells_[0];
var lastCell = this.cells_[this.cells_.length - 1];
return !(this.firstRowIndex_ < firstCell.startRow ||
this.lastRowIndex_ > lastCell.endRow ||
this.firstColIndex_ < firstCell.startCol ||
this.lastColIndex_ > lastCell.endCol);
};
/**
* Returns a boolean value indicating whether or not there is exactly
* one cell in this selection. Note that this may not be the same as checking
* whether getCells().length == 1; if there is a single cell with
* rowSpan/colSpan set it will appear multiple times.
* @return {boolean} Whether there is exatly one cell in this selection.
*/
goog.editor.plugins.TableEditor.CellSelection_.prototype.containsSingleCell =
function() {
var cellCount = this.cells_.length;
return cellCount > 0 &&
(this.cells_[0] == this.cells_[cellCount - 1]);
};
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<!--
Copyright 2009 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.TableEditor Tests
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.TableEditorTest');
</script>
</head>
<body>
<div id="field">
<div>
lorem ipsum
</div>
<div>
ipsum lorem
</div>
</div>
</body>
</html>
@@ -0,0 +1,303 @@
// Copyright 2009 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.TableEditorTest');
goog.setTestOnly('goog.editor.plugins.TableEditorTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.editor.plugins.TableEditor');
goog.require('goog.object');
goog.require('goog.string');
goog.require('goog.testing.ExpectedFailures');
goog.require('goog.testing.JsUnitException');
goog.require('goog.testing.editor.FieldMock');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var field;
var plugin;
var fieldMock;
var expectedFailures;
var testHelper;
function setUpPage() {
field = goog.dom.getElement('field');
expectedFailures = new goog.testing.ExpectedFailures();
}
function setUp() {
testHelper = new goog.testing.editor.TestHelper(
goog.dom.getElement('field'));
testHelper.setUpEditableElement();
field.focus();
plugin = new goog.editor.plugins.TableEditor();
fieldMock = new goog.testing.editor.FieldMock();
plugin.registerFieldObject(fieldMock);
if (goog.userAgent.IE &&
(goog.userAgent.compare(goog.userAgent.VERSION, '7.0') >= 0)) {
goog.testing.TestCase.protectedTimeout_ = window.setTimeout;
}
}
function tearDown() {
testHelper.tearDownEditableElement();
expectedFailures.handleTearDown();
}
function testEnable() {
fieldMock.$replay();
plugin.enable(fieldMock);
assertTrue('Plugin should be enabled', plugin.isEnabled(fieldMock));
if (goog.userAgent.GECKO) {
// This code path is executed only for GECKO browsers but we can't
// verify it because of a GECKO bug while reading the value of the
// command "enableObjectResizing".
// See https://bugzilla.mozilla.org/show_bug.cgi?id=506368
expectedFailures.expectFailureFor(goog.userAgent.GECKO);
try {
var doc = plugin.getFieldDomHelper().getDocument();
assertTrue('Object resizing should be enabled',
doc.queryCommandValue('enableObjectResizing'));
} catch (e) {
// We need to marshal our exception in order for it to be handled
// properly.
expectedFailures.handleException(new goog.testing.JsUnitException(e));
}
}
fieldMock.$verify();
}
function testIsSupportedCommand() {
goog.object.forEach(goog.editor.plugins.TableEditor.COMMAND,
function(command) {
assertTrue(goog.string.subs('Plugin should support %s', command),
plugin.isSupportedCommand(command));
});
assertFalse('Plugin shouldn\'t support a bogus command',
plugin.isSupportedCommand('+fable'));
}
function testCreateTable() {
fieldMock.$replay();
createTableAndSelectCell();
var table = plugin.getCurrentTable_();
assertNotNull('Table should not be null', table);
assertEquals('Table should have the default number of rows',
2,
table.rows.length);
assertEquals('Table should have the default number of cells',
8,
getCellCount(table));
fieldMock.$verify();
}
function testInsertRowBefore() {
fieldMock.$replay();
createTableAndSelectCell();
var table = plugin.getCurrentTable_();
var selectedRow = fieldMock.getRange().getContainerElement().parentNode;
assertNull('Selected row shouldn\'t have a previous sibling',
selectedRow.previousSibling);
assertEquals('Table should have two rows', 2, table.rows.length);
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.INSERT_ROW_BEFORE);
assertEquals('A row should have been inserted', 3, table.rows.length);
// Assert that we inserted a row above the currently selected row.
assertNotNull('Selected row should have a previous sibling',
selectedRow.previousSibling);
fieldMock.$verify();
}
function testInsertRowAfter() {
fieldMock.$replay();
createTableAndSelectCell({width: 2, height: 1});
var selectedRow = fieldMock.getRange().getContainerElement().parentNode;
var table = plugin.getCurrentTable_();
assertEquals('Table should have one row', 1, table.rows.length);
assertNull('Selected row shouldn\'t have a next sibling',
selectedRow.nextSibling);
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.INSERT_ROW_AFTER);
assertEquals('A row should have been inserted', 2, table.rows.length);
// Assert that we inserted a row after the currently selected row.
assertNotNull('Selected row should have a next sibling',
selectedRow.nextSibling);
fieldMock.$verify();
}
function testInsertColumnBefore() {
fieldMock.$replay();
createTableAndSelectCell({width: 1, height: 1});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
assertEquals('Table should have one cell', 1, getCellCount(table));
assertNull('Selected cell shouldn\'t have a previous sibling',
selectedCell.previousSibling);
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.INSERT_COLUMN_BEFORE);
assertEquals('A cell should have been inserted', 2, getCellCount(table));
assertNotNull('Selected cell should have a previous sibling',
selectedCell.previousSibling);
fieldMock.$verify();
}
function testInsertColumnAfter() {
fieldMock.$replay();
createTableAndSelectCell({width: 1, height: 1});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
assertEquals('Table should have one cell', 1, getCellCount(table));
assertNull('Selected cell shouldn\'t have a next sibling',
selectedCell.nextSibling);
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.INSERT_COLUMN_AFTER);
assertEquals('A cell should have been inserted', 2, getCellCount(table));
assertNotNull('Selected cell should have a next sibling',
selectedCell.nextSibling);
fieldMock.$verify();
}
function testRemoveRows() {
fieldMock.$replay();
createTableAndSelectCell({width: 1, height: 2});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
selectedCell.id = 'selected';
assertEquals('Table should have two rows', 2, table.rows.length);
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_ROWS);
assertEquals('A row should have been removed', 1, table.rows.length);
assertNull('The correct row should have been removed',
goog.dom.getElement('selected'));
// Verify that the table is removed if we don't have any rows.
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_ROWS);
assertEquals('The table should have been removed',
0,
field.getElementsByTagName('table').length);
fieldMock.$verify();
}
function testRemoveColumns() {
fieldMock.$replay();
createTableAndSelectCell({width: 2, height: 1});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
selectedCell.id = 'selected';
assertEquals('Table should have two cells', 2, getCellCount(table));
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_COLUMNS);
assertEquals('A cell should have been removed', 1, getCellCount(table));
assertNull('The correct cell should have been removed',
goog.dom.getElement('selected'));
// Verify that the table is removed if we don't have any columns.
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.REMOVE_COLUMNS);
assertEquals('The table should have been removed',
0,
field.getElementsByTagName('table').length);
fieldMock.$verify();
}
function testSplitCell() {
fieldMock.$replay();
createTableAndSelectCell({width: 1, height: 1});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
// Splitting is only supported if we set these attributes.
selectedCell.rowSpan = '1';
selectedCell.colSpan = '2';
selectedCell.innerHTML = 'foo';
goog.dom.Range.createFromNodeContents(selectedCell).select();
assertEquals('Table should have one cell', 1, getCellCount(table));
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.SPLIT_CELL);
assertEquals('The cell should have been split', 2, getCellCount(table));
assertEquals('The cell content should be intact',
'foo',
selectedCell.innerHTML);
assertNotNull('The new cell should be inserted before',
selectedCell.previousSibling);
fieldMock.$verify();
}
function testMergeCells() {
fieldMock.$replay();
createTableAndSelectCell({width: 2, height: 1});
var table = plugin.getCurrentTable_();
var selectedCell = fieldMock.getRange().getContainerElement();
selectedCell.innerHTML = 'foo';
selectedCell.nextSibling.innerHTML = 'bar';
var range = goog.dom.Range.createFromNodeContents(
table.getElementsByTagName('tr')[0]);
range.select();
plugin.execCommandInternal(
goog.editor.plugins.TableEditor.COMMAND.MERGE_CELLS);
expectedFailures.expectFailureFor(
goog.userAgent.IE &&
goog.userAgent.isVersionOrHigher('8'));
try {
// In IE8, even after explicitly setting the range to span
// multiple cells, the browser selection only contains the first TD
// which causes the merge operation to fail.
assertEquals('The cells should be merged', 1, getCellCount(table));
assertEquals('The cell should have expected colspan',
2,
selectedCell.colSpan);
assertHTMLEquals('The content should be merged',
'foo bar',
selectedCell.innerHTML);
} catch (e) {
expectedFailures.handleException(e);
}
fieldMock.$verify();
}
/**
* Helper routine which returns the number of cells in the table.
*
* @param {Element} table The table in question.
* @return {number} Number of cells.
*/
function getCellCount(table) {
return table.cells ? table.cells.length :
table.rows[0].cells.length * table.rows.length;
}
/**
* Helper method which creates a table and puts the cursor on the first TD.
* In IE, the cursor isn't positioned in the first cell (TD) and we simulate
* that behavior explicitly to be consistent across all browsers.
*
* @param {Object} op_tableProps Optional table properties.
*/
function createTableAndSelectCell(opt_tableProps) {
goog.dom.Range.createCaret(field, 1).select();
plugin.execCommandInternal(goog.editor.plugins.TableEditor.COMMAND.TABLE,
opt_tableProps);
if (goog.userAgent.IE) {
var range = goog.dom.Range.createFromNodeContents(
field.getElementsByTagName('td')[0]);
range.select();
}
}
@@ -0,0 +1,744 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview TrogEdit plugin to handle enter keys by inserting the
* specified block level tag.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.editor.plugins.TagOnEnterHandler');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.Command');
goog.require('goog.editor.node');
goog.require('goog.editor.plugins.EnterHandler');
goog.require('goog.editor.range');
goog.require('goog.editor.style');
goog.require('goog.events.KeyCodes');
goog.require('goog.functions');
goog.require('goog.string.Unicode');
goog.require('goog.style');
goog.require('goog.userAgent');
/**
* Plugin to handle enter keys. This subclass normalizes all browsers to use
* the given block tag on enter.
* @param {goog.dom.TagName} tag The type of tag to add on enter.
* @constructor
* @extends {goog.editor.plugins.EnterHandler}
*/
goog.editor.plugins.TagOnEnterHandler = function(tag) {
this.tag = tag;
goog.editor.plugins.EnterHandler.call(this);
};
goog.inherits(goog.editor.plugins.TagOnEnterHandler,
goog.editor.plugins.EnterHandler);
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.getTrogClassId = function() {
return 'TagOnEnterHandler';
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.getNonCollapsingBlankHtml =
function() {
if (this.tag == goog.dom.TagName.P) {
return '<p>&nbsp;</p>';
} else if (this.tag == goog.dom.TagName.DIV) {
return '<div><br></div>';
}
return '<br>';
};
/**
* This plugin is active on uneditable fields so it can provide a value for
* queryCommandValue calls asking for goog.editor.Command.BLOCKQUOTE.
* @return {boolean} True.
* @override
*/
goog.editor.plugins.TagOnEnterHandler.prototype.activeOnUneditableFields =
goog.functions.TRUE;
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.isSupportedCommand = function(
command) {
return command == goog.editor.Command.DEFAULT_TAG;
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.queryCommandValue = function(
command) {
return command == goog.editor.Command.DEFAULT_TAG ? this.tag : null;
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.handleBackspaceInternal =
function(e, range) {
goog.editor.plugins.TagOnEnterHandler.superClass_.handleBackspaceInternal.
call(this, e, range);
if (goog.userAgent.GECKO) {
this.markBrToNotBeRemoved_(range, true);
}
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.processParagraphTagsInternal =
function(e, split) {
if ((goog.userAgent.OPERA || goog.userAgent.IE) &&
this.tag != goog.dom.TagName.P) {
this.ensureBlockIeOpera(this.tag);
}
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.handleDeleteGecko = function(
e) {
var range = this.getFieldObject().getRange();
var container = goog.editor.style.getContainer(
range && range.getContainerElement());
if (this.getFieldObject().getElement().lastChild == container &&
goog.editor.plugins.EnterHandler.isBrElem(container)) {
// Don't delete if it's the last node in the field and just has a BR.
e.preventDefault();
// TODO(user): I think we probably don't need to stopPropagation here
e.stopPropagation();
} else {
// Go ahead with deletion.
// Prevent an existing BR immediately following the selection being deleted
// from being removed in the keyup stage (as opposed to a BR added by FF
// after deletion, which we do remove).
this.markBrToNotBeRemoved_(range, false);
// Manually delete the selection if it's at a BR.
this.deleteBrGecko(e);
}
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.handleKeyUpInternal = function(
e) {
if (goog.userAgent.GECKO) {
if (e.keyCode == goog.events.KeyCodes.DELETE) {
this.removeBrIfNecessary_(false);
} else if (e.keyCode == goog.events.KeyCodes.BACKSPACE) {
this.removeBrIfNecessary_(true);
}
} else if ((goog.userAgent.IE || goog.userAgent.OPERA) &&
e.keyCode == goog.events.KeyCodes.ENTER) {
this.ensureBlockIeOpera(this.tag, true);
}
// Safari uses DIVs by default.
};
/**
* String that matches a single BR tag or NBSP surrounded by non-breaking
* whitespace
* @type {string}
* @private
*/
goog.editor.plugins.TagOnEnterHandler.BrOrNbspSurroundedWithWhiteSpace_ =
'[\t\n\r ]*(<br[^>]*\/?>|&nbsp;)[\t\n\r ]*';
/**
* String that matches a single BR tag or NBSP surrounded by non-breaking
* whitespace
* @type {RegExp}
* @private
*/
goog.editor.plugins.TagOnEnterHandler.emptyLiRegExp_ = new RegExp('^' +
goog.editor.plugins.TagOnEnterHandler.BrOrNbspSurroundedWithWhiteSpace_ +
'$');
/**
* Ensures the current node is wrapped in the tag.
* @param {Node} node The node to ensure gets wrapped.
* @param {Element} container Element containing the selection.
* @return {Element} Element containing the selection, after the wrapping.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.ensureNodeIsWrappedW3c_ =
function(node, container) {
if (container == this.getFieldObject().getElement()) {
// If the first block-level ancestor of cursor is the field,
// don't split the tree. Find all the text from the cursor
// to both block-level elements surrounding it (if they exist)
// and split the text into two elements.
// This is the IE contentEditable behavior.
// The easy way to do this is to wrap all the text in an element
// and then split the element as if the user had hit enter
// in the paragraph
// However, simply wrapping the text into an element creates problems
// if the text was already wrapped using some other element such as an
// anchor. For example, wrapping the text of
// <a href="">Text</a>
// would produce
// <a href=""><p>Text</p></a>
// which is not what we want. What we really want is
// <p><a href="">Text</a></p>
// So we need to search for an ancestor of position.node to be wrapped.
// We do this by iterating up the hierarchy of postiion.node until we've
// reached the node that's just under the container.
var isChildOfFn = function(child) {
return container == child.parentNode; };
var nodeToWrap = goog.dom.getAncestor(node, isChildOfFn, true);
container = goog.editor.plugins.TagOnEnterHandler.wrapInContainerW3c_(
this.tag, {node: nodeToWrap, offset: 0}, container);
}
return container;
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.handleEnterWebkitInternal =
function(e) {
if (this.tag == goog.dom.TagName.DIV) {
var range = this.getFieldObject().getRange();
var container =
goog.editor.style.getContainer(range.getContainerElement());
var position = goog.editor.range.getDeepEndPoint(range, true);
container = this.ensureNodeIsWrappedW3c_(position.node, container);
goog.dom.Range.createCaret(position.node, position.offset).select();
}
};
/** @override */
goog.editor.plugins.TagOnEnterHandler.prototype.
handleEnterAtCursorGeckoInternal = function(e, wasCollapsed, range) {
// We use this because there are a few cases where FF default
// implementation doesn't follow IE's:
// -Inserts BRs into empty elements instead of NBSP which has nasty
// side effects w/ making/deleting selections
// -Hitting enter when your cursor is in the field itself. IE will
// create two elements. FF just inserts a BR.
// -Hitting enter inside an empty list-item doesn't create a block
// tag. It just splits the list and puts your cursor in the middle.
var li = null;
if (wasCollapsed) {
// Only break out of lists for collapsed selections.
li = goog.dom.getAncestorByTagNameAndClass(
range && range.getContainerElement(), goog.dom.TagName.LI);
}
var isEmptyLi = (li &&
li.innerHTML.match(
goog.editor.plugins.TagOnEnterHandler.emptyLiRegExp_));
var elementAfterCursor = isEmptyLi ?
this.breakOutOfEmptyListItemGecko_(li) :
this.handleRegularEnterGecko_();
// Move the cursor in front of "nodeAfterCursor", and make sure it
// is visible
this.scrollCursorIntoViewGecko_(elementAfterCursor);
// Fix for http://b/1991234 :
if (goog.editor.plugins.EnterHandler.isBrElem(elementAfterCursor)) {
// The first element in the new line is a line with just a BR and maybe some
// whitespace.
// Calling normalize() is needed because there might be empty text nodes
// before BR and empty text nodes cause the cursor position bug in Firefox.
// See http://b/5220858
elementAfterCursor.normalize();
var br = elementAfterCursor.getElementsByTagName(goog.dom.TagName.BR)[0];
if (br.previousSibling &&
br.previousSibling.nodeType == goog.dom.NodeType.TEXT) {
// If there is some whitespace before the BR, don't put the selection on
// the BR, put it in the text node that's there, otherwise when you type
// it will create adjacent text nodes.
elementAfterCursor = br.previousSibling;
}
}
goog.editor.range.selectNodeStart(elementAfterCursor);
e.preventDefault();
// TODO(user): I think we probably don't need to stopPropagation here
e.stopPropagation();
};
/**
* If The cursor is in an empty LI then break out of the list like in IE
* @param {Node} li LI to break out of.
* @return {!Element} Element to put the cursor after.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.breakOutOfEmptyListItemGecko_ =
function(li) {
// Do this as follows:
// 1. <ul>...<li>&nbsp;</li>...</ul>
// 2. <ul id='foo1'>...<li id='foo2'>&nbsp;</li>...</ul>
// 3. <ul id='foo1'>...</ul><p id='foo3'>&nbsp;</p><ul id='foo2'>...</ul>
// 4. <ul>...</ul><p>&nbsp;</p><ul>...</ul>
//
// There are a couple caveats to the above. If the UL is contained in
// a list, then the new node inserted is an LI, not a P.
// For an OL, it's all the same, except the tagname of course.
// Finally, it's possible that with the LI at the beginning or the end
// of the list that we'll end up with an empty list. So we special case
// those cases.
var listNode = li.parentNode;
var grandparent = listNode.parentNode;
var inSubList = grandparent.tagName == goog.dom.TagName.OL ||
grandparent.tagName == goog.dom.TagName.UL;
// TODO(robbyw): Should we apply the list or list item styles to the new node?
var newNode = goog.dom.getDomHelper(li).createElement(
inSubList ? goog.dom.TagName.LI : this.tag);
if (!li.previousSibling) {
goog.dom.insertSiblingBefore(newNode, listNode);
} else {
if (li.nextSibling) {
var listClone = listNode.cloneNode(false);
while (li.nextSibling) {
listClone.appendChild(li.nextSibling);
}
goog.dom.insertSiblingAfter(listClone, listNode);
}
goog.dom.insertSiblingAfter(newNode, listNode);
}
if (goog.editor.node.isEmpty(listNode)) {
goog.dom.removeNode(listNode);
}
goog.dom.removeNode(li);
newNode.innerHTML = '&nbsp;';
return newNode;
};
/**
* Wrap the text indicated by "position" in an HTML container of type
* "nodeName".
* @param {string} nodeName Type of container, e.g. "p" (paragraph).
* @param {Object} position The W3C cursor position object
* (from getCursorPositionW3c).
* @param {Node} container The field containing position.
* @return {!Element} The container element that holds the contents from
* position.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.wrapInContainerW3c_ = function(nodeName,
position, container) {
var start = position.node;
while (start.previousSibling &&
!goog.editor.style.isContainer(start.previousSibling)) {
start = start.previousSibling;
}
var end = position.node;
while (end.nextSibling &&
!goog.editor.style.isContainer(end.nextSibling)) {
end = end.nextSibling;
}
var para = container.ownerDocument.createElement(nodeName);
while (start != end) {
var newStart = start.nextSibling;
goog.dom.appendChild(para, start);
start = newStart;
}
var nextSibling = end.nextSibling;
goog.dom.appendChild(para, end);
container.insertBefore(para, nextSibling);
return para;
};
/**
* When we delete an element, FF inserts a BR. We want to strip that
* BR after the fact, but in the case where your cursor is at a character
* right before a BR and you delete that character, we don't want to
* strip it. So we detect this case on keydown and mark the BR as not needing
* removal.
* @param {goog.dom.AbstractRange} range The closure range object.
* @param {boolean} isBackspace Whether this is handling the backspace key.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.markBrToNotBeRemoved_ =
function(range, isBackspace) {
var focusNode = range.getFocusNode();
var focusOffset = range.getFocusOffset();
var newEndOffset = isBackspace ? focusOffset : focusOffset + 1;
if (goog.editor.node.getLength(focusNode) == newEndOffset) {
var sibling = focusNode.nextSibling;
if (sibling && sibling.tagName == goog.dom.TagName.BR) {
this.brToKeep_ = sibling;
}
}
};
/**
* If we hit delete/backspace to merge elements, FF inserts a BR.
* We want to strip that BR. In markBrToNotBeRemoved, we detect if
* there was already a BR there before the delete/backspace so that
* we don't accidentally remove a user-inserted BR.
* @param {boolean} isBackSpace Whether this is handling the backspace key.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.removeBrIfNecessary_ = function(
isBackSpace) {
var range = this.getFieldObject().getRange();
var focusNode = range.getFocusNode();
var focusOffset = range.getFocusOffset();
var sibling;
if (isBackSpace && focusNode.data == '') {
// nasty hack. sometimes firefox will backspace a paragraph and put
// the cursor before the BR. when it does this, the focusNode is
// an empty textnode.
sibling = focusNode.nextSibling;
} else if (isBackSpace && focusOffset == 0) {
var node = focusNode;
while (node && !node.previousSibling &&
node.parentNode != this.getFieldObject().getElement()) {
node = node.parentNode;
}
sibling = node.previousSibling;
} else if (focusNode.length == focusOffset) {
sibling = focusNode.nextSibling;
}
if (!sibling || sibling.tagName != goog.dom.TagName.BR ||
this.brToKeep_ == sibling) {
return;
}
goog.dom.removeNode(sibling);
if (focusNode.nodeType == goog.dom.NodeType.TEXT) {
// Sometimes firefox inserts extra whitespace. Do our best to deal.
// This is buggy though.
focusNode.data =
goog.editor.plugins.TagOnEnterHandler.trimTabsAndLineBreaks_(
focusNode.data);
// When we strip whitespace, make sure that our cursor is still at
// the end of the textnode.
goog.dom.Range.createCaret(focusNode,
Math.min(focusOffset, focusNode.length)).select();
}
};
/**
* Trim the tabs and line breaks from a string.
* @param {string} string String to trim.
* @return {string} Trimmed string.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.trimTabsAndLineBreaks_ = function(
string) {
return string.replace(/^[\t\n\r]|[\t\n\r]$/g, '');
};
/**
* Called in response to a normal enter keystroke. It has the action of
* splitting elements.
* @return {Element} The node that the cursor should be before.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.handleRegularEnterGecko_ =
function() {
var range = this.getFieldObject().getRange();
var container =
goog.editor.style.getContainer(range.getContainerElement());
var newNode;
if (goog.editor.plugins.EnterHandler.isBrElem(container)) {
if (container.tagName == goog.dom.TagName.BODY) {
// If the field contains only a single BR, this code ensures we don't
// try to clone the body tag.
container = this.ensureNodeIsWrappedW3c_(
container.getElementsByTagName(goog.dom.TagName.BR)[0],
container);
}
newNode = container.cloneNode(true);
goog.dom.insertSiblingAfter(newNode, container);
} else {
if (!container.firstChild) {
container.innerHTML = '&nbsp;';
}
var position = goog.editor.range.getDeepEndPoint(range, true);
container = this.ensureNodeIsWrappedW3c_(position.node, container);
newNode = goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_(
position.node, position.offset, container);
// If the left half and right half of the splitted node are anchors then
// that means the user pressed enter while the caret was inside
// an anchor tag and split it. The left half is the first anchor
// found while traversing the right branch of container. The right half
// is the first anchor found while traversing the left branch of newNode.
var leftAnchor =
goog.editor.plugins.TagOnEnterHandler.findAnchorInTraversal_(
container);
var rightAnchor =
goog.editor.plugins.TagOnEnterHandler.findAnchorInTraversal_(
newNode, true);
if (leftAnchor && rightAnchor &&
leftAnchor.tagName == goog.dom.TagName.A &&
rightAnchor.tagName == goog.dom.TagName.A) {
// If the original anchor (left anchor) is now empty, that means
// the user pressed [Enter] at the beginning of the anchor,
// in which case we we
// want to replace that anchor with its child nodes
// Otherwise, we take the second half of the splitted text and break
// it out of the anchor.
var anchorToRemove = goog.editor.node.isEmpty(leftAnchor, false) ?
leftAnchor : rightAnchor;
goog.dom.flattenElement(/** @type {!Element} */ (anchorToRemove));
}
}
return /** @type {!Element} */ (newNode);
};
/**
* Scroll the cursor into view, resulting from splitting the paragraph/adding
* a br. It behaves differently than scrollIntoView
* @param {Element} element The element immediately following the cursor. Will
* be used to determine how to scroll in order to make the cursor visible.
* CANNOT be a BR, as they do not have offsetHeight/offsetTop.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.prototype.scrollCursorIntoViewGecko_ =
function(element) {
if (!this.getFieldObject().isFixedHeight()) {
return; // Only need to scroll fixed height fields.
}
var field = this.getFieldObject().getElement();
// Get the y position of the element we want to scroll to
var elementY = goog.style.getPageOffsetTop(element);
// Determine the height of that element, since we want the bottom of the
// element to be in view.
var bottomOfNode = elementY + element.offsetHeight;
var dom = this.getFieldDomHelper();
var win = this.getFieldDomHelper().getWindow();
var scrollY = dom.getDocumentScroll().y;
var viewportHeight = goog.dom.getViewportSize(win).height;
// If the botom of the element is outside the viewport, move it into view
if (bottomOfNode > viewportHeight + scrollY) {
// In standards mode, use the html element and not the body
if (field.tagName == goog.dom.TagName.BODY &&
goog.editor.node.isStandardsMode(field)) {
field = field.parentNode;
}
field.scrollTop = bottomOfNode - viewportHeight;
}
};
/**
* Splits the DOM tree around the given node and returns the node
* containing the second half of the tree. The first half of the tree
* is modified, but not removed from the DOM.
* @param {Node} positionNode Node to split at.
* @param {number} positionOffset Offset into positionNode to split at. If
* positionNode is a text node, this offset is an offset in to the text
* content of that node. Otherwise, positionOffset is an offset in to
* the childNodes array. All elements with child index of positionOffset
* or greater will be moved to the second half. If positionNode is an
* empty element, the dom will be split at that element, with positionNode
* ending up in the second half. positionOffset must be 0 in this case.
* @param {Node=} opt_root Node at which to stop splitting the dom (the root
* is also split).
* @return {!Node} The node containing the second half of the tree.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.splitDom_ = function(
positionNode, positionOffset, opt_root) {
if (!opt_root) opt_root = positionNode.ownerDocument.body;
// Split the node.
var textSplit = positionNode.nodeType == goog.dom.NodeType.TEXT;
var secondHalfOfSplitNode;
if (textSplit) {
if (goog.userAgent.IE &&
positionOffset == positionNode.nodeValue.length) {
// Since splitText fails in IE at the end of a node, we split it manually.
secondHalfOfSplitNode = goog.dom.getDomHelper(positionNode).
createTextNode('');
goog.dom.insertSiblingAfter(secondHalfOfSplitNode, positionNode);
} else {
secondHalfOfSplitNode = positionNode.splitText(positionOffset);
}
} else {
// Here we ensure positionNode is the last node in the first half of the
// resulting tree.
if (positionOffset) {
// Use offset as an index in to childNodes.
positionNode = positionNode.childNodes[positionOffset - 1];
} else {
// In this case, positionNode would be the last node in the first half
// of the tree, but we actually want to move it to the second half.
// Therefore we set secondHalfOfSplitNode to the same node.
positionNode = secondHalfOfSplitNode = positionNode.firstChild ||
positionNode;
}
}
// Create second half of the tree.
var secondHalf = goog.editor.node.splitDomTreeAt(
positionNode, secondHalfOfSplitNode, opt_root);
if (textSplit) {
// Join secondHalfOfSplitNode and its right text siblings together and
// then replace leading NonNbspWhiteSpace with a Nbsp. If
// secondHalfOfSplitNode has a right sibling that isn't a text node,
// then we can leave secondHalfOfSplitNode empty.
secondHalfOfSplitNode =
goog.editor.plugins.TagOnEnterHandler.joinTextNodes_(
secondHalfOfSplitNode, true);
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
secondHalfOfSplitNode, true, !!secondHalfOfSplitNode.nextSibling);
// Join positionNode and its left text siblings together and then replace
// trailing NonNbspWhiteSpace with a Nbsp.
var firstHalf = goog.editor.plugins.TagOnEnterHandler.joinTextNodes_(
positionNode, false);
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
firstHalf, false, false);
}
return secondHalf;
};
/**
* Splits the DOM tree around the given node and returns the node containing
* second half of the tree, which is appended after the old node. The first
* half of the tree is modified, but not removed from the DOM.
* @param {Node} positionNode Node to split at.
* @param {number} positionOffset Offset into positionNode to split at. If
* positionNode is a text node, this offset is an offset in to the text
* content of that node. Otherwise, positionOffset is an offset in to
* the childNodes array. All elements with child index of positionOffset
* or greater will be moved to the second half. If positionNode is an
* empty element, the dom will be split at that element, with positionNode
* ending up in the second half. positionOffset must be 0 in this case.
* @param {Node} node Node to split.
* @return {!Node} The node containing the second half of the tree.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_ = function(
positionNode, positionOffset, node) {
var newNode = goog.editor.plugins.TagOnEnterHandler.splitDom_(
positionNode, positionOffset, node);
goog.dom.insertSiblingAfter(newNode, node);
return newNode;
};
/**
* Joins node and its adjacent text nodes together.
* @param {Node} node The node to start joining.
* @param {boolean} moveForward Determines whether to join left siblings (false)
* or right siblings (true).
* @return {Node} The joined text node.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.joinTextNodes_ = function(node,
moveForward) {
if (node && node.nodeName == '#text') {
var nextNodeFn = moveForward ? 'nextSibling' : 'previousSibling';
var prevNodeFn = moveForward ? 'previousSibling' : 'nextSibling';
var nodeValues = [node.nodeValue];
while (node[nextNodeFn] &&
node[nextNodeFn].nodeType == goog.dom.NodeType.TEXT) {
node = node[nextNodeFn];
nodeValues.push(node.nodeValue);
goog.dom.removeNode(node[prevNodeFn]);
}
if (!moveForward) {
nodeValues.reverse();
}
node.nodeValue = nodeValues.join('');
}
return node;
};
/**
* Replaces leading or trailing spaces of a text node to a single Nbsp.
* @param {Node} textNode The text node to search and replace white spaces.
* @param {boolean} fromStart Set to true to replace leading spaces, false to
* replace trailing spaces.
* @param {boolean} isLeaveEmpty Set to true to leave the node empty if the
* text node was empty in the first place, otherwise put a Nbsp into the
* text node.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_ = function(
textNode, fromStart, isLeaveEmpty) {
var regExp = fromStart ? /^[ \t\r\n]+/ : /[ \t\r\n]+$/;
textNode.nodeValue = textNode.nodeValue.replace(regExp,
goog.string.Unicode.NBSP);
if (!isLeaveEmpty && textNode.nodeValue == '') {
textNode.nodeValue = goog.string.Unicode.NBSP;
}
};
/**
* Finds the first A element in a traversal from the input node. The input
* node itself is not included in the search.
* @param {Node} node The node to start searching from.
* @param {boolean=} opt_useFirstChild Whether to traverse along the first child
* (true) or last child (false).
* @return {Node} The first anchor node found in the search, or null if none
* was found.
* @private
*/
goog.editor.plugins.TagOnEnterHandler.findAnchorInTraversal_ = function(node,
opt_useFirstChild) {
while ((node = opt_useFirstChild ? node.firstChild : node.lastChild) &&
node.tagName != goog.dom.TagName.A) {
// Do nothing - advancement is handled in the condition.
}
return node;
};
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<!--
Tests for goog.editor.plugins.TagOnEnterHandler
@author marcosalmeida@google.com
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
goog.editor.plugins.TagOnEnterHandler jsunit tests
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.TagOnEnterHandlerTest');
</script>
</head>
<body>
<div id="root">
<div id="field1" class="tr-field">
</div>
</div>
</body>
</html>
@@ -0,0 +1,546 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.TagOnEnterHandlerTest');
goog.setTestOnly('goog.editor.plugins.TagOnEnterHandlerTest');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Field');
goog.require('goog.editor.Plugin');
goog.require('goog.editor.plugins.TagOnEnterHandler');
goog.require('goog.events.KeyCodes');
goog.require('goog.string.Unicode');
goog.require('goog.testing.dom');
goog.require('goog.testing.editor.TestHelper');
goog.require('goog.testing.events');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var savedHtml;
var editor;
var field1;
function setUp() {
field1 = makeField('field1');
field1.makeEditable();
}
/**
* Tests that deleting a BR that comes right before a block element works.
* @bug 1471096
*/
function testDeleteBrBeforeBlock() {
// This test only works on Gecko, because it's testing for manual deletion of
// BR tags, which is done only for Gecko. For other browsers we fall through
// and let the browser do the delete, which can only be tested with a robot
// test (see javascript/apps/editor/tests/delete_br_robot.html).
if (goog.userAgent.GECKO) {
field1.setHtml(false, 'one<br><br><div>two</div>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
helper.select(field1.getElement(), 2); // Between the two BR's.
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted exactly one <br>',
'one<br><div>two</div>',
field1.getElement().innerHTML);
} // End if GECKO
}
/**
* Tests that deleting a BR is working normally (that the workaround for the
* bug is not causing double deletes).
* @bug 1471096
*/
function testDeleteBrNormal() {
// This test only works on Gecko, because it's testing for manual deletion of
// BR tags, which is done only for Gecko. For other browsers we fall through
// and let the browser do the delete, which can only be tested with a robot
// test (see javascript/apps/editor/tests/delete_br_robot.html).
if (goog.userAgent.GECKO) {
field1.setHtml(false, 'one<br><br><br>two');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
helper.select(field1.getElement(), 2); // Between the first and second BR's.
field1.getElement().focus();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.DELETE);
assertEquals('Should have deleted exactly one <br>',
'one<br><br>two',
field1.getElement().innerHTML);
} // End if GECKO
}
/**
* Regression test for http://b/1991234 . Tests that when you hit enter and it
* creates a blank line with whitespace and a BR, the cursor is placed in the
* whitespace text node instead of the BR, otherwise continuing to type will
* create adjacent text nodes, which causes browsers to mess up some
* execcommands. Fix is in a Gecko-only codepath, thus test runs only for Gecko.
* A full test for the entire sequence that reproed the bug is in
* javascript/apps/editor/tests/ponenter_robot.html .
*/
function testEnterCreatesBlankLine() {
if (goog.userAgent.GECKO) {
field1.setHtml(false, '<p>one <br></p>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
// Place caret after 'one' but keeping a space and a BR as FF does.
helper.select('one ', 3);
field1.getElement().focus();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var range = field1.getRange();
assertFalse('Selection should not be in BR tag',
range.getStartNode().nodeType == goog.dom.NodeType.ELEMENT &&
range.getStartNode().tagName == goog.dom.TagName.BR);
assertEquals('Selection should be in text node to avoid creating adjacent' +
' text nodes',
goog.dom.NodeType.TEXT, range.getStartNode().nodeType);
var rangeStartNode =
goog.dom.Range.createFromNodeContents(range.getStartNode());
assertHTMLEquals('The value of selected text node should be replaced with' +
'&nbsp;',
'&nbsp;', rangeStartNode.getHtmlFragment());
}
}
/**
* Regression test for http://b/3051179 . Tests that when you hit enter and it
* creates a blank line with a BR and the cursor is placed in P.
* Splitting DOM causes to make an empty text node. Then if the cursor is placed
* at the text node the cursor is shown at wrong location.
* Therefore this test checks that the cursor is not placed at an empty node.
* Fix is in a Gecko-only codepath, thus test runs only for Gecko.
*/
function testEnterNormalizeNodes() {
if (goog.userAgent.GECKO) {
field1.setHtml(false, '<p>one<br></p>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
// Place caret after 'one' but keeping a BR as FF does.
helper.select('one', 3);
field1.getElement().focus();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
var range = field1.getRange();
assertTrue('Selection should be in P tag',
range.getStartNode().nodeType == goog.dom.NodeType.ELEMENT &&
range.getStartNode().tagName == goog.dom.TagName.P);
assertTrue('Selection should be at the head and collapsed',
range.getStartOffset() == 0 && range.isCollapsed());
}
}
/**
* Verifies
* goog.editor.plugins.TagOnEnterHandler.prototype.handleRegularEnterGecko_
* when we explicitly split anchor elements. This test runs only for Gecko
* since this is a Gecko-only codepath.
*/
function testEnterAtBeginningOfLink() {
if (goog.userAgent.GECKO) {
field1.setHtml(false, '<a href="/">b<br></a>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
field1.focusAndPlaceCursorAtStart();
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<p>&nbsp;</p><p><a href="/">b<br></a></p>');
}
}
/**
* Verifies correct handling of pressing enter in an empty list item.
*/
function testEnterInEmptyListItemInEmptyList() {
if (goog.userAgent.GECKO) {
field1.setHtml(false, '<ul><li>&nbsp;</li></ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[0];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches('<p>&nbsp;</p>');
}
}
function testEnterInEmptyListItemAtBeginningOfList() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul style="font-weight: bold">' +
'<li>&nbsp;</li>' +
'<li>1</li>' +
'<li>2</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[0];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<p>&nbsp;</p><ul style="font-weight: bold"><li>1</li><li>2</li></ul>');
}
}
function testEnterInEmptyListItemAtEndOfList() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul style="font-weight: bold">' +
'<li>1</li>' +
'<li>2</li>' +
'<li>&nbsp;</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[2];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<ul style="font-weight: bold"><li>1</li><li>2</li></ul><p>&nbsp;</p>');
}
}
function testEnterInEmptyListItemInMiddleOfList() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul style="font-weight: bold">' +
'<li>1</li>' +
'<li>&nbsp;</li>' +
'<li>2</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[1];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<ul style="font-weight: bold"><li>1</li></ul>' +
'<p>&nbsp;</p>' +
'<ul style="font-weight: bold"><li>2</li></ul>');
}
}
function testEnterInEmptyListItemInSublist() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul>' +
'<li>A</li>' +
'<ul style="font-weight: bold">' +
'<li>1</li>' +
'<li>&nbsp;</li>' +
'<li>2</li>' +
'</ul>' +
'<li>B</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[2];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<ul>' +
'<li>A</li>' +
'<ul style="font-weight: bold"><li>1</li></ul>' +
'<li>&nbsp;</li>' +
'<ul style="font-weight: bold"><li>2</li></ul>' +
'<li>B</li>' +
'</ul>');
}
}
function testEnterInEmptyListItemAtBeginningOfSublist() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul>' +
'<li>A</li>' +
'<ul style="font-weight: bold">' +
'<li>&nbsp;</li>' +
'<li>1</li>' +
'<li>2</li>' +
'</ul>' +
'<li>B</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[1];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<ul>' +
'<li>A</li>' +
'<li>&nbsp;</li>' +
'<ul style="font-weight: bold"><li>1</li><li>2</li></ul>' +
'<li>B</li>' +
'</ul>');
}
}
function testEnterInEmptyListItemAtEndOfSublist() {
if (goog.userAgent.GECKO) {
field1.setHtml(false,
'<ul>' +
'<li>A</li>' +
'<ul style="font-weight: bold">' +
'<li>1</li>' +
'<li>2</li>' +
'<li>&nbsp;</li>' +
'</ul>' +
'<li>B</li>' +
'</ul>');
var helper = new goog.testing.editor.TestHelper(field1.getElement());
var li = field1.getElement().getElementsByTagName(goog.dom.TagName.LI)[3];
helper.select(li.firstChild, 0);
goog.testing.events.fireKeySequence(field1.getElement(),
goog.events.KeyCodes.ENTER);
helper.assertHtmlMatches(
'<ul>' +
'<li>A</li>' +
'<ul style="font-weight: bold"><li>1</li><li>2</li></ul>' +
'<li>&nbsp;</li>' +
'<li>B</li>' +
'</ul>');
}
}
function testPrepareContentForPOnEnter() {
assertPreparedContents('hi', 'hi');
assertPreparedContents(
goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES ? '<p>&nbsp;</p>' : '',
' ');
}
function testPrepareContentForDivOnEnter() {
assertPreparedContents('hi', 'hi', goog.dom.TagName.DIV);
assertPreparedContents(
goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES ? '<div><br></div>' : '',
' ',
goog.dom.TagName.DIV);
}
/**
* Assert that the prepared contents matches the expected.
*/
function assertPreparedContents(expected, original, opt_tag) {
var field = makeField('field1', opt_tag);
field.makeEditable();
assertEquals(expected,
field.reduceOp_(
goog.editor.Plugin.Op.PREPARE_CONTENTS_HTML, original));
}
/**
* Selects the node at the given id, and simulates an ENTER keypress.
* @param {googe.editor.Field} field The field with the node.
* @param {string} id A DOM id.
* @return {boolean} Whether preventDefault was called on the event.
*/
function selectNodeAndHitEnter(field, id) {
var cursor = field.getEditableDomHelper().getElement(id);
goog.dom.Range.createFromNodeContents(cursor).select();
return goog.testing.events.fireKeySequence(
cursor, goog.events.KeyCodes.ENTER);
}
/**
* Creates a field with only the enter handler plugged in, for testing.
* @param {string} id A DOM id.
* @param {boolean=} opt_tag The block tag to use. Defaults to P.
* @return {goog.editor.Field} A field.
*/
function makeField(id, opt_tag) {
var field = new goog.editor.Field(id);
field.registerPlugin(
new goog.editor.plugins.TagOnEnterHandler(opt_tag || goog.dom.TagName.P));
return field;
}
/**
* Runs a test for splitting the dom.
* @param {number} offset Index into the text node to split.
* @param {string} firstHalfString What the html of the first half of the DOM
* should be.
* @param {string} secondHalfString What the html of the 2nd half of the DOM
* should be.
* @param {boolean} isAppend True if the second half should be appended to the
* DOM.
* @param {boolean=} opt_goToRoot True if the root argument for splitDom should
* be excluded.
*/
function helpTestSplit_(offset, firstHalfString, secondHalfString, isAppend,
opt_goToBody) {
var node = document.createElement('div');
node.innerHTML = '<b>begin bold<i>italic</i>end bold</b>';
document.body.appendChild(node);
var italic = node.getElementsByTagName('i')[0].firstChild;
var splitFn = isAppend ?
goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_ :
goog.editor.plugins.TagOnEnterHandler.splitDom_;
var secondHalf = splitFn(italic, offset, opt_goToBody ? undefined : node);
if (opt_goToBody) {
secondHalfString = '<div>' + secondHalfString + '</div>';
}
assertEquals('original node should have first half of the html',
firstHalfString,
node.innerHTML.toLowerCase().
replace(goog.string.Unicode.NBSP, '&nbsp;'));
assertEquals('new node should have second half of the html',
secondHalfString,
secondHalf.innerHTML.toLowerCase().
replace(goog.string.Unicode.NBSP, '&nbsp;'));
if (isAppend) {
assertTrue('second half of dom should be the original node\'s next' +
'sibling', node.nextSibling == secondHalf);
goog.dom.removeNode(secondHalf);
}
goog.dom.removeNode(node);
}
/**
* Runs different cases of splitting the DOM.
* @param {function(number, string, string)} testFn Function that takes an
* offset, firstHalfString and secondHalfString as parameters.
*/
function splitDomCases_(testFn) {
testFn(3, '<b>begin bold<i>ita</i></b>', '<b><i>lic</i>end bold</b>');
testFn(0, '<b>begin bold<i>&nbsp;</i></b>', '<b><i>italic</i>end bold</b>');
testFn(6, '<b>begin bold<i>italic</i></b>', '<b><i>&nbsp;</i>end bold</b>');
}
function testSplitDom() {
splitDomCases_(function(offset, firstHalfString, secondHalfString) {
helpTestSplit_(offset, firstHalfString, secondHalfString, false, true);
helpTestSplit_(offset, firstHalfString, secondHalfString, false, false);
});
}
function testSplitDomAndAppend() {
splitDomCases_(function(offset, firstHalfString, secondHalfString) {
helpTestSplit_(offset, firstHalfString, secondHalfString, true, false);
});
}
function testSplitDomAtElement() {
var node = document.createElement('div');
node.innerHTML = '<div>abc<br>def</div>';
document.body.appendChild(node);
goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_(node.firstChild, 1,
node.firstChild);
goog.testing.dom.assertHtmlContentsMatch('<div>abc</div><div><br>def</div>',
node);
goog.dom.removeNode(node);
}
function testSplitDomAtElementStart() {
var node = document.createElement('div');
node.innerHTML = '<div>abc<br>def</div>';
document.body.appendChild(node);
goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_(node.firstChild, 0,
node.firstChild);
goog.testing.dom.assertHtmlContentsMatch('<div></div><div>abc<br>def</div>',
node);
goog.dom.removeNode(node);
}
function testSplitDomAtChildlessElement() {
var node = document.createElement('div');
node.innerHTML = '<div>abc<br>def</div>';
document.body.appendChild(node);
var br = node.getElementsByTagName(goog.dom.TagName.BR)[0];
goog.editor.plugins.TagOnEnterHandler.splitDomAndAppend_(
br, 0, node.firstChild);
goog.testing.dom.assertHtmlContentsMatch('<div>abc</div><div><br>def</div>',
node);
goog.dom.removeNode(node);
}
function testReplaceWhiteSpaceWithNbsp() {
var node = document.createElement('div');
var textNode = document.createTextNode('');
node.appendChild(textNode);
textNode.nodeValue = ' test ';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, true, false);
assertHTMLEquals('&nbsp;test ', node.innerHTML);
textNode.nodeValue = ' test ';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, true, false);
assertHTMLEquals('&nbsp;test ', node.innerHTML);
textNode.nodeValue = ' test ';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, false, false);
assertHTMLEquals(' test&nbsp;', node.innerHTML);
textNode.nodeValue = ' test ';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, false, false);
assertHTMLEquals(' test&nbsp;', node.innerHTML);
textNode.nodeValue = '';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, false, false);
assertHTMLEquals('&nbsp;', node.innerHTML);
textNode.nodeValue = '';
goog.editor.plugins.TagOnEnterHandler.replaceWhiteSpaceWithNbsp_(
node.firstChild, false, true);
assertHTMLEquals('', node.innerHTML);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
goog.editor.plugins
<!DOCTYPE html>
<!--
All Rights Reserved.
@author ajp@google.com (Andy Perelson)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Trogedit Unit Tests - goog.editor.plugins.UndoRedo
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.UndoRedoTest');
</script>
</head>
<body>
<div id="testField">
</div>
</body>
</html>
@@ -0,0 +1,516 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.UndoRedoTest');
goog.setTestOnly('goog.editor.plugins.UndoRedoTest');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.browserrange');
goog.require('goog.editor.Field');
goog.require('goog.editor.plugins.LoremIpsum');
goog.require('goog.editor.plugins.UndoRedo');
goog.require('goog.events');
goog.require('goog.functions');
goog.require('goog.testing.MockClock');
goog.require('goog.testing.PropertyReplacer');
goog.require('goog.testing.StrictMock');
goog.require('goog.testing.jsunit');
var mockEditableField;
var editableField;
var fieldHashCode;
var undoPlugin;
var state;
var mockState;
var commands;
var clock;
var stubs = new goog.testing.PropertyReplacer();
function setUp() {
mockEditableField = new goog.testing.StrictMock(goog.editor.Field);
// Update the arg list verifier for dispatchCommandValueChange to
// correctly compare arguments that are arrays (or other complex objects).
mockEditableField.$registerArgumentListVerifier('dispatchEvent',
function(expected, args) {
return goog.array.equals(expected, args,
function(a, b) { assertObjectEquals(a, b); return true; });
});
mockEditableField.getHashCode = function() {
return 'fieldId';
};
undoPlugin = new goog.editor.plugins.UndoRedo();
undoPlugin.registerFieldObject(mockEditableField);
mockState = new goog.testing.StrictMock(
goog.editor.plugins.UndoRedo.UndoState_);
mockState.fieldHashCode = 'fieldId';
mockState.isAsynchronous = function() {
return false;
};
// Don't bother mocking the inherited event target pieces of the state.
// If we don't do this, then mocked asynchronous undos are a lot harder and
// that behavior is tested as part of the UndoRedoManager tests.
mockState.addEventListener = goog.nullFunction;
commands = [
goog.editor.plugins.UndoRedo.COMMAND.REDO,
goog.editor.plugins.UndoRedo.COMMAND.UNDO
];
state = new goog.editor.plugins.UndoRedo.UndoState_('1', '', null,
goog.nullFunction);
clock = new goog.testing.MockClock(true);
editableField = new goog.editor.Field('testField');
fieldHashCode = editableField.getHashCode();
}
function tearDown() {
// Reset field so any attempted access during disposes don't cause errors.
mockEditableField.$reset();
clock.dispose();
undoPlugin.dispose();
// NOTE(nicksantos): I think IE is blowing up on this call because
// it is lame. It manifests its lameness by throwing an exception.
// Kudos to XT for helping me to figure this out.
try {
} catch (e) {}
if (!editableField.isUneditable()) {
editableField.makeUneditable();
}
editableField.dispose();
goog.dom.getElement('testField').innerHTML = '';
stubs.reset();
}
// undo-redo plugin tests
function testQueryCommandValue() {
assertFalse('Must return false for empty undo stack.',
undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.UNDO));
assertFalse('Must return false for empty redo stack.',
undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.REDO));
undoPlugin.undoManager_.addState(mockState);
assertTrue('Must return true for a non-empty undo stack.',
undoPlugin.queryCommandValue(goog.editor.plugins.UndoRedo.COMMAND.UNDO));
}
function testExecCommand() {
undoPlugin.undoManager_.addState(mockState);
mockState.undo();
mockState.$replay();
undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.UNDO);
// Second undo should do nothing since only one item on stack.
undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.UNDO);
mockState.$verify();
mockState.$reset();
mockState.redo();
mockState.$replay();
undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
// Second redo should do nothing since only one item on stack.
undoPlugin.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
mockState.$verify();
}
function testHandleKeyboardShortcut_TrogStates() {
undoPlugin.undoManager_.addState(mockState);
undoPlugin.undoManager_.addState(state);
undoPlugin.undoManager_.undo();
mockEditableField.$reset();
var stubUndoEvent = {ctrlKey: true, altKey: false, shiftKey: false};
var stubRedoEvent = {ctrlKey: true, altKey: false, shiftKey: true};
var stubRedoEvent2 = {ctrlKey: true, altKey: false, shiftKey: false};
var result;
// Test handling Trogedit undos. Should always call EditableField's
// execCommand. Since EditableField is mocked, this will not result in a call
// to the mockState's undo and redo methods.
mockEditableField.execCommand(goog.editor.plugins.UndoRedo.COMMAND.UNDO);
mockEditableField.$replay();
result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'z', true);
assertTrue('Plugin must return true when it handles shortcut.', result);
mockEditableField.$verify();
mockEditableField.$reset();
mockEditableField.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
mockEditableField.$replay();
result = undoPlugin.handleKeyboardShortcut(stubRedoEvent, 'z', true);
assertTrue('Plugin must return true when it handles shortcut.', result);
mockEditableField.$verify();
mockEditableField.$reset();
mockEditableField.execCommand(goog.editor.plugins.UndoRedo.COMMAND.REDO);
mockEditableField.$replay();
result = undoPlugin.handleKeyboardShortcut(stubRedoEvent2, 'y', true);
assertTrue('Plugin must return true when it handles shortcut.', result);
mockEditableField.$verify();
mockEditableField.$reset();
mockEditableField.$replay();
result = undoPlugin.handleKeyboardShortcut(stubRedoEvent2, 'y', false);
assertFalse('Plugin must return false when modifier is not pressed.', result);
mockEditableField.$verify();
mockEditableField.$reset();
mockEditableField.$replay();
result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'f', true);
assertFalse('Plugin must return false when it doesn\'t handle shortcut.',
result);
mockEditableField.$verify();
}
function testHandleKeyboardShortcut_NotTrogStates() {
var stubUndoEvent = {ctrlKey: true, altKey: false, shiftKey: false};
// Trogedit undo states all have a fieldHashCode, nulling that out makes this
// state be treated as a non-Trogedit undo-redo state.
state.fieldHashCode = null;
undoPlugin.undoManager_.addState(state);
mockEditableField.$reset();
// Non-trog state shouldn't go through EditableField.execCommand, however,
// we still exect command value change dispatch since undo-redo plugin
// redispatches those anytime manager's state changes.
mockEditableField.dispatchEvent({
type: goog.editor.Field.EventType.COMMAND_VALUE_CHANGE,
commands: commands});
mockEditableField.$replay();
var result = undoPlugin.handleKeyboardShortcut(stubUndoEvent, 'z', true);
assertTrue('Plugin must return true when it handles shortcut.' , result);
mockEditableField.$verify();
}
function testEnable() {
assertFalse('Plugin must start disabled.',
undoPlugin.isEnabled(editableField));
editableField.makeEditable(editableField);
editableField.setHtml(false, '<div>a</div>');
undoPlugin.enable(editableField);
assertTrue(undoPlugin.isEnabled(editableField));
assertNotNull('Must have an event handler for enabled field.',
undoPlugin.eventHandlers_[fieldHashCode]);
var currentState = undoPlugin.currentStates_[fieldHashCode];
assertNotNull('Enabled plugin must have a current state.', currentState);
assertEquals('After enable, undo content must match the field content.',
editableField.getElement().innerHTML, currentState.undoContent_);
assertTrue('After enable, undo cursorPosition must match the field cursor' +
'position.', cursorPositionsEqual(getCurrentCursorPosition(),
currentState.undoCursorPosition_));
assertUndefined('Current state must never have redo content.',
currentState.redoContent_);
assertUndefined('Current state must never have redo cursor position.',
currentState.redoCursorPosition_);
}
function testDisable() {
editableField.makeEditable(editableField);
undoPlugin.enable(editableField);
assertTrue('Plugin must be enabled so we can test disabling.',
undoPlugin.isEnabled(editableField));
var delayedChangeFired = false;
goog.events.listenOnce(editableField,
goog.editor.Field.EventType.DELAYEDCHANGE,
function(e) {
delayedChangeFired = true;
});
editableField.setHtml(false, 'foo');
undoPlugin.disable(editableField);
assertTrue('disable must fire pending delayed changes.', delayedChangeFired);
assertEquals('disable must add undo state from pending change.',
1, undoPlugin.undoManager_.undoStack_.length);
assertFalse(undoPlugin.isEnabled(editableField));
assertUndefined('Disabled plugin must not have current state.',
undoPlugin.eventHandlers_[fieldHashCode]);
assertUndefined('Disabled plugin must not have event handlers.',
undoPlugin.eventHandlers_[fieldHashCode]);
}
function testUpdateCurrentState_() {
editableField.registerPlugin(new goog.editor.plugins.LoremIpsum('LOREM'));
editableField.makeEditable(editableField);
editableField.getPluginByClassId('LoremIpsum').usingLorem_ = true;
undoPlugin.updateCurrentState_(editableField);
var currentState = undoPlugin.currentStates_[fieldHashCode];
assertNotUndefined('Must create empty states for field using lorem ipsum.',
undoPlugin.currentStates_[fieldHashCode]);
assertEquals('', currentState.undoContent_);
assertNull(currentState.undoCursorPosition_);
editableField.getPluginByClassId('LoremIpsum').usingLorem_ = false;
// Pretend foo is the default contents to test '' == default contents
// behavior.
editableField.getInjectableContents = function(contents, styles) {
return contents == '' ? 'foo' : contents;
};
editableField.setHtml(false, 'foo');
undoPlugin.updateCurrentState_(editableField);
assertEquals(currentState, undoPlugin.currentStates_[fieldHashCode]);
// NOTE(user): Because there is already a current state, this setHtml will add
// a state to the undo stack.
editableField.setHtml(false, '<div>a</div>');
// Select some text so we have a valid selection that gets saved in the
// UndoState.
goog.dom.browserrange.createRangeFromNodeContents(
editableField.getElement()).select();
undoPlugin.updateCurrentState_(editableField);
currentState = undoPlugin.currentStates_[fieldHashCode];
assertNotNull('Must create state for field not using lorem ipsum',
currentState);
assertEquals(fieldHashCode, currentState.fieldHashCode);
var content = editableField.getElement().innerHTML;
var cursorPosition = getCurrentCursorPosition();
assertEquals(content, currentState.undoContent_);
assertTrue(cursorPositionsEqual(
cursorPosition, currentState.undoCursorPosition_));
assertUndefined(currentState.redoContent_);
assertUndefined(currentState.redoCursorPosition_);
undoPlugin.updateCurrentState_(editableField);
assertEquals('Updating state when state has not changed must not add undo ' +
'state to stack.', 1, undoPlugin.undoManager_.undoStack_.length);
assertEquals('Updating state when state has not changed must not create ' +
'a new state.', currentState, undoPlugin.currentStates_[fieldHashCode]);
assertUndefined('Updating state when state has not changed must not add ' +
'redo content.', currentState.redoContent_);
assertUndefined('Updating state when state has not changed must not add ' +
'redo cursor position.', currentState.redoCursorPosition_);
editableField.setHtml(false, '<div>b</div>');
undoPlugin.updateCurrentState_(editableField);
currentState = undoPlugin.currentStates_[fieldHashCode];
assertNotNull('Must create state for field not using lorem ipsum',
currentState);
assertEquals(fieldHashCode, currentState.fieldHashCode);
var newContent = editableField.getElement().innerHTML;
var newCursorPosition = getCurrentCursorPosition();
assertEquals(newContent, currentState.undoContent_);
assertTrue(cursorPositionsEqual(
newCursorPosition, currentState.undoCursorPosition_));
assertUndefined(currentState.redoContent_);
assertUndefined(currentState.redoCursorPosition_);
var undoState = goog.array.peek(undoPlugin.undoManager_.undoStack_);
assertNotNull('Must create state for field not using lorem ipsum',
currentState);
assertEquals(fieldHashCode, currentState.fieldHashCode);
assertEquals(content, undoState.undoContent_);
assertTrue(cursorPositionsEqual(
cursorPosition, undoState.undoCursorPosition_));
assertEquals(newContent, undoState.redoContent_);
assertTrue(cursorPositionsEqual(
newCursorPosition, undoState.redoCursorPosition_));
}
/**
* Tests that change events get restarted properly after an undo call despite
* an exception being thrown in the process (see bug/1991234).
*/
function testUndoRestartsChangeEvents() {
undoPlugin.registerFieldObject(editableField);
editableField.makeEditable(editableField);
editableField.setHtml(false, '<div>a</div>');
clock.tick(1000);
undoPlugin.enable(editableField);
// Change content so we can undo it.
editableField.setHtml(false, '<div>b</div>');
clock.tick(1000);
var currentState = undoPlugin.currentStates_[fieldHashCode];
stubs.set(editableField, 'setCursorPosition',
goog.functions.error('Faking exception during setCursorPosition()'));
try {
currentState.undo();
} catch (e) {
fail('Exception should not have been thrown during undo()');
}
assertEquals('Change events should be on', 0,
editableField.stoppedEvents_[goog.editor.Field.EventType.CHANGE]);
assertEquals('Delayed change events should be on', 0,
editableField.stoppedEvents_[goog.editor.Field.EventType.DELAYEDCHANGE]);
}
function testRefreshCurrentState() {
editableField.makeEditable(editableField);
editableField.setHtml(false, '<div>a</div>');
clock.tick(1000);
undoPlugin.enable(editableField);
// Create current state and verify it.
var currentState = undoPlugin.currentStates_[fieldHashCode];
assertEquals(fieldHashCode, currentState.fieldHashCode);
var content = editableField.getElement().innerHTML;
var cursorPosition = getCurrentCursorPosition();
assertEquals(content, currentState.undoContent_);
assertTrue(cursorPositionsEqual(
cursorPosition, currentState.undoCursorPosition_));
// Update the field w/o dispatching delayed change, and verify that the
// current state hasn't changed to reflect new values.
editableField.setHtml(false, '<div>b</div>', true);
clock.tick(1000);
currentState = undoPlugin.currentStates_[fieldHashCode];
assertEquals('Content must match old state.',
content, currentState.undoContent_);
assertTrue('Cursor position must match old state.',
cursorPositionsEqual(
cursorPosition, currentState.undoCursorPosition_));
undoPlugin.refreshCurrentState(editableField);
assertFalse('Refresh must not cause states to go on the undo-redo stack.',
undoPlugin.undoManager_.hasUndoState());
currentState = undoPlugin.currentStates_[fieldHashCode];
content = editableField.getElement().innerHTML;
cursorPosition = getCurrentCursorPosition();
assertEquals('Content must match current field state.',
content, currentState.undoContent_);
assertTrue('Cursor position must match current field state.',
cursorPositionsEqual(cursorPosition, currentState.undoCursorPosition_));
undoPlugin.disable(editableField);
assertUndefined(undoPlugin.currentStates_[fieldHashCode]);
undoPlugin.refreshCurrentState(editableField);
assertUndefined('Must not refresh current state of fields that do not have ' +
'undo-redo enabled.', undoPlugin.currentStates_[fieldHashCode]);
}
/**
* Returns the CursorPosition for the selection currently in the Field.
* @return {goog.editor.plugins.UndoRedo.CursorPosition_}
*/
function getCurrentCursorPosition() {
return undoPlugin.getCursorPosition_(editableField);
}
/**
* Compares two cursor positions and returns whether they are equal.
* @param {goog.editor.plugins.UndoRedo.CursorPosition_} a
* A cursor position.
* @param {goog.editor.plugins.UndoRedo.CursorPosition_} b
* A cursor position.
* @return {boolean} Whether the positions are equal.
*/
function cursorPositionsEqual(a, b) {
if (!a && !b) {
return true;
} else if (a && b) {
return a.toString() == b.toString();
}
// Only one cursor position is an object, can't be equal.
return false;
}
// Undo state tests
function testSetUndoState() {
state.setUndoState('content', 'position');
assertEquals('Undo content incorrectly set', 'content', state.undoContent_);
assertEquals('Undo cursor position incorrectly set', 'position',
state.undoCursorPosition_);
}
function testSetRedoState() {
state.setRedoState('content', 'position');
assertEquals('Redo content incorrectly set', 'content', state.redoContent_);
assertEquals('Redo cursor position incorrectly set', 'position',
state.redoCursorPosition_);
}
function testEquals() {
assertTrue('A state must equal itself', state.equals(state));
var state2 = new goog.editor.plugins.UndoRedo.UndoState_('1', '', null);
assertTrue('A state must equal a state with the same hash code and content.',
state.equals(state2));
state2 = new goog.editor.plugins.UndoRedo.UndoState_('1', '', 'foo');
assertTrue('States with different cursor positions must be equal',
state.equals(state2));
state2.setRedoState('bar', null);
assertFalse('States with different redo content must not be equal',
state.equals(state2));
state2 = new goog.editor.plugins.UndoRedo.UndoState_('3', '', null);
assertFalse('States with different field hash codes must not be equal',
state.equals(state2));
state2 = new goog.editor.plugins.UndoRedo.UndoState_('1', 'baz', null);
assertFalse('States with different undoContent must not be equal',
state.equals(state2));
}
/** @bug 1359214 */
function testClearUndoHistory() {
var undoRedoPlugin = new goog.editor.plugins.UndoRedo();
editableField.registerPlugin(undoRedoPlugin);
editableField.makeEditable(editableField);
editableField.dispatchChange();
clock.tick(10000);
editableField.getElement().innerHTML = 'y';
editableField.dispatchChange();
assertFalse(undoRedoPlugin.undoManager_.hasUndoState());
clock.tick(10000);
assertTrue(undoRedoPlugin.undoManager_.hasUndoState());
editableField.getElement().innerHTML = 'z';
editableField.dispatchChange();
var numCalls = 0;
goog.events.listen(editableField, goog.editor.Field.EventType.DELAYEDCHANGE,
function() {
numCalls++;
});
undoRedoPlugin.clearHistory();
// 1 call from stopChangeEvents(). 0 calls from startChangeEvents().
assertEquals('clearHistory must not cause delayed change when none pending',
1, numCalls);
clock.tick(10000);
assertFalse(undoRedoPlugin.undoManager_.hasUndoState());
}
@@ -0,0 +1,338 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Code for managing series of undo-redo actions in the form of
* {@link goog.editor.plugins.UndoRedoState}s.
*
*/
goog.provide('goog.editor.plugins.UndoRedoManager');
goog.provide('goog.editor.plugins.UndoRedoManager.EventType');
goog.require('goog.editor.plugins.UndoRedoState');
goog.require('goog.events');
goog.require('goog.events.EventTarget');
/**
* Manages undo and redo operations through a series of {@code UndoRedoState}s
* maintained on undo and redo stacks.
*
* @constructor
* @extends {goog.events.EventTarget}
*/
goog.editor.plugins.UndoRedoManager = function() {
goog.events.EventTarget.call(this);
/**
* The maximum number of states on the undo stack at any time. Used to limit
* the memory footprint of the undo-redo stack.
* TODO(user) have a separate memory size based limit.
* @type {number}
* @private
*/
this.maxUndoDepth_ = 100;
/**
* The undo stack.
* @type {Array<goog.editor.plugins.UndoRedoState>}
* @private
*/
this.undoStack_ = [];
/**
* The redo stack.
* @type {Array<goog.editor.plugins.UndoRedoState>}
* @private
*/
this.redoStack_ = [];
/**
* A queue of pending undo or redo actions. Stored as objects with two
* properties: func and state. The func property stores the undo or redo
* function to be called, the state property stores the state that method
* came from.
* @type {Array<Object>}
* @private
*/
this.pendingActions_ = [];
};
goog.inherits(goog.editor.plugins.UndoRedoManager, goog.events.EventTarget);
/**
* Event types for the events dispatched by undo-redo manager.
* @enum {string}
*/
goog.editor.plugins.UndoRedoManager.EventType = {
/**
* Signifies that he undo or redo stack transitioned between 0 and 1 states,
* meaning that the ability to peform undo or redo operations has changed.
*/
STATE_CHANGE: 'state_change',
/**
* Signifies that a state was just added to the undo stack. Events of this
* type will have a {@code state} property whose value is the state that
* was just added.
*/
STATE_ADDED: 'state_added',
/**
* Signifies that the undo method of a state is about to be called.
* Events of this type will have a {@code state} property whose value is the
* state whose undo action is about to be performed. If the event is cancelled
* the action does not proceed, but the state will still transition between
* stacks.
*/
BEFORE_UNDO: 'before_undo',
/**
* Signifies that the redo method of a state is about to be called.
* Events of this type will have a {@code state} property whose value is the
* state whose redo action is about to be performed. If the event is cancelled
* the action does not proceed, but the state will still transition between
* stacks.
*/
BEFORE_REDO: 'before_redo'
};
/**
* The key for the listener for the completion of the asynchronous state whose
* undo or redo action is in progress. Null if no action is in progress.
* @type {goog.events.Key}
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.inProgressActionKey_ = null;
/**
* Set the max undo stack depth (not the real memory usage).
* @param {number} depth Depth of the stack.
*/
goog.editor.plugins.UndoRedoManager.prototype.setMaxUndoDepth =
function(depth) {
this.maxUndoDepth_ = depth;
};
/**
* Add state to the undo stack. This clears the redo stack.
*
* @param {goog.editor.plugins.UndoRedoState} state The state to add to the undo
* stack.
*/
goog.editor.plugins.UndoRedoManager.prototype.addState = function(state) {
// TODO: is the state.equals check necessary?
if (this.undoStack_.length == 0 ||
!state.equals(this.undoStack_[this.undoStack_.length - 1])) {
this.undoStack_.push(state);
if (this.undoStack_.length > this.maxUndoDepth_) {
this.undoStack_.shift();
}
// Clobber the redo stack.
var redoLength = this.redoStack_.length;
this.redoStack_.length = 0;
this.dispatchEvent({
type: goog.editor.plugins.UndoRedoManager.EventType.STATE_ADDED,
state: state
});
// If the redo state had states on it, then clobbering the redo stack above
// has caused a state change.
if (this.undoStack_.length == 1 || redoLength) {
this.dispatchStateChange_();
}
}
};
/**
* Dispatches a STATE_CHANGE event with this manager as the target.
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.dispatchStateChange_ =
function() {
this.dispatchEvent(
goog.editor.plugins.UndoRedoManager.EventType.STATE_CHANGE);
};
/**
* Performs the undo operation of the state at the top of the undo stack, moving
* that state to the top of the redo stack. If the undo stack is empty, does
* nothing.
*/
goog.editor.plugins.UndoRedoManager.prototype.undo = function() {
this.shiftState_(this.undoStack_, this.redoStack_);
};
/**
* Performs the redo operation of the state at the top of the redo stack, moving
* that state to the top of the undo stack. If redo undo stack is empty, does
* nothing.
*/
goog.editor.plugins.UndoRedoManager.prototype.redo = function() {
this.shiftState_(this.redoStack_, this.undoStack_);
};
/**
* @return {boolean} Wether the undo stack has items on it, i.e., if it is
* possible to perform an undo operation.
*/
goog.editor.plugins.UndoRedoManager.prototype.hasUndoState = function() {
return this.undoStack_.length > 0;
};
/**
* @return {boolean} Wether the redo stack has items on it, i.e., if it is
* possible to perform a redo operation.
*/
goog.editor.plugins.UndoRedoManager.prototype.hasRedoState = function() {
return this.redoStack_.length > 0;
};
/**
* Move a state from one stack to the other, performing the appropriate undo
* or redo action.
*
* @param {Array<goog.editor.plugins.UndoRedoState>} fromStack Stack to move
* the state from.
* @param {Array<goog.editor.plugins.UndoRedoState>} toStack Stack to move
* the state to.
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.shiftState_ = function(
fromStack, toStack) {
if (fromStack.length) {
var state = fromStack.pop();
// Push the current state into the redo stack.
toStack.push(state);
this.addAction_({
type: fromStack == this.undoStack_ ?
goog.editor.plugins.UndoRedoManager.EventType.BEFORE_UNDO :
goog.editor.plugins.UndoRedoManager.EventType.BEFORE_REDO,
func: fromStack == this.undoStack_ ? state.undo : state.redo,
state: state
});
// If either stack transitioned between 0 and 1 in size then the ability
// to do an undo or redo has changed and we must dispatch a state change.
if (fromStack.length == 0 || toStack.length == 1) {
this.dispatchStateChange_();
}
}
};
/**
* Adds an action to the queue of pending undo or redo actions. If no actions
* are pending, immediately performs the action.
*
* @param {Object} action An undo or redo action. Stored as an object with two
* properties: func and state. The func property stores the undo or redo
* function to be called, the state property stores the state that method
* came from.
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.addAction_ = function(action) {
this.pendingActions_.push(action);
if (this.pendingActions_.length == 1) {
this.doAction_();
}
};
/**
* Executes the action at the front of the pending actions queue. If an action
* is already in progress or the queue is empty, does nothing.
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.doAction_ = function() {
if (this.inProgressActionKey_ || this.pendingActions_.length == 0) {
return;
}
var action = this.pendingActions_.shift();
var e = {
type: action.type,
state: action.state
};
if (this.dispatchEvent(e)) {
if (action.state.isAsynchronous()) {
this.inProgressActionKey_ = goog.events.listen(action.state,
goog.editor.plugins.UndoRedoState.ACTION_COMPLETED,
this.finishAction_, false, this);
action.func.call(action.state);
} else {
action.func.call(action.state);
this.doAction_();
}
}
};
/**
* Finishes processing the current in progress action, starting the next queued
* action if one exists.
* @private
*/
goog.editor.plugins.UndoRedoManager.prototype.finishAction_ = function() {
goog.events.unlistenByKey(/** @type {number} */ (this.inProgressActionKey_));
this.inProgressActionKey_ = null;
this.doAction_();
};
/**
* Clears the undo and redo stacks.
*/
goog.editor.plugins.UndoRedoManager.prototype.clearHistory = function() {
if (this.undoStack_.length > 0 || this.redoStack_.length > 0) {
this.undoStack_.length = 0;
this.redoStack_.length = 0;
this.dispatchStateChange_();
}
};
/**
* @return {goog.editor.plugins.UndoRedoState|undefined} The state at the top of
* the undo stack without removing it from the stack.
*/
goog.editor.plugins.UndoRedoManager.prototype.undoPeek = function() {
return this.undoStack_[this.undoStack_.length - 1];
};
/**
* @return {goog.editor.plugins.UndoRedoState|undefined} The state at the top of
* the redo stack without removing it from the stack.
*/
goog.editor.plugins.UndoRedoManager.prototype.redoPeek = function() {
return this.redoStack_[this.redoStack_.length - 1];
};
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
@author ajp@google.com (Andy Perelson)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Trogedit Unit Tests - goog.editor.plugins.UndoRedoManager
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.UndoRedoManagerTest');
</script>
</head>
<body>
</body>
</html>
@@ -0,0 +1,387 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.UndoRedoManagerTest');
goog.setTestOnly('goog.editor.plugins.UndoRedoManagerTest');
goog.require('goog.editor.plugins.UndoRedoManager');
goog.require('goog.editor.plugins.UndoRedoState');
goog.require('goog.events');
goog.require('goog.testing.StrictMock');
goog.require('goog.testing.jsunit');
var mockState1;
var mockState2;
var mockState3;
var states;
var manager;
var stateChangeCount;
var beforeUndoCount;
var beforeRedoCount;
var preventDefault;
function setUp() {
manager = new goog.editor.plugins.UndoRedoManager();
stateChangeCount = 0;
goog.events.listen(manager,
goog.editor.plugins.UndoRedoManager.EventType.STATE_CHANGE,
function() {
stateChangeCount++;
});
beforeUndoCount = 0;
preventDefault = false;
goog.events.listen(manager,
goog.editor.plugins.UndoRedoManager.EventType.BEFORE_UNDO,
function(e) {
beforeUndoCount++;
if (preventDefault) {
e.preventDefault();
}
});
beforeRedoCount = 0;
goog.events.listen(manager,
goog.editor.plugins.UndoRedoManager.EventType.BEFORE_REDO,
function(e) {
beforeRedoCount++;
if (preventDefault) {
e.preventDefault();
}
});
mockState1 = new goog.testing.StrictMock(goog.editor.plugins.UndoRedoState);
mockState2 = new goog.testing.StrictMock(goog.editor.plugins.UndoRedoState);
mockState3 = new goog.testing.StrictMock(goog.editor.plugins.UndoRedoState);
states = [mockState1, mockState2, mockState3];
mockState1.equals = mockState2.equals = mockState3.equals = function(state) {
return this == state;
};
mockState1.isAsynchronous = mockState2.isAsynchronous =
mockState3.isAsynchronous = function() {
return false;
};
}
function tearDown() {
goog.events.removeAll(manager);
manager.dispose();
}
/**
* Adds all the mock states to the undo-redo manager.
*/
function addStatesToManager() {
manager.addState(states[0]);
for (var i = 1; i < states.length; i++) {
var state = states[i];
manager.addState(state);
}
stateChangeCount = 0;
}
/**
* Resets all mock states so that they are ready for testing.
*/
function resetStates() {
for (var i = 0; i < states.length; i++) {
states[i].$reset();
}
}
function testSetMaxUndoDepth() {
manager.setMaxUndoDepth(2);
addStatesToManager();
assertArrayEquals('Undo stack must contain only the two most recent states.',
[mockState2, mockState3], manager.undoStack_);
}
function testAddState() {
var stateAddedCount = 0;
goog.events.listen(manager,
goog.editor.plugins.UndoRedoManager.EventType.STATE_ADDED,
function() {
stateAddedCount++;
});
manager.addState(mockState1);
assertArrayEquals('Undo stack must contain added state.',
[mockState1], manager.undoStack_);
assertEquals('Manager must dispatch one state change event on ' +
'undo stack 0->1 transition.', 1, stateChangeCount);
assertEquals('State added must have dispatched once.', 1, stateAddedCount);
mockState1.$reset();
// Test adding same state twice.
manager.addState(mockState1);
assertArrayEquals('Undo stack must not contain two equal, sequential states.',
[mockState1], manager.undoStack_);
assertEquals('Manager must not dispatch state change event when nothing is ' +
'added to the stack.', 1, stateChangeCount);
assertEquals('State added must have dispatched once.', 1, stateAddedCount);
// Test adding a second state.
manager.addState(mockState2);
assertArrayEquals('Undo stack must contain both states.',
[mockState1, mockState2], manager.undoStack_);
assertEquals('Manager must not dispatch state change event when second ' +
'state is added to the stack.', 1, stateChangeCount);
assertEquals('State added must have dispatched twice.', 2, stateAddedCount);
// Test adding a state when there is state on the redo stack.
manager.undo();
assertEquals('Manager must dispatch state change when redo stack goes to 1.',
2, stateChangeCount);
manager.addState(mockState3);
assertArrayEquals('Undo stack must contain states 1 and 3.',
[mockState1, mockState3], manager.undoStack_);
assertEquals('Manager must dispatch state change event when redo stack ' +
'goes to zero.', 3, stateChangeCount);
assertEquals('State added must have dispatched three times.',
3, stateAddedCount);
}
function testHasState() {
assertFalse('New manager must have no undo state.', manager.hasUndoState());
assertFalse('New manager must have no redo state.', manager.hasRedoState());
manager.addState(mockState1);
assertTrue('Manager must have only undo state.', manager.hasUndoState());
assertFalse('Manager must have no redo state.', manager.hasRedoState());
manager.undo();
assertFalse('Manager must have no undo state.', manager.hasUndoState());
assertTrue('Manager must have only redo state.', manager.hasRedoState());
}
function testClearHistory() {
addStatesToManager();
manager.undo();
stateChangeCount = 0;
manager.clearHistory();
assertFalse('Undo stack must be empty.', manager.hasUndoState());
assertFalse('Redo stack must be empty.', manager.hasRedoState());
assertEquals('State change count must be 1 after clear history.',
1, stateChangeCount);
manager.clearHistory();
assertEquals('Repeated clearHistory must not change state change count.',
1, stateChangeCount);
}
function testUndo() {
addStatesToManager();
mockState3.undo();
mockState3.$replay();
manager.undo();
assertEquals('Adding first item to redo stack must dispatch state change.',
1, stateChangeCount);
assertEquals('Undo must cause before action to dispatch',
1, beforeUndoCount);
mockState3.$verify();
preventDefault = true;
mockState2.$replay();
manager.undo();
assertEquals('No stack transitions between 0 and 1, must not dispatch ' +
'state change.', 1, stateChangeCount);
assertEquals('Undo must cause before action to dispatch',
2, beforeUndoCount);
mockState2.$verify(); // Verify that undo was prevented.
preventDefault = false;
mockState1.undo();
mockState1.$replay();
manager.undo();
assertEquals('Doing last undo operation must dispatch state change.',
2, stateChangeCount);
assertEquals('Undo must cause before action to dispatch',
3, beforeUndoCount);
mockState1.$verify();
}
function testUndo_Asynchronous() {
// Using a stub instead of a mock here so that the state can behave as an
// EventTarget and dispatch events.
var stubState = new goog.editor.plugins.UndoRedoState(true);
var undoCalled = false;
stubState.undo = function() {
undoCalled = true;
};
stubState.redo = goog.nullFunction;
stubState.equals = function() {
return false;
};
manager.addState(mockState2);
manager.addState(mockState1);
manager.addState(stubState);
manager.undo();
assertTrue('undoCalled must be true (undo must be called).', undoCalled);
assertEquals('Undo must cause before action to dispatch',
1, beforeUndoCount);
// Calling undo shouldn't actually undo since the first async undo hasn't
// fired an event yet.
mockState1.$replay();
manager.undo();
mockState1.$verify();
assertEquals('Before action must not dispatch for pending undo.',
1, beforeUndoCount);
// Dispatching undo completed on first undo, should cause the second pending
// undo to happen.
mockState1.$reset();
mockState1.undo();
mockState1.$replay();
mockState2.$replay(); // Nothing should happen to mockState2.
stubState.dispatchEvent(goog.editor.plugins.UndoRedoState.ACTION_COMPLETED);
mockState1.$verify();
mockState2.$verify();
assertEquals('Second undo must cause before action to dispatch',
2, beforeUndoCount);
// Test last undo.
mockState2.$reset();
mockState2.undo();
mockState2.$replay();
manager.undo();
mockState2.$verify();
assertEquals('Third undo must cause before action to dispatch',
3, beforeUndoCount);
}
function testRedo() {
addStatesToManager();
manager.undo();
manager.undo();
manager.undo();
resetStates();
stateChangeCount = 0;
mockState1.redo();
mockState1.$replay();
manager.redo();
assertEquals('Pushing first item onto undo stack during redo must dispatch ' +
'state change.', 1, stateChangeCount);
assertEquals('First redo must cause before action to dispatch',
1, beforeRedoCount);
mockState1.$verify();
preventDefault = true;
mockState2.$replay();
manager.redo();
assertEquals('No stack transitions between 0 and 1, must not dispatch ' +
'state change.', 1, stateChangeCount);
assertEquals('Second redo must cause before action to dispatch',
2, beforeRedoCount);
mockState2.$verify(); // Verify that redo was prevented.
preventDefault = false;
mockState3.redo();
mockState3.$replay();
manager.redo();
assertEquals('Removing last item from redo stack must dispatch state change.',
2, stateChangeCount);
assertEquals('Third redo must cause before action to dispatch',
3, beforeRedoCount);
mockState3.$verify();
mockState3.$reset();
mockState3.undo();
mockState3.$replay();
manager.undo();
assertEquals('Putting item on redo stack must dispatch state change.',
3, stateChangeCount);
assertEquals('Undo must cause before action to dispatch',
4, beforeUndoCount);
mockState3.$verify();
}
function testRedo_Asynchronous() {
var stubState = new goog.editor.plugins.UndoRedoState(true);
var redoCalled = false;
stubState.redo = function() {
redoCalled = true;
};
stubState.undo = goog.nullFunction;
stubState.equals = function() {
return false;
};
manager.addState(stubState);
manager.addState(mockState1);
manager.addState(mockState2);
manager.undo();
manager.undo();
manager.undo();
stubState.dispatchEvent(goog.editor.plugins.UndoRedoState.ACTION_COMPLETED);
resetStates();
manager.redo();
assertTrue('redoCalled must be true (redo must be called).', redoCalled);
// Calling redo shouldn't actually redo since the first async redo hasn't
// fired an event yet.
mockState1.$replay();
manager.redo();
mockState1.$verify();
// Dispatching redo completed on first redo, should cause the second pending
// redo to happen.
mockState1.$reset();
mockState1.redo();
mockState1.$replay();
mockState2.$replay(); // Nothing should happen to mockState1.
stubState.dispatchEvent(goog.editor.plugins.UndoRedoState.ACTION_COMPLETED);
mockState1.$verify();
mockState2.$verify();
// Test last redo.
mockState2.$reset();
mockState2.redo();
mockState2.$replay();
manager.redo();
mockState2.$verify();
}
function testUndoAndRedoPeek() {
addStatesToManager();
manager.undo();
assertEquals('redoPeek must return the top of the redo stack.',
manager.redoStack_[manager.redoStack_.length - 1], manager.redoPeek());
assertEquals('undoPeek must return the top of the undo stack.',
manager.undoStack_[manager.undoStack_.length - 1], manager.undoPeek());
}
@@ -0,0 +1,86 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Code for an UndoRedoState interface representing an undo and
* redo action for a particular state change. To be used by
* {@link goog.editor.plugins.UndoRedoManager}.
*
*/
goog.provide('goog.editor.plugins.UndoRedoState');
goog.require('goog.events.EventTarget');
/**
* Represents an undo and redo action for a particular state transition.
*
* @param {boolean} asynchronous Whether the undo or redo actions for this
* state complete asynchronously. If true, then this state must fire
* an ACTION_COMPLETED event when undo or redo is complete.
* @constructor
* @extends {goog.events.EventTarget}
*/
goog.editor.plugins.UndoRedoState = function(asynchronous) {
goog.editor.plugins.UndoRedoState.base(this, 'constructor');
/**
* Indicates if the undo or redo actions for this state complete
* asynchronously.
* @type {boolean}
* @private
*/
this.asynchronous_ = asynchronous;
};
goog.inherits(goog.editor.plugins.UndoRedoState, goog.events.EventTarget);
/**
* Event type for events indicating that this state has completed an undo or
* redo operation.
*/
goog.editor.plugins.UndoRedoState.ACTION_COMPLETED = 'action_completed';
/**
* @return {boolean} Whether or not the undo and redo actions of this state
* complete asynchronously. If true, the state will fire an ACTION_COMPLETED
* event when an undo or redo action is complete.
*/
goog.editor.plugins.UndoRedoState.prototype.isAsynchronous = function() {
return this.asynchronous_;
};
/**
* Undoes the action represented by this state.
*/
goog.editor.plugins.UndoRedoState.prototype.undo = goog.abstractMethod;
/**
* Redoes the action represented by this state.
*/
goog.editor.plugins.UndoRedoState.prototype.redo = goog.abstractMethod;
/**
* Checks if two undo-redo states are the same.
* @param {goog.editor.plugins.UndoRedoState} state The state to compare.
* @return {boolean} Wether the two states are equal.
*/
goog.editor.plugins.UndoRedoState.prototype.equals = goog.abstractMethod;
@@ -0,0 +1,28 @@
goog.editor.plugins
<!DOCTYPE html>
<!--
All Rights Reserved.
@author ajp@google.com (Andy Perelson)
-->
<html>
<!--
Copyright 2008 The Closure Library Authors. All Rights Reserved.
Use of this source code is governed by the Apache License, Version 2.0.
See the COPYING file for details.
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Trogedit Unit Tests - goog.editor.plugins.UndoRedoState
</title>
<script src="../../base.js">
</script>
<script>
goog.require('goog.editor.plugins.UndoRedoStateTest');
</script>
</head>
<body>
</body>
</html>
@@ -0,0 +1,34 @@
// Copyright 2008 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
goog.provide('goog.editor.plugins.UndoRedoStateTest');
goog.setTestOnly('goog.editor.plugins.UndoRedoStateTest');
goog.require('goog.editor.plugins.UndoRedoState');
goog.require('goog.testing.jsunit');
var asyncState;
var syncState;
function setUp() {
asyncState = new goog.editor.plugins.UndoRedoState(true);
syncState = new goog.editor.plugins.UndoRedoState(false);
}
function testIsAsynchronous() {
assertTrue('Must return true for asynchronous state',
asyncState.isAsynchronous());
assertFalse('Must return false for synchronous state',
syncState.isAsynchronous());
}
@@ -0,0 +1,632 @@
// 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 Utilties for working with ranges.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.range');
goog.provide('goog.editor.range.Point');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.Range');
goog.require('goog.dom.RangeEndpoint');
goog.require('goog.dom.SavedCaretRange');
goog.require('goog.editor.node');
goog.require('goog.editor.style');
goog.require('goog.iter');
goog.require('goog.userAgent');
/**
* Given a range and an element, create a narrower range that is limited to the
* boundaries of the element. If the range starts (or ends) outside the
* element, the narrowed range's start point (or end point) will be the
* leftmost (or rightmost) leaf of the element.
* @param {goog.dom.AbstractRange} range The range.
* @param {Element} el The element to limit the range to.
* @return {goog.dom.AbstractRange} A new narrowed range, or null if the
* element does not contain any part of the given range.
*/
goog.editor.range.narrow = function(range, el) {
var startContainer = range.getStartNode();
var endContainer = range.getEndNode();
if (startContainer && endContainer) {
var isElement = function(node) {
return node == el;
};
var hasStart = goog.dom.getAncestor(startContainer, isElement, true);
var hasEnd = goog.dom.getAncestor(endContainer, isElement, true);
if (hasStart && hasEnd) {
// The range is contained entirely within this element.
return range.clone();
} else if (hasStart) {
// The range starts inside the element, but ends outside it.
var leaf = goog.editor.node.getRightMostLeaf(el);
return goog.dom.Range.createFromNodes(
range.getStartNode(), range.getStartOffset(),
leaf, goog.editor.node.getLength(leaf));
} else if (hasEnd) {
// The range starts outside the element, but ends inside it.
return goog.dom.Range.createFromNodes(
goog.editor.node.getLeftMostLeaf(el), 0,
range.getEndNode(), range.getEndOffset());
}
}
// The selection starts and ends outside the element.
return null;
};
/**
* Given a range, expand the range to include outer tags if the full contents of
* those tags are entirely selected. This essentially changes the dom position,
* but not the visible position of the range.
* Ex. <li>foo</li> if "foo" is selected, instead of returning start and end
* nodes as the foo text node, return the li.
* @param {goog.dom.AbstractRange} range The range.
* @param {Node=} opt_stopNode Optional node to stop expanding past.
* @return {!goog.dom.AbstractRange} The expanded range.
*/
goog.editor.range.expand = function(range, opt_stopNode) {
// Expand the start out to the common container.
var expandedRange = goog.editor.range.expandEndPointToContainer_(
range, goog.dom.RangeEndpoint.START, opt_stopNode);
// Expand the end out to the common container.
expandedRange = goog.editor.range.expandEndPointToContainer_(
expandedRange, goog.dom.RangeEndpoint.END, opt_stopNode);
var startNode = expandedRange.getStartNode();
var endNode = expandedRange.getEndNode();
var startOffset = expandedRange.getStartOffset();
var endOffset = expandedRange.getEndOffset();
// If we have reached a common container, now expand out.
if (startNode == endNode) {
while (endNode != opt_stopNode &&
startOffset == 0 &&
endOffset == goog.editor.node.getLength(endNode)) {
// Select the parent instead.
var parentNode = endNode.parentNode;
startOffset = goog.array.indexOf(parentNode.childNodes, endNode);
endOffset = startOffset + 1;
endNode = parentNode;
}
startNode = endNode;
}
return goog.dom.Range.createFromNodes(startNode, startOffset,
endNode, endOffset);
};
/**
* Given a range, expands the start or end points as far out towards the
* range's common container (or stopNode, if provided) as possible, while
* perserving the same visible position.
*
* @param {goog.dom.AbstractRange} range The range to expand.
* @param {goog.dom.RangeEndpoint} endpoint The endpoint to expand.
* @param {Node=} opt_stopNode Optional node to stop expanding past.
* @return {!goog.dom.AbstractRange} The expanded range.
* @private
*/
goog.editor.range.expandEndPointToContainer_ = function(range, endpoint,
opt_stopNode) {
var expandStart = endpoint == goog.dom.RangeEndpoint.START;
var node = expandStart ? range.getStartNode() : range.getEndNode();
var offset = expandStart ? range.getStartOffset() : range.getEndOffset();
var container = range.getContainerElement();
// Expand the node out until we reach the container or the stop node.
while (node != container && node != opt_stopNode) {
// It is only valid to expand the start if we are at the start of a node
// (offset 0) or expand the end if we are at the end of a node
// (offset length).
if (expandStart && offset != 0 ||
!expandStart && offset != goog.editor.node.getLength(node)) {
break;
}
var parentNode = node.parentNode;
var index = goog.array.indexOf(parentNode.childNodes, node);
offset = expandStart ? index : index + 1;
node = parentNode;
}
return goog.dom.Range.createFromNodes(
expandStart ? node : range.getStartNode(),
expandStart ? offset : range.getStartOffset(),
expandStart ? range.getEndNode() : node,
expandStart ? range.getEndOffset() : offset);
};
/**
* Cause the window's selection to be the start of this node.
* @param {Node} node The node to select the start of.
*/
goog.editor.range.selectNodeStart = function(node) {
goog.dom.Range.createCaret(goog.editor.node.getLeftMostLeaf(node), 0).
select();
};
/**
* Position the cursor immediately to the left or right of "node".
* In Firefox, the selection parent is outside of "node", so the cursor can
* effectively be moved to the end of a link node, without being considered
* inside of it.
* Note: This does not always work in WebKit. In particular, if you try to
* place a cursor to the right of a link, typing still puts you in the link.
* Bug: http://bugs.webkit.org/show_bug.cgi?id=17697
* @param {Node} node The node to position the cursor relative to.
* @param {boolean} toLeft True to place it to the left, false to the right.
* @return {!goog.dom.AbstractRange} The newly selected range.
*/
goog.editor.range.placeCursorNextTo = function(node, toLeft) {
var parent = node.parentNode;
var offset = goog.array.indexOf(parent.childNodes, node) +
(toLeft ? 0 : 1);
var point = goog.editor.range.Point.createDeepestPoint(
parent, offset, toLeft, true);
var range = goog.dom.Range.createCaret(point.node, point.offset);
range.select();
return range;
};
/**
* Normalizes the node, preserving the selection of the document.
*
* May also normalize things outside the node, if it is more efficient to do so.
*
* @param {Node} node The node to normalize.
*/
goog.editor.range.selectionPreservingNormalize = function(node) {
var doc = goog.dom.getOwnerDocument(node);
var selection = goog.dom.Range.createFromWindow(goog.dom.getWindow(doc));
var normalizedRange =
goog.editor.range.rangePreservingNormalize(node, selection);
if (normalizedRange) {
normalizedRange.select();
}
};
/**
* Manually normalizes the node in IE, since native normalize in IE causes
* transient problems.
* @param {Node} node The node to normalize.
* @private
*/
goog.editor.range.normalizeNodeIe_ = function(node) {
var lastText = null;
var child = node.firstChild;
while (child) {
var next = child.nextSibling;
if (child.nodeType == goog.dom.NodeType.TEXT) {
if (child.nodeValue == '') {
node.removeChild(child);
} else if (lastText) {
lastText.nodeValue += child.nodeValue;
node.removeChild(child);
} else {
lastText = child;
}
} else {
goog.editor.range.normalizeNodeIe_(child);
lastText = null;
}
child = next;
}
};
/**
* Normalizes the given node.
* @param {Node} node The node to normalize.
*/
goog.editor.range.normalizeNode = function(node) {
if (goog.userAgent.IE) {
goog.editor.range.normalizeNodeIe_(node);
} else {
node.normalize();
}
};
/**
* Normalizes the node, preserving a range of the document.
*
* May also normalize things outside the node, if it is more efficient to do so.
*
* @param {Node} node The node to normalize.
* @param {goog.dom.AbstractRange?} range The range to normalize.
* @return {goog.dom.AbstractRange?} The range, adjusted for normalization.
*/
goog.editor.range.rangePreservingNormalize = function(node, range) {
if (range) {
var rangeFactory = goog.editor.range.normalize(range);
// WebKit has broken selection affinity, so carets tend to jump out of the
// beginning of inline elements. This means that if we're doing the
// normalize as the result of a range that will later become the selection,
// we might not normalize something in the range after it is read back from
// the selection. We can't just normalize the parentNode here because WebKit
// can move the selection range out of multiple inline parents.
var container = goog.editor.style.getContainer(range.getContainerElement());
}
if (container) {
goog.editor.range.normalizeNode(
goog.dom.findCommonAncestor(container, node));
} else if (node) {
goog.editor.range.normalizeNode(node);
}
if (rangeFactory) {
return rangeFactory();
} else {
return null;
}
};
/**
* Get the deepest point in the DOM that's equivalent to the endpoint of the
* given range.
*
* @param {goog.dom.AbstractRange} range A range.
* @param {boolean} atStart True for the start point, false for the end point.
* @return {!goog.editor.range.Point} The end point, expressed as a node
* and an offset.
*/
goog.editor.range.getDeepEndPoint = function(range, atStart) {
return atStart ?
goog.editor.range.Point.createDeepestPoint(
range.getStartNode(), range.getStartOffset()) :
goog.editor.range.Point.createDeepestPoint(
range.getEndNode(), range.getEndOffset());
};
/**
* Given a range in the current DOM, create a factory for a range that
* represents the same selection in a normalized DOM. The factory function
* should be invoked after the DOM is normalized.
*
* All browsers do a bad job preserving ranges across DOM normalization.
* The issue is best described in this 5-year-old bug report:
* https://bugzilla.mozilla.org/show_bug.cgi?id=191864
* For most applications, this isn't a problem. The browsers do a good job
* handling un-normalized text, so there's usually no reason to normalize.
*
* The exception to this rule is the rich text editing commands
* execCommand and queryCommandValue, which will fail often if there are
* un-normalized text nodes.
*
* The factory function creates new ranges so that we can normalize the DOM
* without problems. It must be created before any normalization happens,
* and invoked after normalization happens.
*
* @param {goog.dom.AbstractRange} range The range to normalize. It may
* become invalid after body.normalize() is called.
* @return {function(): goog.dom.AbstractRange} A factory for a normalized
* range. Should be called after body.normalize() is called.
*/
goog.editor.range.normalize = function(range) {
var isReversed = range.isReversed();
var anchorPoint = goog.editor.range.normalizePoint_(
goog.editor.range.getDeepEndPoint(range, !isReversed));
var anchorParent = anchorPoint.getParentPoint();
var anchorPreviousSibling = anchorPoint.node.previousSibling;
if (anchorPoint.node.nodeType == goog.dom.NodeType.TEXT) {
anchorPoint.node = null;
}
var focusPoint = goog.editor.range.normalizePoint_(
goog.editor.range.getDeepEndPoint(range, isReversed));
var focusParent = focusPoint.getParentPoint();
var focusPreviousSibling = focusPoint.node.previousSibling;
if (focusPoint.node.nodeType == goog.dom.NodeType.TEXT) {
focusPoint.node = null;
}
return function() {
if (!anchorPoint.node && anchorPreviousSibling) {
// If anchorPoint.node was previously an empty text node with no siblings,
// anchorPreviousSibling may not have a nextSibling since that node will
// no longer exist. Do our best and point to the end of the previous
// element.
anchorPoint.node = anchorPreviousSibling.nextSibling;
if (!anchorPoint.node) {
anchorPoint = goog.editor.range.Point.getPointAtEndOfNode(
anchorPreviousSibling);
}
}
if (!focusPoint.node && focusPreviousSibling) {
// If focusPoint.node was previously an empty text node with no siblings,
// focusPreviousSibling may not have a nextSibling since that node will no
// longer exist. Do our best and point to the end of the previous
// element.
focusPoint.node = focusPreviousSibling.nextSibling;
if (!focusPoint.node) {
focusPoint = goog.editor.range.Point.getPointAtEndOfNode(
focusPreviousSibling);
}
}
return goog.dom.Range.createFromNodes(
anchorPoint.node || anchorParent.node.firstChild || anchorParent.node,
anchorPoint.offset,
focusPoint.node || focusParent.node.firstChild || focusParent.node,
focusPoint.offset);
};
};
/**
* Given a point in the current DOM, adjust it to represent the same point in
* a normalized DOM.
*
* See the comments on goog.editor.range.normalize for more context.
*
* @param {goog.editor.range.Point} point A point in the document.
* @return {!goog.editor.range.Point} The same point, for easy chaining.
* @private
*/
goog.editor.range.normalizePoint_ = function(point) {
var previous;
if (point.node.nodeType == goog.dom.NodeType.TEXT) {
// If the cursor position is in a text node,
// look at all the previous text siblings of the text node,
// and set the offset relative to the earliest text sibling.
for (var current = point.node.previousSibling;
current && current.nodeType == goog.dom.NodeType.TEXT;
current = current.previousSibling) {
point.offset += goog.editor.node.getLength(current);
}
previous = current;
} else {
previous = point.node.previousSibling;
}
var parent = point.node.parentNode;
point.node = previous ? previous.nextSibling : parent.firstChild;
return point;
};
/**
* Checks if a range is completely inside an editable region.
* @param {goog.dom.AbstractRange} range The range to test.
* @return {boolean} Whether the range is completely inside an editable region.
*/
goog.editor.range.isEditable = function(range) {
var rangeContainer = range.getContainerElement();
// Closure's implementation of getContainerElement() is a little too
// smart in IE when exactly one element is contained in the range.
// It assumes that there's a user whose intent was actually to select
// all that element's children, so it returns the element itself as its
// own containing element.
// This little sanity check detects this condition so we can account for it.
var rangeContainerIsOutsideRange =
range.getStartNode() != rangeContainer.parentElement;
return (rangeContainerIsOutsideRange &&
goog.editor.node.isEditableContainer(rangeContainer)) ||
goog.editor.node.isEditable(rangeContainer);
};
/**
* Returns whether the given range intersects with any instance of the given
* tag.
* @param {goog.dom.AbstractRange} range The range to check.
* @param {goog.dom.TagName} tagName The name of the tag.
* @return {boolean} Whether the given range intersects with any instance of
* the given tag.
*/
goog.editor.range.intersectsTag = function(range, tagName) {
if (goog.dom.getAncestorByTagNameAndClass(range.getContainerElement(),
tagName)) {
return true;
}
return goog.iter.some(range, function(node) {
return node.tagName == tagName;
});
};
/**
* One endpoint of a range, represented as a Node and and offset.
* @param {Node} node The node containing the point.
* @param {number} offset The offset of the point into the node.
* @constructor
* @final
*/
goog.editor.range.Point = function(node, offset) {
/**
* The node containing the point.
* @type {Node}
*/
this.node = node;
/**
* The offset of the point into the node.
* @type {number}
*/
this.offset = offset;
};
/**
* Gets the point of this point's node in the DOM.
* @return {!goog.editor.range.Point} The node's point.
*/
goog.editor.range.Point.prototype.getParentPoint = function() {
var parent = this.node.parentNode;
return new goog.editor.range.Point(
parent, goog.array.indexOf(parent.childNodes, this.node));
};
/**
* Construct the deepest possible point in the DOM that's equivalent
* to the given point, expressed as a node and an offset.
* @param {Node} node The node containing the point.
* @param {number} offset The offset of the point from the node.
* @param {boolean=} opt_trendLeft Notice that a (node, offset) pair may be
* equivalent to more than one descendent (node, offset) pair in the DOM.
* By default, we trend rightward. If this parameter is true, then we
* trend leftward. The tendency to fall rightward by default is for
* consistency with other range APIs (like placeCursorNextTo).
* @param {boolean=} opt_stopOnChildlessElement If true, and we encounter
* a Node which is an Element that cannot have children, we return a Point
* based on its parent rather than that Node itself.
* @return {!goog.editor.range.Point} A new point.
*/
goog.editor.range.Point.createDeepestPoint =
function(node, offset, opt_trendLeft, opt_stopOnChildlessElement) {
while (node.nodeType == goog.dom.NodeType.ELEMENT) {
var child = node.childNodes[offset];
if (!child && !node.lastChild) {
break;
} else if (child) {
var prevSibling = child.previousSibling;
if (opt_trendLeft && prevSibling) {
if (opt_stopOnChildlessElement &&
goog.editor.range.Point.isTerminalElement_(prevSibling)) {
break;
}
node = prevSibling;
offset = goog.editor.node.getLength(node);
} else {
if (opt_stopOnChildlessElement &&
goog.editor.range.Point.isTerminalElement_(child)) {
break;
}
node = child;
offset = 0;
}
} else {
if (opt_stopOnChildlessElement &&
goog.editor.range.Point.isTerminalElement_(node.lastChild)) {
break;
}
node = node.lastChild;
offset = goog.editor.node.getLength(node);
}
}
return new goog.editor.range.Point(node, offset);
};
/**
* Return true if the specified node is an Element that is not expected to have
* children. The createDeepestPoint() method should not traverse into
* such elements.
* @param {Node} node .
* @return {boolean} True if the node is an Element that does not contain
* child nodes (e.g. BR, IMG).
* @private
*/
goog.editor.range.Point.isTerminalElement_ = function(node) {
return (node.nodeType == goog.dom.NodeType.ELEMENT &&
!goog.dom.canHaveChildren(node));
};
/**
* Construct a point at the very end of the given node.
* @param {Node} node The node to create a point for.
* @return {!goog.editor.range.Point} A new point.
*/
goog.editor.range.Point.getPointAtEndOfNode = function(node) {
return new goog.editor.range.Point(node, goog.editor.node.getLength(node));
};
/**
* Saves the range by inserting carets into the HTML.
*
* Unlike the regular saveUsingCarets, this SavedRange normalizes text nodes.
* Browsers have other bugs where they don't handle split text nodes in
* contentEditable regions right.
*
* @param {goog.dom.AbstractRange} range The abstract range object.
* @return {!goog.dom.SavedCaretRange} A saved caret range that normalizes
* text nodes.
*/
goog.editor.range.saveUsingNormalizedCarets = function(range) {
return new goog.editor.range.NormalizedCaretRange_(range);
};
/**
* Saves the range using carets, but normalizes text nodes when carets
* are removed.
* @see goog.editor.range.saveUsingNormalizedCarets
* @param {goog.dom.AbstractRange} range The range being saved.
* @constructor
* @extends {goog.dom.SavedCaretRange}
* @private
*/
goog.editor.range.NormalizedCaretRange_ = function(range) {
goog.dom.SavedCaretRange.call(this, range);
};
goog.inherits(goog.editor.range.NormalizedCaretRange_,
goog.dom.SavedCaretRange);
/**
* Normalizes text nodes whenever carets are removed from the document.
* @param {goog.dom.AbstractRange=} opt_range A range whose offsets have already
* been adjusted for caret removal; it will be adjusted and returned if it
* is also affected by post-removal operations, such as text node
* normalization.
* @return {goog.dom.AbstractRange|undefined} The adjusted range, if opt_range
* was provided.
* @override
*/
goog.editor.range.NormalizedCaretRange_.prototype.removeCarets =
function(opt_range) {
var startCaret = this.getCaret(true);
var endCaret = this.getCaret(false);
var node = startCaret && endCaret ?
goog.dom.findCommonAncestor(startCaret, endCaret) :
startCaret || endCaret;
goog.editor.range.NormalizedCaretRange_.superClass_.removeCarets.call(this);
if (opt_range) {
return goog.editor.range.rangePreservingNormalize(node, opt_range);
} else if (node) {
goog.editor.range.selectionPreservingNormalize(node);
}
};
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<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.
-->
<!--
@author nicksantos@google.com (Nick Santos)
-->
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>
Closure Unit Tests - goog.editor.range
</title>
<script src="../base.js">
</script>
<script>
goog.require('goog.editor.rangeTest');
</script>
</head>
<body>
<div id='root'>
<div id='parentNode'>
abc
<div id='def'>def</div>
ghi
<div id='jkl'>jkl</div>
mno
<div id='pqr'>pqr</div>
stu
</div>
<div id='caretRangeTest-1'>
abc
<div id='def-1'>def</div>
ghi
<div id='jkl-1'>jkl</div>
mno
<div id='pqr-1'>pqr</div>
stu
</div>
<div id='normalizeTest-with-br'>abc<br/>def</div>
<div id='normalizeTest-with-div'><div>abc</div></div>
<div id='normalizeTest-with-empty-text-nodes'></div>
<div id='normalizeTest-2'>
abc
<div id='def-2'>def</div>
ghi
<div id='jkl-2'>jkl</div>
mno
<div id='pqr-2'>pqr</div>
stu
</div>
<div id='normalizeTest-3'>
abc
<div id='def-3'>def</div>
ghi
<div id='jkl-3'>jkl</div>
mno
<div id='pqr-3'>pqr</div>
stu
</div>
<div id='normalizeTest-4'>
abc
<div id='def-4'>def</div>
ghi
<div id='jkl-4'>jkl</div>
mno
<div id='pqr-4'>pqr</div>
stu
</div>
<div id='editableTest' g_editable='true'>
abc
<div>def</div>
ghi
<div>jkl</div>
mno
<div>pqr</div>
stu
</div>
</div>
</body>
</html>
@@ -0,0 +1,942 @@
// 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.rangeTest');
goog.setTestOnly('goog.editor.rangeTest');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.range');
goog.require('goog.editor.range.Point');
goog.require('goog.string');
goog.require('goog.testing.dom');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent');
var savedHtml;
var $;
function setUpPage() {
$ = goog.dom.getElement;
}
function setUp() {
savedHtml = $('root').innerHTML;
}
function tearDown() {
$('root').innerHTML = savedHtml;
}
function testNoNarrow() {
var def = $('def');
var jkl = $('jkl');
var range = goog.dom.Range.createFromNodes(
def.firstChild, 1, jkl.firstChild, 2);
range = goog.editor.range.narrow(range, $('parentNode'));
goog.testing.dom.assertRangeEquals(
def.firstChild, 1, jkl.firstChild, 2, range);
}
function testNarrowAtEndEdge() {
var def = $('def');
var jkl = $('jkl');
var range = goog.dom.Range.createFromNodes(
def.firstChild, 1, jkl.firstChild, 2);
range = goog.editor.range.narrow(range, def);
goog.testing.dom.assertRangeEquals(
def.firstChild, 1, def.firstChild, 3, range);
}
function testNarrowAtStartEdge() {
var def = $('def');
var jkl = $('jkl');
var range = goog.dom.Range.createFromNodes(
def.firstChild, 1, jkl.firstChild, 2);
range = goog.editor.range.narrow(range, jkl);
goog.testing.dom.assertRangeEquals(
jkl.firstChild, 0, jkl.firstChild, 2, range);
}
function testNarrowOutsideElement() {
var def = $('def');
var jkl = $('jkl');
var range = goog.dom.Range.createFromNodes(
def.firstChild, 1, jkl.firstChild, 2);
range = goog.editor.range.narrow(range, $('pqr'));
assertNull(range);
}
function testNoExpand() {
var div = $('parentNode');
div.innerHTML = '<div>longword</div>';
// Select "ongwo" and make sure we don't expand since this is not
// a full container.
var textNode = div.firstChild.firstChild;
var range = goog.dom.Range.createFromNodes(textNode, 1, textNode, 6);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(textNode, 1, textNode, 6, range);
}
function testSimpleExpand() {
var div = $('parentNode');
div.innerHTML = '<div>longword</div>foo';
// Select "longword" and make sure we do expand to include the div since
// the full container text is selected.
var textNode = div.firstChild.firstChild;
var range = goog.dom.Range.createFromNodes(textNode, 0, textNode, 8);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(div, 0, div, 1, range);
// Select "foo" and make sure we expand out to the parent div.
var fooNode = div.lastChild;
range = goog.dom.Range.createFromNodes(fooNode, 0, fooNode, 3);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(div, 1, div, 2, range);
}
function testDoubleExpand() {
var div = $('parentNode');
div.innerHTML = '<div><span>longword</span></div>foo';
// Select "longword" and make sure we do expand to include the span
// and the div since both of their full contents are selected.
var textNode = div.firstChild.firstChild.firstChild;
var range = goog.dom.Range.createFromNodes(textNode, 0, textNode, 8);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(div, 0, div, 1, range);
// Same visible position, different dom position.
// Start in text node, end in span.
range = goog.dom.Range.createFromNodes(textNode, 0, textNode.parentNode, 1);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(div, 0, div, 1, range);
}
function testMultipleChildrenExpand() {
var div = $('parentNode');
div.innerHTML = '<ol><li>one</li><li>two</li><li>three</li></ol>';
// Select "two" and make sure we expand to the li, but not the ol.
var li = div.firstChild.childNodes[1];
var textNode = li.firstChild;
var range = goog.dom.Range.createFromNodes(textNode, 0, textNode, 3);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(
li.parentNode, 1, li.parentNode, 2, range);
// Make the same visible selection, only slightly different dom position.
// Select starting from the text node, but ending in the li.
range = goog.dom.Range.createFromNodes(textNode, 0, li, 1);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(
li.parentNode, 1, li.parentNode, 2, range);
}
function testSimpleDifferentContainersExpand() {
var div = $('parentNode');
div.innerHTML = '<ol><li>1</li><li><b>bold</b><i>italic</i></li></ol>';
// Select all of "bold" and "italic" at the text node level, and
// make sure we expand to the li.
var li = div.firstChild.childNodes[1];
var boldNode = li.childNodes[0];
var italicNode = li.childNodes[1];
var range = goog.dom.Range.createFromNodes(boldNode.firstChild, 0,
italicNode.firstChild, 6);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(
li.parentNode, 1, li.parentNode, 2, range);
// Make the same visible selection, only slightly different dom position.
// Select "bold" at the b node level and "italic" at the text node level.
range = goog.dom.Range.createFromNodes(boldNode, 0,
italicNode.firstChild, 6);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(
li.parentNode, 1, li.parentNode, 2, range);
}
function testSimpleDifferentContainersSmallExpand() {
var div = $('parentNode');
div.innerHTML = '<ol><li>1</li><li><b>bold</b><i>italic</i>' +
'<u>under</u></li></ol>';
// Select all of "bold" and "italic", but we can't expand to the
// entire li since we didn't select "under".
var li = div.firstChild.childNodes[1];
var boldNode = li.childNodes[0];
var italicNode = li.childNodes[1];
var range = goog.dom.Range.createFromNodes(boldNode.firstChild, 0,
italicNode.firstChild, 6);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(li, 0, li, 2, range);
// Same visible position, different dom position.
// Select "bold" starting in text node, "italic" at i node.
range = goog.dom.Range.createFromNodes(boldNode.firstChild, 0,
italicNode, 1);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(li, 0, li, 2, range);
}
function testEmbeddedDifferentContainersExpand() {
var div = $('parentNode');
div.innerHTML = '<div><b><i>italic</i>after</b><u>under</u></div>foo';
// Select "italic" "after" "under", should expand all the way to parent.
var boldNode = div.firstChild.childNodes[0];
var italicNode = boldNode.childNodes[0];
var underNode = div.firstChild.childNodes[1];
var range = goog.dom.Range.createFromNodes(italicNode.firstChild, 0,
underNode.firstChild, 5);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(div, 0, div, 1, range);
}
function testReverseSimpleExpand() {
var div = $('parentNode');
div.innerHTML = '<div>longword</div>foo';
// Select "longword" and make sure we do expand to include the div since
// the full container text is selected.
var textNode = div.firstChild.firstChild;
var range = goog.dom.Range.createFromNodes(textNode, 8, textNode, 0);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(div, 0, div, 1, range);
}
function testExpandWithStopNode() {
var div = $('parentNode');
div.innerHTML = '<div><span>word</span></div>foo';
// Select "word".
var span = div.firstChild.firstChild;
var textNode = span.firstChild;
var range = goog.dom.Range.createFromNodes(textNode, 0, textNode, 4);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(div, 0, div, 1, range);
// Same selection, but force stop at the span.
range = goog.dom.Range.createFromNodes(textNode, 0, textNode, 4);
range = goog.editor.range.expand(range, span);
goog.testing.dom.assertRangeEquals(span, 0, span, 1, range);
}
// Ojan didn't believe this code worked, this was the case he
// thought was broken. Keeping just as a regression test.
function testOjanCase() {
var div = $('parentNode');
div.innerHTML = '<em><i><b>foo</b>bar</i></em>';
// Select "foo", at text node level.
var iNode = div.firstChild.firstChild;
var textNode = iNode.firstChild.firstChild;
var range = goog.dom.Range.createFromNodes(textNode, 0, textNode, 3);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(iNode, 0, iNode, 1, range);
// Same selection, at b node level.
range = goog.dom.Range.createFromNodes(iNode.firstChild, 0,
iNode.firstChild, 1);
range = goog.editor.range.expand(range);
goog.testing.dom.assertRangeEquals(iNode, 0, iNode, 1, range);
}
function testPlaceCursorNextToLeft() {
var div = $('parentNode');
div.innerHTML = 'foo<div id="bar">bar</div>baz';
var node = $('bar');
var range = goog.editor.range.placeCursorNextTo(node, true);
var expose = goog.testing.dom.exposeNode;
assertEquals('Selection should be to the left of the node ' +
expose(node) + ',' + expose(range.getStartNode().nextSibling),
node, range.getStartNode().nextSibling);
assertEquals('Selection should be collapsed',
true, range.isCollapsed());
}
function testPlaceCursorNextToRight() {
var div = $('parentNode');
div.innerHTML = 'foo<div id="bar">bar</div>baz';
var node = $('bar');
var range = goog.editor.range.placeCursorNextTo(node, false);
assertEquals('Selection should be to the right of the node',
node, range.getStartNode().previousSibling);
assertEquals('Selection should be collapsed',
true, range.isCollapsed());
}
function testPlaceCursorNextTo_rightOfLineBreak() {
var div = $('parentNode');
div.innerHTML = '<div contentEditable="true">hhhh<br />h</div>';
var children = div.firstChild.childNodes;
assertEquals(3, children.length);
var node = children[1];
var range = goog.editor.range.placeCursorNextTo(node, false);
assertEquals(node.nextSibling, range.getStartNode());
}
function testPlaceCursorNextTo_leftOfHr() {
var div = $('parentNode');
div.innerHTML = '<hr />aaa';
var children = div.childNodes;
assertEquals(2, children.length);
var node = children[0];
var range = goog.editor.range.placeCursorNextTo(node, true);
assertEquals(div, range.getStartNode());
assertEquals(0, range.getStartOffset());
}
function testPlaceCursorNextTo_rightOfHr() {
var div = $('parentNode');
div.innerHTML = 'aaa<hr>';
var children = div.childNodes;
assertEquals(2, children.length);
var node = children[1];
var range = goog.editor.range.placeCursorNextTo(node, false);
assertEquals(div, range.getStartNode());
assertEquals(2, range.getStartOffset());
}
function testPlaceCursorNextTo_rightOfImg() {
var div = $('parentNode');
div.innerHTML =
'aaa<img src="https://www.google.com/images/srpr/logo3w.png">bbb';
var children = div.childNodes;
assertEquals(3, children.length);
var imgNode = children[1];
var range = goog.editor.range.placeCursorNextTo(imgNode, false);
assertEquals('range node should be the right sibling of img tag',
children[2], range.getStartNode());
assertEquals(0, range.getStartOffset());
}
function testPlaceCursorNextTo_rightOfImgAtEnd() {
var div = $('parentNode');
div.innerHTML =
'aaa<img src="https://www.google.com/images/srpr/logo3w.png">';
var children = div.childNodes;
assertEquals(2, children.length);
var imgNode = children[1];
var range = goog.editor.range.placeCursorNextTo(imgNode, false);
assertEquals('range node should be the parent of img',
div, range.getStartNode());
assertEquals('offset should be right after the img tag',
2, range.getStartOffset());
}
function testPlaceCursorNextTo_leftOfImg() {
var div = $('parentNode');
div.innerHTML =
'<img src="https://www.google.com/images/srpr/logo3w.png">xxx';
var children = div.childNodes;
assertEquals(2, children.length);
var imgNode = children[0];
var range = goog.editor.range.placeCursorNextTo(imgNode, true);
assertEquals('range node should be the parent of img',
div, range.getStartNode());
assertEquals('offset should point to the img tag',
0, range.getStartOffset());
}
function testPlaceCursorNextTo_rightOfFirstOfTwoImgTags() {
var div = $('parentNode');
div.innerHTML =
'aaa<img src="https://www.google.com/images/srpr/logo3w.png">' +
'<img src="https://www.google.com/images/srpr/logo3w.png">';
var children = div.childNodes;
assertEquals(3, children.length);
var imgNode = children[1]; // First of two IMG nodes
var range = goog.editor.range.placeCursorNextTo(imgNode, false);
assertEquals('range node should be the parent of img instead of ' +
'node with innerHTML=' + range.getStartNode().innerHTML,
div, range.getStartNode());
assertEquals('offset should be right after the img tag',
2, range.getStartOffset());
}
function testGetDeepEndPoint() {
var div = $('parentNode');
var def = $('def');
var jkl = $('jkl');
assertPointEquals(div.firstChild, 0,
goog.editor.range.getDeepEndPoint(
goog.dom.Range.createFromNodeContents(div), true));
assertPointEquals(div.lastChild, div.lastChild.length,
goog.editor.range.getDeepEndPoint(
goog.dom.Range.createFromNodeContents(div), false));
assertPointEquals(def.firstChild, 0,
goog.editor.range.getDeepEndPoint(
goog.dom.Range.createCaret(div, 1), true));
assertPointEquals(def.nextSibling, 0,
goog.editor.range.getDeepEndPoint(
goog.dom.Range.createCaret(div, 2), true));
}
function testNormalizeOnNormalizedDom() {
var defText = $('def').firstChild;
var jklText = $('jkl').firstChild;
var range = goog.dom.Range.createFromNodes(defText, 1, jklText, 2);
var newRange = normalizeBody(range);
goog.testing.dom.assertRangeEquals(defText, 1, jklText, 2, newRange);
}
function testDeepPointFindingOnNormalizedDom() {
var def = $('def');
var jkl = $('jkl');
var range = goog.dom.Range.createFromNodes(def, 0, jkl, 1);
var newRange = normalizeBody(range);
// Make sure that newRange is measured relative to the text nodes,
// not the DIV elements.
goog.testing.dom.assertRangeEquals(
def.firstChild, 0, jkl.firstChild, 3, newRange);
}
function testNormalizeOnVeryFragmentedDom() {
var defText = $('def').firstChild;
var jklText = $('jkl').firstChild;
var range = goog.dom.Range.createFromNodes(defText, 1, jklText, 2);
// Fragment the DOM a bunch.
fragmentText(defText);
fragmentText(jklText);
var newRange = normalizeBody(range);
// our old text nodes may not be valid anymore. find new ones.
defText = $('def').firstChild;
jklText = $('jkl').firstChild;
goog.testing.dom.assertRangeEquals(defText, 1, jklText, 2, newRange);
}
function testNormalizeOnDivWithEmptyTextNodes() {
var emptyDiv = $('normalizeTest-with-empty-text-nodes');
// Append empty text nodes to the emptyDiv.
var tnode1 = goog.dom.createTextNode('');
var tnode2 = goog.dom.createTextNode('');
var tnode3 = goog.dom.createTextNode('');
goog.dom.appendChild(emptyDiv, tnode1);
goog.dom.appendChild(emptyDiv, tnode2);
goog.dom.appendChild(emptyDiv, tnode3);
var range = goog.dom.Range.createFromNodes(emptyDiv, 1, emptyDiv, 2);
// Cannot use document.body.normalize() as it fails to normalize the div
// (in IE) if it has nothing but empty text nodes.
var newRange = goog.editor.range.rangePreservingNormalize(emptyDiv, range);
if (goog.userAgent.GECKO &&
goog.string.compareVersions(goog.userAgent.VERSION, '1.9') == -1) {
// In FF2, node.normalize() leaves an empty textNode in the div, unlike
// other browsers where the div is left with no children.
goog.testing.dom.assertRangeEquals(
emptyDiv.firstChild, 0, emptyDiv.firstChild, 0, newRange);
} else {
goog.testing.dom.assertRangeEquals(emptyDiv, 0, emptyDiv, 0, newRange);
}
}
function testRangeCreatedInVeryFragmentedDom() {
var def = $('def');
var defText = def.firstChild;
var jkl = $('jkl');
var jklText = jkl.firstChild;
// Fragment the DOM a bunch.
fragmentText(defText);
fragmentText(jklText);
// Notice that there are two empty text nodes at the beginning of each
// fragmented node.
var range = goog.dom.Range.createFromNodes(def, 3, jkl, 4);
var newRange = normalizeBody(range);
// our old text nodes may not be valid anymore. find new ones.
defText = $('def').firstChild;
jklText = $('jkl').firstChild;
goog.testing.dom.assertRangeEquals(defText, 1, jklText, 2, newRange);
}
function testNormalizeInFragmentedDomWithPreviousSiblings() {
var ghiText = $('def').nextSibling;
var mnoText = $('jkl').nextSibling;
var range = goog.dom.Range.createFromNodes(ghiText, 1, mnoText, 2);
// Fragment the DOM a bunch.
fragmentText($('def').previousSibling); // fragment abc
fragmentText(ghiText);
fragmentText(mnoText);
var newRange = normalizeBody(range);
// our old text nodes may not be valid anymore. find new ones.
ghiText = $('def').nextSibling;
mnoText = $('jkl').nextSibling;
goog.testing.dom.assertRangeEquals(ghiText, 1, mnoText, 2, newRange);
}
function testRangeCreatedInFragmentedDomWithPreviousSiblings() {
var def = $('def');
var ghiText = $('def').nextSibling;
var jkl = $('jkl');
var mnoText = $('jkl').nextSibling;
// Fragment the DOM a bunch.
fragmentText($('def').previousSibling); // fragment abc
fragmentText(ghiText);
fragmentText(mnoText);
// Notice that there are two empty text nodes at the beginning of each
// fragmented node.
var root = $('parentNode');
var range = goog.dom.Range.createFromNodes(root, 9, root, 16);
var newRange = normalizeBody(range);
// our old text nodes may not be valid anymore. find new ones.
ghiText = $('def').nextSibling;
mnoText = $('jkl').nextSibling;
goog.testing.dom.assertRangeEquals(ghiText, 1, mnoText, 2, newRange);
}
/**
* Branched from the tests for goog.dom.SavedCaretRange.
*/
function testSavedCaretRange() {
var def = $('def-1');
var jkl = $('jkl-1');
var range = goog.dom.Range.createFromNodes(
def.firstChild, 1, jkl.firstChild, 2);
range.select();
var saved = goog.editor.range.saveUsingNormalizedCarets(range);
assertHTMLEquals(
'd<span id="' + saved.startCaretId_ + '"></span>ef', def.innerHTML);
assertHTMLEquals(
'jk<span id="' + saved.endCaretId_ + '"></span>l', jkl.innerHTML);
clearSelectionAndRestoreSaved(saved);
var selection = goog.dom.Range.createFromWindow(window);
def = $('def-1');
jkl = $('jkl-1');
assertHTMLEquals('def', def.innerHTML);
assertHTMLEquals('jkl', jkl.innerHTML);
// Check that everything was normalized ok.
assertEquals(1, def.childNodes.length);
assertEquals(1, jkl.childNodes.length);
goog.testing.dom.assertRangeEquals(
def.firstChild, 1, jkl.firstChild, 2, selection);
}
function testRangePreservingNormalize() {
var parent = $('normalizeTest-4');
var def = $('def-4');
var jkl = $('jkl-4');
fragmentText(def.firstChild);
fragmentText(jkl.firstChild);
var range = goog.dom.Range.createFromNodes(def, 3, jkl, 4);
var oldRangeDescription = goog.testing.dom.exposeRange(range);
range = goog.editor.range.rangePreservingNormalize(parent, range);
// Check that everything was normalized ok.
assertEquals('def should have 1 child; range is ' +
goog.testing.dom.exposeRange(range) +
', range was ' + oldRangeDescription,
1, def.childNodes.length);
assertEquals('jkl should have 1 child; range is ' +
goog.testing.dom.exposeRange(range) +
', range was ' + oldRangeDescription,
1, jkl.childNodes.length);
goog.testing.dom.assertRangeEquals(def.firstChild, 1, jkl.firstChild, 2,
range);
}
function testRangePreservingNormalizeWhereEndNodePreviousSiblingIsSplit() {
var parent = $('normalizeTest-with-br');
var br = parent.childNodes[1];
fragmentText(parent.firstChild);
var range = goog.dom.Range.createFromNodes(parent, 3, br, 0);
range = goog.editor.range.rangePreservingNormalize(parent, range);
// Code used to throw an error here.
assertEquals('parent should have 3 children', 3, parent.childNodes.length);
goog.testing.dom.assertRangeEquals(parent.firstChild, 1, parent, 1, range);
}
function testRangePreservingNormalizeWhereStartNodePreviousSiblingIsSplit() {
var parent = $('normalizeTest-with-br');
var br = parent.childNodes[1];
fragmentText(parent.firstChild);
fragmentText(parent.lastChild);
var range = goog.dom.Range.createFromNodes(br, 0, parent, 9);
range = goog.editor.range.rangePreservingNormalize(parent, range);
// Code used to throw an error here.
assertEquals('parent should have 3 children', 3, parent.childNodes.length);
goog.testing.dom.assertRangeEquals(parent, 1, parent.lastChild, 1, range);
}
function testSelectionPreservingNormalize1() {
var parent = $('normalizeTest-2');
var def = $('def-2');
var jkl = $('jkl-2');
fragmentText(def.firstChild);
fragmentText(jkl.firstChild);
goog.dom.Range.createFromNodes(def, 3, jkl, 4).select();
assertFalse(goog.dom.Range.createFromWindow(window).isReversed());
var oldRangeDescription = goog.testing.dom.exposeRange(
goog.dom.Range.createFromWindow(window));
goog.editor.range.selectionPreservingNormalize(parent);
// Check that everything was normalized ok.
var range = goog.dom.Range.createFromWindow(window);
assertFalse(range.isReversed());
assertEquals('def should have 1 child; range is ' +
goog.testing.dom.exposeRange(range) +
', range was ' + oldRangeDescription,
1, def.childNodes.length);
assertEquals('jkl should have 1 child; range is ' +
goog.testing.dom.exposeRange(range) +
', range was ' + oldRangeDescription,
1, jkl.childNodes.length);
goog.testing.dom.assertRangeEquals(def.firstChild, 1, jkl.firstChild, 2,
range);
}
/**
* Make sure that selectionPreservingNormalize doesn't explode with no
* selection in the document.
*/
function testSelectionPreservingNormalize2() {
var parent = $('normalizeTest-3');
var def = $('def-3');
var jkl = $('jkl-3');
def.firstChild.splitText(1);
jkl.firstChild.splitText(2);
goog.dom.Range.clearSelection(window);
goog.editor.range.selectionPreservingNormalize(parent);
// Check that everything was normalized ok.
assertEquals(1, def.childNodes.length);
assertEquals(1, jkl.childNodes.length);
assertFalse(goog.dom.Range.hasSelection(window));
}
function testSelectionPreservingNormalize3() {
if (goog.userAgent.IE) {
return;
}
var parent = $('normalizeTest-2');
var def = $('def-2');
var jkl = $('jkl-2');
fragmentText(def.firstChild);
fragmentText(jkl.firstChild);
goog.dom.Range.createFromNodes(jkl, 4, def, 3).select();
assertTrue(goog.dom.Range.createFromWindow(window).isReversed());
var oldRangeDescription = goog.testing.dom.exposeRange(
goog.dom.Range.createFromWindow(window));
goog.editor.range.selectionPreservingNormalize(parent);
// Check that everything was normalized ok.
var range = goog.dom.Range.createFromWindow(window);
assertTrue(range.isReversed());
assertEquals('def should have 1 child; range is ' +
goog.testing.dom.exposeRange(range) +
', range was ' + oldRangeDescription,
1, def.childNodes.length);
assertEquals('jkl should have 1 child; range is ' +
goog.testing.dom.exposeRange(range) +
', range was ' + oldRangeDescription,
1, jkl.childNodes.length);
goog.testing.dom.assertRangeEquals(def.firstChild, 1, jkl.firstChild, 2,
range);
}
function testSelectionPreservingNormalizeAfterPlaceCursorNextTo() {
var parent = $('normalizeTest-with-div');
goog.editor.range.placeCursorNextTo(parent.firstChild);
goog.editor.range.selectionPreservingNormalize(parent);
// Code used to throw an exception here.
}
/** Normalize the body and return the normalized range. */
function normalizeBody(range) {
var rangeFactory = goog.editor.range.normalize(range);
document.body.normalize();
return rangeFactory();
}
/** Break a text node up into lots of little fragments. */
function fragmentText(text) {
// NOTE(nicksantos): For some reason, splitText makes IE deeply
// unhappy to the point where normalize and other normal DOM operations
// start failing. It's a useful test for Firefox though, because different
// versions of FireFox handle empty text nodes differently.
// See goog.editor.BrowserFeature.
if (goog.userAgent.IE) {
manualSplitText(text, 2);
manualSplitText(text, 1);
manualSplitText(text, 0);
manualSplitText(text, 0);
} else {
text.splitText(2);
text.splitText(1);
text.splitText(0);
text.splitText(0);
}
}
/**
* Clear the selection by re-parsing the DOM. Then restore the saved
* selection.
* @param {goog.dom.SavedRange} saved The saved range.
*/
function clearSelectionAndRestoreSaved(saved) {
goog.dom.Range.clearSelection(window);
assertFalse(goog.dom.Range.hasSelection(window));
saved.restore();
assertTrue(goog.dom.Range.hasSelection(window));
}
function manualSplitText(node, pos) {
var newNodeString = node.nodeValue.substr(pos);
node.nodeValue = node.nodeValue.substr(0, pos);
goog.dom.insertSiblingAfter(document.createTextNode(newNodeString), node);
}
function testSelectNodeStartSimple() {
var div = $('parentNode');
div.innerHTML = '<p>Cursor should go in here</p>';
goog.editor.range.selectNodeStart(div);
var range = goog.dom.Range.createFromWindow(window);
// Gotta love browsers and their inconsistencies with selection
// representations. What we are trying to achieve is that when we type
// the text will go into the P node. In Gecko, the selection is at the start
// of the text node, as you'd expect, but in pre-530 Webkit, it has been
// normalized to the visible position of P:0.
if (goog.userAgent.GECKO || goog.userAgent.IE ||
(goog.userAgent.WEBKIT && goog.userAgent.isVersionOrHigher('530'))) {
goog.testing.dom.assertRangeEquals(div.firstChild.firstChild, 0,
div.firstChild.firstChild, 0, range);
} else {
goog.testing.dom.assertRangeEquals(div.firstChild, 0,
div.firstChild, 0, range);
}
}
function testSelectNodeStartBr() {
var div = $('parentNode');
div.innerHTML = '<p><br>Cursor should go in here</p>';
goog.editor.range.selectNodeStart(div);
var range = goog.dom.Range.createFromWindow(window);
// We have to skip the BR since Gecko can't render a cursor at a BR.
goog.testing.dom.assertRangeEquals(div.firstChild, 0,
div.firstChild, 0, range);
}
function testIsEditable() {
var containerElement = document.getElementById('editableTest');
// Find editable container element's index.
var containerIndex = 0;
var currentSibling = containerElement;
while (currentSibling = currentSibling.previousSibling) {
containerIndex++;
}
var editableContainer = goog.dom.Range.createFromNodes(
containerElement.parentNode, containerIndex,
containerElement.parentNode, containerIndex + 1);
assertFalse('Range containing container element not considered editable',
goog.editor.range.isEditable(editableContainer));
var allEditableChildren = goog.dom.Range.createFromNodes(
containerElement, 0, containerElement,
containerElement.childNodes.length);
assertTrue('Range of all of container element children considered editable',
goog.editor.range.isEditable(allEditableChildren));
var someEditableChildren = goog.dom.Range.createFromNodes(
containerElement, 2, containerElement, 6);
assertTrue('Range of some container element children considered editable',
goog.editor.range.isEditable(someEditableChildren));
var mixedEditableNonEditable = goog.dom.Range.createFromNodes(
containerElement.previousSibling, 0, containerElement, 2);
assertFalse('Range overlapping some content not considered editable',
goog.editor.range.isEditable(mixedEditableNonEditable));
}
function testIntersectsTag() {
var root = $('root');
root.innerHTML =
'<b>Bold</b><p><span><code>x</code></span></p><p>y</p><i>Italic</i>';
// Select the whole thing.
var range = goog.dom.Range.createFromNodeContents(root);
assertTrue(goog.editor.range.intersectsTag(range, goog.dom.TagName.DIV));
assertTrue(goog.editor.range.intersectsTag(range, goog.dom.TagName.B));
assertTrue(goog.editor.range.intersectsTag(range, goog.dom.TagName.I));
assertTrue(goog.editor.range.intersectsTag(range, goog.dom.TagName.CODE));
assertFalse(goog.editor.range.intersectsTag(range, goog.dom.TagName.U));
// Just select italic.
range = goog.dom.Range.createFromNodes(root, 3, root, 4);
assertTrue(goog.editor.range.intersectsTag(range, goog.dom.TagName.DIV));
assertFalse(goog.editor.range.intersectsTag(range, goog.dom.TagName.B));
assertTrue(goog.editor.range.intersectsTag(range, goog.dom.TagName.I));
assertFalse(goog.editor.range.intersectsTag(range, goog.dom.TagName.CODE));
assertFalse(goog.editor.range.intersectsTag(range, goog.dom.TagName.U));
// Select "ld x y".
range = goog.dom.Range.createFromNodes(root.firstChild.firstChild, 2,
root.childNodes[2], 1);
assertTrue(goog.editor.range.intersectsTag(range, goog.dom.TagName.DIV));
assertTrue(goog.editor.range.intersectsTag(range, goog.dom.TagName.B));
assertFalse(goog.editor.range.intersectsTag(range, goog.dom.TagName.I));
assertTrue(goog.editor.range.intersectsTag(range, goog.dom.TagName.CODE));
assertFalse(goog.editor.range.intersectsTag(range, goog.dom.TagName.U));
// Select ol.
range = goog.dom.Range.createFromNodes(root.firstChild.firstChild, 1,
root.firstChild.firstChild, 3);
assertTrue(goog.editor.range.intersectsTag(range, goog.dom.TagName.DIV));
assertTrue(goog.editor.range.intersectsTag(range, goog.dom.TagName.B));
assertFalse(goog.editor.range.intersectsTag(range, goog.dom.TagName.I));
assertFalse(goog.editor.range.intersectsTag(range, goog.dom.TagName.CODE));
assertFalse(goog.editor.range.intersectsTag(range, goog.dom.TagName.U));
}
function testNormalizeNode() {
var div = goog.dom.createDom('DIV', null, 'a', 'b', 'c');
assertEquals(3, div.childNodes.length);
goog.editor.range.normalizeNode(div);
assertEquals(1, div.childNodes.length);
assertEquals('abc', div.firstChild.nodeValue);
div = goog.dom.createDom('DIV', null,
goog.dom.createDom('SPAN', null, '1', '2'),
goog.dom.createTextNode(''),
goog.dom.createDom('BR'),
'b',
'c');
assertEquals(5, div.childNodes.length);
assertEquals(2, div.firstChild.childNodes.length);
goog.editor.range.normalizeNode(div);
if (goog.userAgent.GECKO && !goog.userAgent.isVersionOrHigher(1.9) ||
goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher(526)) {
// Old Gecko and Webkit versions don't delete the empty node.
assertEquals(4, div.childNodes.length);
} else {
assertEquals(3, div.childNodes.length);
}
assertEquals(1, div.firstChild.childNodes.length);
assertEquals('12', div.firstChild.firstChild.nodeValue);
assertEquals('bc', div.lastChild.nodeValue);
assertEquals('BR', div.lastChild.previousSibling.tagName);
}
function testDeepestPoint() {
var parent = $('parentNode');
var def = $('def');
assertEquals(def, parent.childNodes[1]);
var deepestPoint = goog.editor.range.Point.createDeepestPoint;
var defStartLeft = deepestPoint(parent, 1, true);
assertPointEquals(def.previousSibling, def.previousSibling.nodeValue.length,
defStartLeft);
var defStartRight = deepestPoint(parent, 1, false);
assertPointEquals(def.firstChild, 0, defStartRight);
var defEndLeft = deepestPoint(parent, 2, true);
assertPointEquals(def.firstChild, def.firstChild.nodeValue.length,
defEndLeft);
var defEndRight = deepestPoint(parent, 2, false);
assertPointEquals(def.nextSibling, 0, defEndRight);
}
function assertPointEquals(node, offset, actualPoint) {
assertEquals('Point has wrong node', node, actualPoint.node);
assertEquals('Point has wrong offset', offset, actualPoint.offset);
}
@@ -0,0 +1,739 @@
// Copyright 2006 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 Class to encapsulate an editable field that blends in with
* the style of the page. The field can be fixed height, grow with its
* contents, or have a min height after which it grows to its contents.
* This is a goog.editor.Field, but with blending and sizing capabilities,
* and avoids using an iframe whenever possible.
*
* @author nicksantos@google.com (Nick Santos)
* @see ../demos/editor/seamlessfield.html
*/
goog.provide('goog.editor.SeamlessField');
goog.require('goog.cssom.iframe.style');
goog.require('goog.dom');
goog.require('goog.dom.Range');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Field');
goog.require('goog.editor.icontent');
goog.require('goog.editor.icontent.FieldFormatInfo');
goog.require('goog.editor.icontent.FieldStyleInfo');
goog.require('goog.editor.node');
goog.require('goog.events');
goog.require('goog.events.EventType');
goog.require('goog.log');
goog.require('goog.style');
/**
* This class encapsulates an editable field that blends in with the
* surrounding page.
* To see events fired by this object, please see the base class.
*
* @param {string} id An identifer for the field. This is used to find the
* field and the element associated with this field.
* @param {Document=} opt_doc The document that the element with the given
* id can be found it.
* @constructor
* @extends {goog.editor.Field}
*/
goog.editor.SeamlessField = function(id, opt_doc) {
goog.editor.Field.call(this, id, opt_doc);
};
goog.inherits(goog.editor.SeamlessField, goog.editor.Field);
/**
* @override
*/
goog.editor.SeamlessField.prototype.logger =
goog.log.getLogger('goog.editor.SeamlessField');
// Functions dealing with field sizing.
/**
* The key used for listening for the "dragover" event.
* @type {goog.events.Key}
* @private
*/
goog.editor.SeamlessField.prototype.listenForDragOverEventKey_;
/**
* The key used for listening for the iframe "load" event.
* @type {goog.events.Key}
* @private
*/
goog.editor.SeamlessField.prototype.listenForIframeLoadEventKey_;
/**
* Sets the min height of this editable field's iframe. Only used in growing
* mode when an iframe is used. This will cause an immediate field sizing to
* update the field if necessary based on the new min height.
* @param {number} height The min height specified as a number of pixels,
* e.g., 75.
*/
goog.editor.SeamlessField.prototype.setMinHeight = function(height) {
if (height == this.minHeight_) {
// Do nothing if the min height isn't changing.
return;
}
this.minHeight_ = height;
if (this.usesIframe()) {
this.doFieldSizingGecko();
}
};
/**
* Whether the field should be rendered with a fixed height, or should expand
* to fit its contents.
* @type {boolean}
* @private
*/
goog.editor.SeamlessField.prototype.isFixedHeight_ = false;
/**
* Whether the fixed-height handling has been overridden manually.
* @type {boolean}
* @private
*/
goog.editor.SeamlessField.prototype.isFixedHeightOverridden_ = false;
/**
* @return {boolean} Whether the field should be rendered with a fixed
* height, or should expand to fit its contents.
* @override
*/
goog.editor.SeamlessField.prototype.isFixedHeight = function() {
return this.isFixedHeight_;
};
/**
* @param {boolean} newVal Explicitly set whether the field should be
* of a fixed-height. This overrides auto-detection.
*/
goog.editor.SeamlessField.prototype.overrideFixedHeight = function(newVal) {
this.isFixedHeight_ = newVal;
this.isFixedHeightOverridden_ = true;
};
/**
* Auto-detect whether the current field should have a fixed height.
* @private
*/
goog.editor.SeamlessField.prototype.autoDetectFixedHeight_ = function() {
if (!this.isFixedHeightOverridden_) {
var originalElement = this.getOriginalElement();
if (originalElement) {
this.isFixedHeight_ =
goog.style.getComputedOverflowY(originalElement) == 'auto';
}
}
};
/**
* Resize the iframe in response to the wrapper div changing size.
* @private
*/
goog.editor.SeamlessField.prototype.handleOuterDocChange_ = function() {
if (this.isEventStopped(goog.editor.Field.EventType.CHANGE)) {
return;
}
this.sizeIframeToWrapperGecko_();
};
/**
* Sizes the iframe to its body's height.
* @private
*/
goog.editor.SeamlessField.prototype.sizeIframeToBodyHeightGecko_ = function() {
if (this.acquireSizeIframeLockGecko_()) {
var resized = false;
var ifr = this.getEditableIframe();
if (ifr) {
var fieldHeight = this.getIframeBodyHeightGecko_();
if (this.minHeight_) {
fieldHeight = Math.max(fieldHeight, this.minHeight_);
}
if (parseInt(goog.style.getStyle(ifr, 'height'), 10) != fieldHeight) {
ifr.style.height = fieldHeight + 'px';
resized = true;
}
}
this.releaseSizeIframeLockGecko_();
if (resized) {
this.dispatchEvent(goog.editor.Field.EventType.IFRAME_RESIZED);
}
}
};
/**
* @return {number} The height of the editable iframe's body.
* @private
*/
goog.editor.SeamlessField.prototype.getIframeBodyHeightGecko_ = function() {
var ifr = this.getEditableIframe();
var body = ifr.contentDocument.body;
var htmlElement = body.parentNode;
// If the iframe's height is 0, then the offsetHeight/scrollHeight of the
// HTML element in the iframe can be totally wack (i.e. too large
// by 50-500px). Also, in standard's mode the clientHeight is 0.
if (parseInt(goog.style.getStyle(ifr, 'height'), 10) === 0) {
goog.style.setStyle(ifr, 'height', 1 + 'px');
}
var fieldHeight;
if (goog.editor.node.isStandardsMode(body)) {
// If in standards-mode,
// grab the HTML element as it will contain all the field's
// contents. The body's height, for example, will not include that of
// floated images at the bottom in standards mode.
// Note that this value include all scrollbars *except* for scrollbars
// on the HTML element itself.
fieldHeight = htmlElement.offsetHeight;
} else {
// In quirks-mode, the body-element always seems
// to size to the containing window. The html-element however,
// sizes to the content, and can thus end up with a value smaller
// than its child body-element if the content is shrinking.
// We want to make the iframe shrink too when the content shrinks,
// so rather than size the iframe to the body-element, size it to
// the html-element.
fieldHeight = htmlElement.scrollHeight;
// If there is a horizontal scroll, add in the thickness of the
// scrollbar.
if (htmlElement.clientHeight != htmlElement.offsetHeight) {
fieldHeight += goog.editor.SeamlessField.getScrollbarWidth_();
}
}
return fieldHeight;
};
/**
* Grabs the width of a scrollbar from the browser and caches the result.
* @return {number} The scrollbar width in pixels.
* @private
*/
goog.editor.SeamlessField.getScrollbarWidth_ = function() {
return goog.editor.SeamlessField.scrollbarWidth_ ||
(goog.editor.SeamlessField.scrollbarWidth_ =
goog.style.getScrollbarWidth());
};
/**
* Sizes the iframe to its container div's width. The width of the div
* is controlled by its containing context, not by its contents.
* if it extends outside of it's contents, then it gets a horizontal scroll.
* @private
*/
goog.editor.SeamlessField.prototype.sizeIframeToWrapperGecko_ = function() {
if (this.acquireSizeIframeLockGecko_()) {
var ifr = this.getEditableIframe();
var field = this.getElement();
var resized = false;
if (ifr && field) {
var fieldPaddingBox;
var widthDiv = ifr.parentNode;
var width = widthDiv.offsetWidth;
if (parseInt(goog.style.getStyle(ifr, 'width'), 10) != width) {
fieldPaddingBox = goog.style.getPaddingBox(field);
ifr.style.width = width + 'px';
field.style.width =
width - fieldPaddingBox.left - fieldPaddingBox.right + 'px';
resized = true;
}
var height = widthDiv.offsetHeight;
if (this.isFixedHeight() &&
parseInt(goog.style.getStyle(ifr, 'height'), 10) != height) {
if (!fieldPaddingBox) {
fieldPaddingBox = goog.style.getPaddingBox(field);
}
ifr.style.height = height + 'px';
field.style.height =
height - fieldPaddingBox.top - fieldPaddingBox.bottom + 'px';
resized = true;
}
}
this.releaseSizeIframeLockGecko_();
if (resized) {
this.dispatchEvent(goog.editor.Field.EventType.IFRAME_RESIZED);
}
}
};
/**
* Perform all the sizing immediately.
*/
goog.editor.SeamlessField.prototype.doFieldSizingGecko = function() {
// Because doFieldSizingGecko can be called after a setTimeout
// it is possible that the field has been destroyed before this call
// to do the sizing is executed. Check for field existence and do nothing
// if it has already been destroyed.
if (this.getElement()) {
// The order of operations is important here. Sizing the iframe to the
// wrapper could cause the width to change, which could change the line
// wrapping, which could change the body height. So we need to do that
// first, then size the iframe to fit the body height.
this.sizeIframeToWrapperGecko_();
if (!this.isFixedHeight()) {
this.sizeIframeToBodyHeightGecko_();
}
}
};
/**
* Acquires a lock on resizing the field iframe. This is used to ensure that
* modifications we make while in a mutation event handler don't cause
* infinite loops.
* @return {boolean} False if the lock is already acquired.
* @private
*/
goog.editor.SeamlessField.prototype.acquireSizeIframeLockGecko_ = function() {
if (this.sizeIframeLock_) {
return false;
}
return this.sizeIframeLock_ = true;
};
/**
* Releases a lock on resizing the field iframe. This is used to ensure that
* modifications we make while in a mutation event handler don't cause
* infinite loops.
* @private
*/
goog.editor.SeamlessField.prototype.releaseSizeIframeLockGecko_ = function() {
this.sizeIframeLock_ = false;
};
// Functions dealing with blending in with the surrounding page.
/**
* String containing the css rules that, if applied to a document's body,
* would style that body as if it were the original element we made editable.
* See goog.cssom.iframe.style.getElementContext for more details.
* @type {string}
* @private
*/
goog.editor.SeamlessField.prototype.iframeableCss_ = '';
/**
* Gets the css rules that should be used to style an iframe's body as if it
* were the original element that we made editable.
* @param {boolean=} opt_forceRegeneration Set to true to not read the cached
* copy and instead completely regenerate the css rules.
* @return {string} The string containing the css rules to use.
*/
goog.editor.SeamlessField.prototype.getIframeableCss = function(
opt_forceRegeneration) {
if (!this.iframeableCss_ || opt_forceRegeneration) {
var originalElement = this.getOriginalElement();
if (originalElement) {
this.iframeableCss_ =
goog.cssom.iframe.style.getElementContext(originalElement,
opt_forceRegeneration);
}
}
return this.iframeableCss_;
};
/**
* Sets the css rules that should be used inside the editable iframe.
* Note: to clear the css cache between makeNotEditable/makeEditable,
* call this with "" as iframeableCss.
* TODO(user): Unify all these css setting methods + Nick's open
* CL. This is getting ridiculous.
* @param {string} iframeableCss String containing the css rules to use.
*/
goog.editor.SeamlessField.prototype.setIframeableCss = function(iframeableCss) {
this.iframeableCss_ = iframeableCss;
};
/**
* Used to ensure that CSS stylings are only installed once for none
* iframe seamless mode.
* TODO(user): Make it a formal part of the API that you can only
* set one set of styles globally.
* In seamless, non-iframe mode, all the stylings would go in the
* same document and conflict.
* @type {boolean}
* @private
*/
goog.editor.SeamlessField.haveInstalledCss_ = false;
/**
* Applies CSS from the wrapper-div to the field iframe.
*/
goog.editor.SeamlessField.prototype.inheritBlendedCSS = function() {
// No-op if the field isn't using an iframe.
if (!this.usesIframe()) {
return;
}
var field = this.getElement();
var head = goog.dom.getDomHelper(field).getElementsByTagNameAndClass(
'head')[0];
if (head) {
// We created this <head>, and we know the only thing we put in there
// is a <style> block. So it's safe to blow away all the children
// as part of rewriting the styles.
goog.dom.removeChildren(head);
}
// Force a cache-clearing in CssUtil - this function was called because
// we're applying the 'blend' for the first time, or because we
// *need* to recompute the blend.
var newCSS = this.getIframeableCss(true);
goog.style.installStyles(newCSS, field);
};
// Overridden methods.
/** @override */
goog.editor.SeamlessField.prototype.usesIframe = function() {
// TODO(user): Switch Firefox to using contentEditable
// rather than designMode iframe once contentEditable support
// is less buggy.
return !goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE;
};
/** @override */
goog.editor.SeamlessField.prototype.setupMutationEventHandlersGecko =
function() {
goog.editor.SeamlessField.superClass_.setupMutationEventHandlersGecko.call(
this);
if (this.usesIframe()) {
var iframe = this.getEditableIframe();
var outerDoc = iframe.ownerDocument;
this.eventRegister.listen(outerDoc,
goog.editor.Field.MUTATION_EVENTS_GECKO,
this.handleOuterDocChange_, true);
// If the images load after we do the initial sizing, then this will
// force a field resize.
this.listenForIframeLoadEventKey_ = goog.events.listenOnce(
this.getEditableDomHelper().getWindow(),
goog.events.EventType.LOAD, this.sizeIframeToBodyHeightGecko_,
true, this);
this.eventRegister.listen(outerDoc,
'DOMAttrModified',
goog.bind(this.handleDomAttrChange, this, this.handleOuterDocChange_),
true);
}
};
/** @override */
goog.editor.SeamlessField.prototype.handleChange = function() {
if (this.isEventStopped(goog.editor.Field.EventType.CHANGE)) {
return;
}
goog.editor.SeamlessField.superClass_.handleChange.call(this);
if (this.usesIframe()) {
this.sizeIframeToBodyHeightGecko_();
}
};
/** @override */
goog.editor.SeamlessField.prototype.dispatchBlur = function() {
if (this.isEventStopped(goog.editor.Field.EventType.BLUR)) {
return;
}
goog.editor.SeamlessField.superClass_.dispatchBlur.call(this);
// Clear the selection and restore the current range back after collapsing
// it. The ideal solution would have been to just leave the range intact; but
// when there are multiple fields present on the page, its important that
// the selection isn't retained when we switch between the fields. We also
// have to make sure that the cursor position is retained when we tab in and
// out of a field and our approach addresses both these issues.
// Another point to note is that we do it on a setTimeout to allow for
// DOM modifications on blur. Otherwise, something like setLoremIpsum will
// leave a blinking cursor in the field even though it's blurred.
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE &&
!goog.editor.BrowserFeature.CLEARS_SELECTION_WHEN_FOCUS_LEAVES) {
var win = this.getEditableDomHelper().getWindow();
var dragging = false;
goog.events.unlistenByKey(this.listenForDragOverEventKey_);
this.listenForDragOverEventKey_ = goog.events.listenOnce(
win.document.body, 'dragover',
function() {
dragging = true;
});
goog.global.setTimeout(goog.bind(function() {
// Do not clear the selection if we're only dragging text.
// This addresses a bug on FF1.5/linux where dragging fires a blur,
// but clearing the selection confuses Firefox's drag-and-drop
// implementation. For more info, see http://b/1061064
if (!dragging) {
if (this.editableDomHelper) {
var rng = this.getRange();
// If there are multiple fields on a page, we need to make sure that
// the selection isn't retained when we switch between fields. We
// could have collapsed the range but there is a bug in GECKO where
// the selection stays highlighted even though its backing range is
// collapsed (http://b/1390115). To get around this, we clear the
// selection and restore the collapsed range back in. Restoring the
// range is important so that the cursor stays intact when we tab out
// and into a field (See http://b/1790301 for additional details on
// this).
var iframeWindow = this.editableDomHelper.getWindow();
goog.dom.Range.clearSelection(iframeWindow);
if (rng) {
rng.collapse(true);
rng.select();
}
}
}
}, this), 0);
}
};
/** @override */
goog.editor.SeamlessField.prototype.turnOnDesignModeGecko = function() {
goog.editor.SeamlessField.superClass_.turnOnDesignModeGecko.call(this);
var doc = this.getEditableDomHelper().getDocument();
doc.execCommand('enableInlineTableEditing', false, 'false');
doc.execCommand('enableObjectResizing', false, 'false');
};
/** @override */
goog.editor.SeamlessField.prototype.installStyles = function() {
if (!this.usesIframe()) {
if (!goog.editor.SeamlessField.haveInstalledCss_) {
if (this.cssStyles) {
goog.style.installStyles(this.cssStyles, this.getElement());
}
// TODO(user): this should be reset to false when the editor is quit.
// In non-iframe mode, CSS styles should only be instaled once.
goog.editor.SeamlessField.haveInstalledCss_ = true;
}
}
};
/** @override */
goog.editor.SeamlessField.prototype.makeEditableInternal = function(
opt_iframeSrc) {
if (this.usesIframe()) {
goog.editor.SeamlessField.superClass_.makeEditableInternal.call(this,
opt_iframeSrc);
} else {
var field = this.getOriginalElement();
if (field) {
this.setupFieldObject(field);
field.contentEditable = true;
this.injectContents(field.innerHTML, field);
this.handleFieldLoad();
}
}
};
/** @override */
goog.editor.SeamlessField.prototype.handleFieldLoad = function() {
if (this.usesIframe()) {
// If the CSS inheriting code screws up (e.g. makes fonts too large) and
// the field is sized off in goog.editor.Field.makeIframeField, then we need
// to size it correctly, but it needs to be visible for the browser
// to have fully rendered it. We need to put this on a timeout to give
// the browser time to render.
var self = this;
goog.global.setTimeout(function() {
self.doFieldSizingGecko();
}, 0);
}
goog.editor.SeamlessField.superClass_.handleFieldLoad.call(this);
};
/** @override */
goog.editor.SeamlessField.prototype.getIframeAttributes = function() {
return { 'frameBorder': 0, 'style': 'padding:0;' };
};
/** @override */
goog.editor.SeamlessField.prototype.attachIframe = function(iframe) {
this.autoDetectFixedHeight_();
var field = this.getOriginalElement();
var dh = goog.dom.getDomHelper(field);
// Grab the width/height values of the field before modifying any CSS
// as some of the modifications affect its size (e.g. innerHTML='')
// Here, we set the size of the field to fixed so there's not too much
// jiggling when we set the innerHTML of the field.
var oldWidth = field.style.width;
var oldHeight = field.style.height;
goog.style.setStyle(field, 'visibility', 'hidden');
// If there is a floated element at the bottom of the field,
// then it needs a clearing div at the end to cause the clientHeight
// to contain the entire field.
// Also, with css re-writing, the margins of the first/last
// paragraph don't seem to get included in the clientHeight. Specifically,
// the extra divs below force the field's clientHeight to include the
// margins on the first and last elements contained within it.
var startDiv = dh.createDom(goog.dom.TagName.DIV,
{'style': 'height:0;clear:both', 'innerHTML': '&nbsp;'});
var endDiv = startDiv.cloneNode(true);
field.insertBefore(startDiv, field.firstChild);
goog.dom.appendChild(field, endDiv);
var contentBox = goog.style.getContentBoxSize(field);
var width = contentBox.width;
var height = contentBox.height;
var html = '';
if (this.isFixedHeight()) {
html = '&nbsp;';
goog.style.setStyle(field, 'position', 'relative');
goog.style.setStyle(field, 'overflow', 'visible');
goog.style.setStyle(iframe, 'position', 'absolute');
goog.style.setStyle(iframe, 'top', '0');
goog.style.setStyle(iframe, 'left', '0');
}
goog.style.setSize(field, width, height);
// In strict mode, browsers put blank space at the bottom and right
// if a field when it has an iframe child, to fill up the remaining line
// height. So make the line height = 0.
if (goog.editor.node.isStandardsMode(field)) {
this.originalFieldLineHeight_ = field.style.lineHeight;
goog.style.setStyle(field, 'lineHeight', '0');
}
goog.editor.node.replaceInnerHtml(field, html);
// Set the initial size
goog.style.setSize(iframe, width, height);
goog.style.setSize(field, oldWidth, oldHeight);
goog.style.setStyle(field, 'visibility', '');
goog.dom.appendChild(field, iframe);
// Only write if its not IE HTTPS in which case we're waiting for load.
if (!this.shouldLoadAsynchronously()) {
var doc = iframe.contentWindow.document;
if (goog.editor.node.isStandardsMode(iframe.ownerDocument)) {
doc.open();
doc.write('<!DOCTYPE HTML><html></html>');
doc.close();
}
}
};
/** @override */
goog.editor.SeamlessField.prototype.getFieldFormatInfo = function(
extraStyles) {
var originalElement = this.getOriginalElement();
if (originalElement) {
return new goog.editor.icontent.FieldFormatInfo(
this.id,
goog.editor.node.isStandardsMode(originalElement),
true,
this.isFixedHeight(),
extraStyles);
}
throw Error('no field');
};
/** @override */
goog.editor.SeamlessField.prototype.writeIframeContent = function(
iframe, innerHtml, extraStyles) {
// For seamless iframes, hide the iframe while we're laying it out to
// prevent the flicker.
goog.style.setStyle(iframe, 'visibility', 'hidden');
var formatInfo = this.getFieldFormatInfo(extraStyles);
var styleInfo = new goog.editor.icontent.FieldStyleInfo(
this.getOriginalElement(),
this.cssStyles + this.getIframeableCss());
goog.editor.icontent.writeNormalInitialBlendedIframe(
formatInfo, innerHtml, styleInfo, iframe);
this.doFieldSizingGecko();
goog.style.setStyle(iframe, 'visibility', 'visible');
};
/** @override */
goog.editor.SeamlessField.prototype.restoreDom = function() {
// TODO(user): Consider only removing the iframe if we are
// restoring the original node.
if (this.usesIframe()) {
goog.dom.removeNode(this.getEditableIframe());
}
};
/** @override */
goog.editor.SeamlessField.prototype.clearListeners = function() {
goog.events.unlistenByKey(this.listenForDragOverEventKey_);
goog.events.unlistenByKey(this.listenForIframeLoadEventKey_);
goog.editor.SeamlessField.base(this, 'clearListeners');
};
@@ -0,0 +1,28 @@
<!--
All Rights Reserved.
NOTE: This file should be an exact copy of seamlessfield_test.html,
except for this comment and that the <!DOCTYPE> tag should be removed from
the top. If seamlessfield_test.html is changed, this file should be
changed accordingly.
@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>
<title>Trogedit Unit Tests - goog.editor.SeamlessField</title>
<script src="../base.js"></script>
</head>
<body>
<div id='field'>
</div>
<script src="seamlessfield_test.js"></script>
</body>
</html>
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<!--
All Rights Reserved.
NOTE: If any changes are made to this file, please keep
seamlessfield_quirks_test.html synced with this. That file should
be an exact copy of this one, except for this comment and that the
<!DOCTYPE> tag should be removed from the top. All tests should be added
to seamlessfield_test.js so that they will be run by both
seamlessfield*_test.html files.
@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>Trogedit Unit Tests - goog.editor.SeamlessField</title>
<script src="../base.js"></script>
</head>
<body>
<div id='field'>
</div>
<script src="seamlessfield_test.js"></script>
</body>
</html>
@@ -0,0 +1,469 @@
// 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.
/**
* @fileoverview Trogedit unit tests for goog.editor.SeamlessField.
*
* @author nicksantos@google.com (Nick Santos)
* @suppress {missingProperties} There are many mocks in this unit test,
* and the mocks don't fit well in the type system.
*/
/** @suppress {extraProvide} */
goog.provide('goog.editor.seamlessfield_test');
goog.require('goog.dom');
goog.require('goog.dom.DomHelper');
goog.require('goog.dom.Range');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.editor.Field');
goog.require('goog.editor.SeamlessField');
goog.require('goog.events');
goog.require('goog.functions');
goog.require('goog.style');
goog.require('goog.testing.MockClock');
goog.require('goog.testing.MockRange');
goog.require('goog.testing.jsunit');
goog.setTestOnly('seamlessfield_test');
var fieldElem;
var fieldElemClone;
function setUp() {
fieldElem = goog.dom.getElement('field');
fieldElemClone = fieldElem.cloneNode(true);
}
function tearDown() {
fieldElem.parentNode.replaceChild(fieldElemClone, fieldElem);
}
// the following tests check for blended iframe positioning. They really
// only make sense on browsers without contentEditable.
function testBlankField() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
assertAttachSeamlessIframeSizesCorrectly(
initSeamlessField('&nbsp;', {}), createSeamlessIframe());
}
}
function testFieldWithContent() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
assertAttachSeamlessIframeSizesCorrectly(
initSeamlessField('Hi!', {}), createSeamlessIframe());
}
}
function testFieldWithPadding() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
assertAttachSeamlessIframeSizesCorrectly(
initSeamlessField('Hi!', {'padding': '2px 5px'}),
createSeamlessIframe());
}
}
function testFieldWithMargin() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
assertAttachSeamlessIframeSizesCorrectly(
initSeamlessField('Hi!', {'margin': '2px 5px'}),
createSeamlessIframe());
}
}
function testFieldWithBorder() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
assertAttachSeamlessIframeSizesCorrectly(
initSeamlessField('Hi!', {'border': '2px 5px'}),
createSeamlessIframe());
}
}
function testFieldWithOverflow() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
assertAttachSeamlessIframeSizesCorrectly(
initSeamlessField(['1', '2', '3', '4', '5', '6', '7'].join('<p/>'),
{'overflow': 'auto', 'position': 'relative', 'height': '20px'}),
createSeamlessIframe());
assertEquals(20, fieldElem.offsetHeight);
}
}
function testFieldWithOverflowAndPadding() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
var blendedField = initSeamlessField(
['1', '2', '3', '4', '5', '6', '7'].join('<p/>'),
{
'overflow': 'auto',
'position': 'relative',
'height': '20px',
'padding': '2px 3px'
});
var blendedIframe = createSeamlessIframe();
assertAttachSeamlessIframeSizesCorrectly(blendedField, blendedIframe);
assertEquals(24, fieldElem.offsetHeight);
}
}
function testIframeHeightGrowsOnWrap() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
var clock = new goog.testing.MockClock(true);
var blendedField;
try {
blendedField = initSeamlessField('',
{'border': '1px solid black', 'height': '20px'});
blendedField.makeEditable();
blendedField.setHtml(false, 'Content that should wrap after resize.');
// Ensure that the field was fully loaded and sized before measuring.
clock.tick(1);
// Capture starting heights.
var unwrappedIframeHeight = blendedField.getEditableIframe().offsetHeight;
// Resize the field such that the text should wrap.
fieldElem.style.width = '200px';
blendedField.doFieldSizingGecko();
// Iframe should grow as a result.
var wrappedIframeHeight = blendedField.getEditableIframe().offsetHeight;
assertTrue('Wrapped text should cause iframe to grow - initial height: ' +
unwrappedIframeHeight + ', wrapped height: ' + wrappedIframeHeight,
wrappedIframeHeight > unwrappedIframeHeight);
} finally {
blendedField.dispose();
clock.dispose();
}
}
}
function testDispatchIframeResizedForWrapperHeight() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
var clock = new goog.testing.MockClock(true);
var blendedField = initSeamlessField('Hi!', {'border': '2px 5px'});
var iframe = createSeamlessIframe();
blendedField.attachIframe(iframe);
var resizeCalled = false;
goog.events.listenOnce(
blendedField,
goog.editor.Field.EventType.IFRAME_RESIZED,
function() {
resizeCalled = true;
});
try {
blendedField.makeEditable();
blendedField.setHtml(false, 'Content that should wrap after resize.');
// Ensure that the field was fully loaded and sized before measuring.
clock.tick(1);
assertFalse('Iframe resize must not be dispatched yet', resizeCalled);
// Resize the field such that the text should wrap.
fieldElem.style.width = '200px';
blendedField.sizeIframeToWrapperGecko_();
assertTrue('Iframe resize must be dispatched for Wrapper', resizeCalled);
} finally {
blendedField.dispose();
clock.dispose();
}
}
}
function testDispatchIframeResizedForBodyHeight() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
var clock = new goog.testing.MockClock(true);
var blendedField = initSeamlessField('Hi!', {'border': '2px 5px'});
var iframe = createSeamlessIframe();
blendedField.attachIframe(iframe);
var resizeCalled = false;
goog.events.listenOnce(
blendedField,
goog.editor.Field.EventType.IFRAME_RESIZED,
function() {
resizeCalled = true;
});
try {
blendedField.makeEditable();
blendedField.setHtml(false, 'Content that should wrap after resize.');
// Ensure that the field was fully loaded and sized before measuring.
clock.tick(1);
assertFalse('Iframe resize must not be dispatched yet', resizeCalled);
// Resize the field to a different body height.
var bodyHeight = blendedField.getIframeBodyHeightGecko_();
blendedField.getIframeBodyHeightGecko_ = function() {
return bodyHeight + 1;
};
blendedField.sizeIframeToBodyHeightGecko_();
assertTrue('Iframe resize must be dispatched for Body', resizeCalled);
} finally {
blendedField.dispose();
clock.dispose();
}
}
}
function testDispatchBlur() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE &&
!goog.editor.BrowserFeature.CLEARS_SELECTION_WHEN_FOCUS_LEAVES) {
var blendedField = initSeamlessField('Hi!', {'border': '2px 5px'});
var iframe = createSeamlessIframe();
blendedField.attachIframe(iframe);
var blurCalled = false;
goog.events.listenOnce(blendedField, goog.editor.Field.EventType.BLUR,
function() {
blurCalled = true;
});
var clearSelection = goog.dom.Range.clearSelection;
var cleared = false;
var clearedWindow;
blendedField.editableDomHelper = new goog.dom.DomHelper();
blendedField.editableDomHelper.getWindow =
goog.functions.constant(iframe.contentWindow);
var mockRange = new goog.testing.MockRange();
blendedField.getRange = function() {
return mockRange;
};
goog.dom.Range.clearSelection = function(opt_window) {
clearSelection(opt_window);
cleared = true;
clearedWindow = opt_window;
};
var clock = new goog.testing.MockClock(true);
mockRange.collapse(true);
mockRange.select();
mockRange.$replay();
blendedField.dispatchBlur();
clock.tick(1);
assertTrue('Blur must be dispatched.', blurCalled);
assertTrue('Selection must be cleared.', cleared);
assertEquals('Selection must be cleared in iframe',
iframe.contentWindow, clearedWindow);
mockRange.$verify();
clock.dispose();
}
}
function testSetMinHeight() {
if (!goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
var clock = new goog.testing.MockClock(true);
try {
var field = initSeamlessField(
['1', '2', '3', '4', '5', '6', '7'].join('<p/>'),
{'position': 'relative', 'height': '60px'});
// Initially create and size iframe.
var iframe = createSeamlessIframe();
field.attachIframe(iframe);
field.iframeFieldLoadHandler(iframe, '', {});
// Need to process timeouts set by load handlers.
clock.tick(1000);
var normalHeight = goog.style.getSize(iframe).height;
var delayedChangeCalled = false;
goog.events.listen(field, goog.editor.Field.EventType.DELAYEDCHANGE,
function() {
delayedChangeCalled = true;
});
// Test that min height is obeyed.
field.setMinHeight(30);
clock.tick(1000);
assertEquals('Iframe height must match min height.',
30, goog.style.getSize(iframe).height);
assertFalse('Setting min height must not cause delayed change event.',
delayedChangeCalled);
// Test that min height doesn't shrink field.
field.setMinHeight(0);
clock.tick(1000);
assertEquals(normalHeight, goog.style.getSize(iframe).height);
assertFalse('Setting min height must not cause delayed change event.',
delayedChangeCalled);
} finally {
field.dispose();
clock.dispose();
}
}
}
/**
* @bug 1649967 This code used to throw a Javascript error.
*/
function testSetMinHeightWithNoIframe() {
if (goog.editor.BrowserFeature.HAS_CONTENT_EDITABLE) {
try {
var field = initSeamlessField('&nbsp;', {});
field.makeEditable();
field.setMinHeight(30);
} finally {
field.dispose();
}
}
}
function testStartChangeEvents() {
if (goog.editor.BrowserFeature.USE_MUTATION_EVENTS) {
var clock = new goog.testing.MockClock(true);
try {
var field = initSeamlessField('&nbsp;', {});
field.makeEditable();
var changeCalled = false;
goog.events.listenOnce(field, goog.editor.Field.EventType.CHANGE,
function() {
changeCalled = true;
});
var delayedChangeCalled = false;
goog.events.listenOnce(field, goog.editor.Field.EventType.CHANGE,
function() {
delayedChangeCalled = true;
});
field.stopChangeEvents(true, true);
if (field.changeTimerGecko_) {
field.changeTimerGecko_.start();
}
field.startChangeEvents();
clock.tick(1000);
assertFalse(changeCalled);
assertFalse(delayedChangeCalled);
} finally {
clock.dispose();
field.dispose();
}
}
}
function testManipulateDom() {
// Test in blended field since that is what fires change events.
var editableField = initSeamlessField('&nbsp;', {});
var clock = new goog.testing.MockClock(true);
var delayedChangeCalled = 0;
goog.events.listen(editableField, goog.editor.Field.EventType.DELAYEDCHANGE,
function() {
delayedChangeCalled++;
});
assertFalse(editableField.isLoaded());
editableField.manipulateDom(goog.nullFunction);
clock.tick(1000);
assertEquals('Must not fire delayed change events if field is not loaded.',
0, delayedChangeCalled);
editableField.makeEditable();
var usesIframe = editableField.usesIframe();
try {
editableField.manipulateDom(goog.nullFunction);
clock.tick(1000); // Wait for delayed change to fire.
assertEquals('By default must fire a single delayed change event.',
1, delayedChangeCalled);
editableField.manipulateDom(goog.nullFunction, true);
clock.tick(1000); // Wait for delayed change to fire.
assertEquals('Must prevent all delayed change events.',
1, delayedChangeCalled);
editableField.manipulateDom(function() {
this.handleChange();
this.handleChange();
if (this.changeTimerGecko_) {
this.changeTimerGecko_.fire();
}
this.dispatchDelayedChange_();
this.delayedChangeTimer_.fire();
}, false, editableField);
clock.tick(1000); // Wait for delayed change to fire.
assertEquals('Must ignore dispatch delayed change called within func.',
2, delayedChangeCalled);
} finally {
// Ensure we always uninstall the mock clock and dispose of everything.
editableField.dispose();
clock.dispose();
}
}
function testAttachIframe() {
var blendedField = initSeamlessField('Hi!', {});
var iframe = createSeamlessIframe();
try {
blendedField.attachIframe(iframe);
} catch (err) {
fail('Error occurred while attaching iframe.');
}
}
function createSeamlessIframe() {
// NOTE(nicksantos): This is a reimplementation of
// TR_EditableUtil.getIframeAttributes, but untangled for tests, and
// specifically with what we need for blended mode.
return goog.dom.createDom('IFRAME',
{ 'frameBorder': '0', 'style': 'padding:0;' });
}
/**
* Initialize a new editable field for the field id 'field', with the given
* innerHTML and styles.
*
* @param {string} innerHTML html for the field contents.
* @param {Object} styles Key-value pairs for styles on the field.
* @return {goog.editor.SeamlessField} The field.
*/
function initSeamlessField(innerHTML, styles) {
var field = new goog.editor.SeamlessField('field');
fieldElem.innerHTML = innerHTML;
goog.style.setStyle(fieldElem, styles);
return field;
}
/**
* Make sure that the original field element for the given goog.editor.Field has
* the same size before and after attaching the given iframe. If this is not
* true, then the field will fidget while we're initializing the field,
* and that's not what we want.
*
* @param {goog.editor.Field} fieldObj The field.
* @param {HTMLIFrameElement} iframe The iframe.
*/
function assertAttachSeamlessIframeSizesCorrectly(fieldObj, iframe) {
var size = goog.style.getSize(fieldObj.getOriginalElement());
fieldObj.attachIframe(iframe);
var newSize = goog.style.getSize(fieldObj.getOriginalElement());
assertEquals(size.width, newSize.width);
assertEquals(size.height, newSize.height);
}
@@ -0,0 +1,225 @@
// 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.
/**
* @fileoverview Utilties for working with the styles of DOM nodes, and
* related to rich text editing.
*
* Many of these are not general enough to go into goog.style, and use
* constructs (like "isContainer") that only really make sense inside
* of an HTML editor.
*
* The API has been optimized for iterating over large, irregular DOM
* structures (with lots of text nodes), and so the API tends to be a bit
* more permissive than the goog.style API should be. For example,
* goog.style.getComputedStyle will throw an exception if you give it a
* text node.
*
* @author nicksantos@google.com (Nick Santos)
*/
goog.provide('goog.editor.style');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.NodeType');
goog.require('goog.dom.TagName');
goog.require('goog.editor.BrowserFeature');
goog.require('goog.events.EventType');
goog.require('goog.object');
goog.require('goog.style');
goog.require('goog.userAgent');
/**
* Gets the computed or cascaded style.
*
* This is different than goog.style.getStyle_ because it returns null
* for text nodes (instead of throwing an exception), and never reads
* inline style. These two functions may need to be reconciled.
*
* @param {!Node} node Node to get style of.
* @param {string} stylePropertyName Property to get (must be camelCase,
* not css-style).
* @return {?string} Style value, or null if this is not an element node.
* @private
*/
goog.editor.style.getComputedOrCascadedStyle_ = function(
node, stylePropertyName) {
if (node.nodeType != goog.dom.NodeType.ELEMENT) {
// Only element nodes have style.
return null;
}
return goog.userAgent.IE ?
goog.style.getCascadedStyle(/** @type {!Element} */ (node),
stylePropertyName) :
goog.style.getComputedStyle(/** @type {!Element} */ (node),
stylePropertyName);
};
/**
* Checks whether the given element inherits display: block.
* @param {!Node} node The Node to check.
* @return {boolean} Whether the element inherits CSS display: block.
*/
goog.editor.style.isDisplayBlock = function(node) {
return goog.editor.style.getComputedOrCascadedStyle_(
node, 'display') == 'block';
};
/**
* Returns true if the element is a container of other non-inline HTML
* Note that span, strong and em tags, being inline can only contain
* other inline elements and are thus, not containers. Containers are elements
* that should not be broken up when wrapping selections with a node of an
* inline block styling.
* @param {Node} element The element to check.
* @return {boolean} Whether the element is a container.
*/
goog.editor.style.isContainer = function(element) {
var nodeName = element && element.nodeName.toLowerCase();
return !!(element &&
(goog.editor.style.isDisplayBlock(element) ||
nodeName == 'td' ||
nodeName == 'table' ||
nodeName == 'li'));
};
/**
* Return the first ancestor of this node that is a container, inclusive.
* @see isContainer
* @param {Node} node Node to find the container of.
* @return {Element} The element which contains node.
*/
goog.editor.style.getContainer = function(node) {
// We assume that every node must have a container.
return /** @type {Element} */ (
goog.dom.getAncestor(node, goog.editor.style.isContainer, true));
};
/**
* Set of input types that should be kept selectable even when their ancestors
* are made unselectable.
* @type {Object}
* @private
*/
goog.editor.style.SELECTABLE_INPUT_TYPES_ = goog.object.createSet(
'text', 'file', 'url');
/**
* Prevent the default action on mousedown events.
* @param {goog.events.Event} e The mouse down event.
* @private
*/
goog.editor.style.cancelMouseDownHelper_ = function(e) {
var targetTagName = e.target.tagName;
if (targetTagName != goog.dom.TagName.TEXTAREA &&
targetTagName != goog.dom.TagName.INPUT) {
e.preventDefault();
}
};
/**
* Makes the given element unselectable, as well as all of its children, except
* for text areas, text, file and url inputs.
* @param {Element} element The element to make unselectable.
* @param {goog.events.EventHandler} eventHandler An EventHandler to register
* the event with. Assumes when the node is destroyed, the eventHandler's
* listeners are destroyed as well.
*/
goog.editor.style.makeUnselectable = function(element, eventHandler) {
if (goog.editor.BrowserFeature.HAS_UNSELECTABLE_STYLE) {
// The mousing down on a node should not blur the focused node.
// This is consistent with how IE works.
// TODO: Consider using just the mousedown handler and not the css property.
eventHandler.listen(element, goog.events.EventType.MOUSEDOWN,
goog.editor.style.cancelMouseDownHelper_, true);
}
goog.style.setUnselectable(element, true);
// Make inputs and text areas selectable.
var inputs = element.getElementsByTagName(goog.dom.TagName.INPUT);
for (var i = 0, len = inputs.length; i < len; i++) {
var input = inputs[i];
if (input.type in goog.editor.style.SELECTABLE_INPUT_TYPES_) {
goog.editor.style.makeSelectable(input);
}
}
goog.array.forEach(element.getElementsByTagName(goog.dom.TagName.TEXTAREA),
goog.editor.style.makeSelectable);
};
/**
* Make the given element selectable.
*
* For IE this simply turns off the "unselectable" property.
*
* Under FF no descendent of an unselectable node can be selectable:
*
* https://bugzilla.mozilla.org/show_bug.cgi?id=203291
*
* So we make each ancestor of node selectable, while trying to preserve the
* unselectability of other nodes along that path
*
* This may cause certain text nodes which should be unselectable, to become
* selectable. For example:
*
* <div id=div1 style="-moz-user-select: none">
* Text1
* <span id=span1>Text2</span>
* </div>
*
* If we call makeSelectable on span1, then it will cause "Text1" to become
* selectable, since it had to make div1 selectable in order for span1 to be
* selectable.
*
* If "Text1" were enclosed within a <p> or <span>, then this problem would
* not arise. Text nodes do not have styles, so its style can't be set to
* unselectable.
*
* @param {Element} element The element to make selectable.
*/
goog.editor.style.makeSelectable = function(element) {
goog.style.setUnselectable(element, false);
if (goog.editor.BrowserFeature.HAS_UNSELECTABLE_STYLE) {
// Go up ancestor chain, searching for nodes that are unselectable.
// If such a node exists, mark it as selectable but mark its other children
// as unselectable so the minimum set of nodes is changed.
var child = element;
var current = /** @type {Element} */ (element.parentNode);
while (current && current.tagName != goog.dom.TagName.HTML) {
if (goog.style.isUnselectable(current)) {
goog.style.setUnselectable(current, false, true);
for (var i = 0, len = current.childNodes.length; i < len; i++) {
var node = current.childNodes[i];
if (node != child && node.nodeType == goog.dom.NodeType.ELEMENT) {
goog.style.setUnselectable(current.childNodes[i], true);
}
}
}
child = current;
current = /** @type {Element} */ (current.parentNode);
}
}
};

Some files were not shown because too many files have changed in this diff Show More