// 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( goog.dom.TagName.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(goog.dom.TagName.BLOCKQUOTE, null, elem) .length); assert( 'Selection should be deleted', -1 == elem.innerHTML.indexOf('selection')); assert( 'should have div with  ', -1 != elem.innerHTML.indexOf('>' + getNbsp() + '<')); assert('event should have been prevented', prevented); } function testEnterInNonSetupBlockquoteWhenClassnameIsNotRequired() { setUpFields(false); resetChangeFlags(); var prevented = !selectNodeAndHitEnter(field1, 'field1cursor'); waitForChangeEvents(); assertChangeFlags(); // make sure there are two blockquotes, and a DIV with nbsp in the middle. var elem = field1.getElement(); var dom = field1.getEditableDomHelper(); assertEquals( 'Blockquote should be split', 2, dom.getElementsByTagNameAndClass(goog.dom.TagName.BLOCKQUOTE, null, elem) .length); assert( 'Selection should be deleted', -1 == elem.innerHTML.indexOf('selection')); assert( 'should have div with  ', -1 != elem.innerHTML.indexOf('>' + getNbsp() + '<')); assert('event should have been prevented', prevented); } function testEnterInBlockquoteCreatesDivInBrMode() { setUpFields(true); selectNodeAndHitEnter(field2, 'field2cursor'); var elem = field2.getElement(); var dom = field2.getEditableDomHelper(); var firstBlockquote = dom.getElementsByTagNameAndClass( goog.dom.TagName.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:- //
one
|two
// // After enter on the cursor position without the fix, the resulting HTML // after the blockquote split was:- //
one
//
 
//

two
// // This creates the impression on an unnecessary newline. The resulting HTML // after the fix is:- // //
one
//
 
//
two
field1.setHtml( false, '
one
' + 'two
'); 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( goog.dom.TagName.BLOCKQUOTE, null, elem)[1]; assertHTMLEquals('two
', secondBlockquote.innerHTML); // Verifies that a blockquote split doesn't happen if it doesn't need to. field1.setHtml( false, '
one
'); selectNodeAndHitEnter(field1, 'brcursor'); assertEquals( 1, dom.getElementsByTagNameAndClass(goog.dom.TagName.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:- //
one|
two
// // After enter on the cursor position, the resulting HTML should be. //
one
//
 
//
two
field1.setHtml( false, '
one
' + 'two
'); 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( goog.dom.TagName.BLOCKQUOTE, null, elem)[1]; assertHTMLEquals('two
', secondBlockquote.innerHTML); // Ensures that standard text node split works as expected with the new // change. field1.setHtml( false, '
onetwo
'); 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( goog.dom.TagName.BLOCKQUOTE, null, elem)[1]; assertHTMLEquals('two
', 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". // //
//
a
  1. one|
//
two
//
// // After pressing enter, we have the following structure. // //
//
a
  1. one|
//
//
 
//
//
//
two
//
// // This appears to the user as an empty list. After the fix, the HTML // will be // //
//
a
  1. one|
//
//
 
//
//
two
//
// field1.setHtml( false, '
' + '
a
  1. one
' + '
b
' + '
'); 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( goog.dom.TagName.BLOCKQUOTE, null, elem)[1]; assertHTMLEquals('
b
', secondBlockquote.innerHTML); // Ensure that we remove only unnecessary subtrees. field1.setHtml( false, '
' + '
a
one
two
' + '
c
' + '
'); goog.dom.Range.createCaret(dom.getElement('cursor').firstChild, 3).select(); goog.testing.events.fireKeySequence( field1.getElement(), goog.events.KeyCodes.ENTER); secondBlockquote = dom.getElementsByTagNameAndClass( goog.dom.TagName.BLOCKQUOTE, null, elem)[1]; var expectedHTML = '
two
' + '
c
'; assertHTMLEquals(expectedHTML, secondBlockquote.innerHTML); // Place the cursor in the middle of a line. field1.setHtml( false, '
' + '
one
two
' + '
'); 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(goog.dom.TagName.BLOCKQUOTE, null, elem); assertEquals(2, blockquotes.length); assertHTMLEquals('
o
', blockquotes[0].innerHTML); assertHTMLEquals('
ne
two
', blockquotes[1].innerHTML); } function testEnterInList() { setUpFields(true); // in a list should *never* be handled by custom code. Lists are // just way too complicated to get right. field1.setHtml(false, '
  1. hi!
'); 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(' in a list should not be prevented', prevented); } function testEnterAtEndOfBlockInWebkit() { setUpFields(true); if (goog.userAgent.WEBKIT) { field1.setHtml( false, '
hi!
'); 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(goog.dom.TagName.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

two
'); 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
', 'one
two
', 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
  • two
'); helper.select(field1.getElement(), 1); // Between one and BR. goog.testing.events.fireKeySequence( field1.getElement(), goog.events.KeyCodes.DELETE); assertEquals( 'Should have deleted the
', 'one
  • two
', 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, '
foo

bar
'); helper.select(field1.getElement(), 1); // Before the BR. goog.testing.events.fireKeySequence( field1.getElement(), goog.events.KeyCodes.DELETE); assertEquals( 'Should have deleted the
', '
foo
bar
', field1.getElement().innerHTML); range = field1.getRange(); assertEquals( 'The selected range should be contained within the ', String(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, '
  • one
'); helper.select(field1.getElement(), 0); // Before the BR. goog.testing.events.fireKeySequence( field1.getElement(), goog.events.KeyCodes.DELETE); assertEquals( 'Should have deleted the
', '
  • one
', 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
  • ', String(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, '

    • one
    '); helper.select(field1.getElement(), 1); // Between the BR's. goog.testing.events.fireKeySequence( field1.getElement(), goog.events.KeyCodes.DELETE); assertEquals( 'Should have deleted the
    ', '
    • one
    ', 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
  • ', String(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, '


    foo
    '); 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
    \'s and the blockquote ' + 'isn\'t affected', '
    foo
    ', field1.getElement().innerHTML); var range = field1.getRange(); assertEquals( 'The selected range should be contained within the ' + '
    ', String(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


    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
    ', 'one

    two', field1.getElement().innerHTML); } // End if GECKO } /** * Tests that deleteCursorSelectionW3C_ correctly recognizes visually * collapsed selections in Opera even if they contain a
    . * See the deleteCursorSelectionW3C_ comment in enterhandler.js. */ function testCollapsedSelectionKeepsBrOpera() { setUpFields(true); if (goog.userAgent.OPERA) { field1.setHtml(false, '

    '); field1.focus(); goog.testing.events.fireKeySequence( field1.getElement(), goog.events.KeyCodes.ENTER); assertNotNull( 'The
    must not have been deleted', goog.dom.getElement('pleasedontdeleteme')); } } /** * Selects the node at the given id, and simulates an ENTER keypress. * @param {goog.editor.Field} field The field with the node. * @param {string} id A DOM id. * @return {boolean} Whether preventDefault was called on the event. */ function selectNodeAndHitEnter(field, id) { var dom = field.getEditableDomHelper(); var cursor = dom.getElement(id); goog.dom.Range.createFromNodeContents(cursor).select(); return goog.testing.events.fireKeySequence( cursor, goog.events.KeyCodes.ENTER); } /** * Creates a field with only the enter handler plugged in, for testing. * @param {string} id A DOM id. * @return {goog.editor.Field} A field. */ function makeField(id, classnameRequiredToSplitBlockquote) { var field = new goog.editor.Field(id); field.registerPlugin(new goog.editor.plugins.EnterHandler()); field.registerPlugin( new goog.editor.plugins.Blockquote(classnameRequiredToSplitBlockquote)); goog.events.listen( field, goog.editor.Field.EventType.BEFORECHANGE, function() { // set the global flag that beforechange was fired. firedBeforeChange = true; }); goog.events.listen( field, goog.editor.Field.EventType.DELAYEDCHANGE, function() { // set the global flag that delayed change was fired. firedDelayedChange = true; }); return field; } /** * Reset all the global flags related to change events. */ function resetChangeFlags() { waitForChangeEvents(); firedBeforeChange = firedDelayedChange = false; } /** * Asserts that both change flags were fired since the last reset. */ function assertChangeFlags() { assert('Beforechange should have fired', firedBeforeChange); assert('Delayedchange should have fired', firedDelayedChange); } /** * Wait for delayedchange to propagate. */ function waitForChangeEvents() { clock.tick( goog.editor.Field.DELAYED_CHANGE_FREQUENCY + goog.editor.Field.CHANGE_FREQUENCY); } function getNbsp() { // On WebKit (pre-528) and Opera,   shows up as its unicode character in // innerHTML under some circumstances. return (goog.userAgent.WEBKIT && !goog.userAgent.isVersionOrHigher('528')) || goog.userAgent.OPERA ? '\u00a0' : ' '; } function testPrepareContent() { setUpFields(true); assertPreparedContents('hi', 'hi'); assertPreparedContents( goog.editor.BrowserFeature.COLLAPSES_EMPTY_NODES ? '
    ' : '', ' '); } /** * 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 = '
    abcd
    '; 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('
    ad
    ', container); } } function testDeleteW3CAll() { if (goog.editor.BrowserFeature.HAS_W3C_RANGES) { container.innerHTML = '
    abcd
    '; 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('
     
    ', container); } } function testDeleteW3CPartialEnd() { if (goog.editor.BrowserFeature.HAS_W3C_RANGES) { container.innerHTML = '
    ab
    cd
    '; 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('
    ad
    ', container); } } function testDeleteW3CNonPartialEnd() { if (goog.editor.BrowserFeature.HAS_W3C_RANGES) { container.innerHTML = '
    ab
    cd
    '; 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('
    a
    ', container); } } function testIsInOneContainer() { if (goog.editor.BrowserFeature.HAS_W3C_RANGES) { container.innerHTML = '

    '; 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
    b

    c
    d
    '; 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
    b
    ', container); assertTrue(newRange.isCollapsed()); assertEquals(container, newRange.getStartNode()); assertEquals(2, newRange.getStartOffset()); } }