/** * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ import Document from '../../src/model/document'; import Position from '../../src/model/position'; import Range from '../../src/model/range'; import Element from '../../src/model/element'; import deleteContent from '../../src/controller/deletecontent'; import { setData, getData } from '../../src/dev-utils/model'; describe( 'DataController', () => { let doc; describe( 'deleteContent', () => { describe( 'in simple scenarios', () => { beforeEach( () => { doc = new Document(); doc.createRoot(); const schema = doc.schema; schema.registerItem( 'image', '$inline' ); schema.allow( { name: '$text', inside: '$root' } ); schema.allow( { name: 'image', inside: '$root' } ); } ); test( 'does nothing on collapsed selection', 'f[]oo', 'f[]oo' ); test( 'deletes single character', 'f[o]o', 'f[]o' ); it( 'deletes single character (backward selection)', () => { setData( doc, 'f[o]o', { lastRangeBackward: true } ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc ) ).to.equal( 'f[]o' ); } ); test( 'deletes whole text', '[foo]', '[]' ); test( 'deletes whole text between nodes', '[foo]', '[]' ); test( 'deletes an element', 'x[]y', 'x[]y' ); test( 'deletes a bunch of nodes', 'w[xy]z', 'w[]z' ); test( 'does not break things when option.merge passed', 'w[xy]z', 'w[]z' ); } ); describe( 'with text attributes', () => { beforeEach( () => { doc = new Document(); doc.createRoot(); const schema = doc.schema; schema.registerItem( 'image', '$inline' ); schema.registerItem( 'paragraph', '$block' ); schema.allow( { name: '$text', inside: '$root' } ); schema.allow( { name: '$text', attributes: [ 'bold', 'italic' ] } ); } ); it( 'deletes characters (first half has attrs)', () => { setData( doc, '<$text bold="true">fo[ob]ar' ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc ) ).to.equal( '<$text bold="true">fo[]ar' ); expect( doc.selection.getAttribute( 'bold' ) ).to.equal( true ); } ); it( 'deletes characters (2nd half has attrs)', () => { setData( doc, 'fo[o<$text bold="true">b]ar' ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc ) ).to.equal( 'fo[]<$text bold="true">ar' ); expect( doc.selection.getAttribute( 'bold' ) ).to.undefined; } ); it( 'clears selection attrs when emptied content', () => { setData( doc, 'x[<$text bold="true">foo]y' ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc ) ).to.equal( 'x[]y' ); expect( doc.selection.getAttribute( 'bold' ) ).to.undefined; } ); it( 'leaves selection attributes when text contains them', () => { setData( doc, 'x<$text bold="true">a[foo]by', { selectionAttributes: { bold: true } } ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc ) ).to.equal( 'x<$text bold="true">a[]by' ); expect( doc.selection.getAttribute( 'bold' ) ).to.equal( true ); } ); } ); // Note: The algorithm does not care what kind of it's merging as it knows nothing useful about these elements. // In most cases it handles all elements like you'd expect to handle block elements in HTML. However, // in some scenarios where the tree depth is bigger results may be hard to justify. In fact, such cases // should not happen unless we're talking about lists or tables, but these features will need to cover // their scenarios themselves. In all generic scenarios elements are never nested. // // You may also be thinking – but I don't want my elements to be merged. It means that there are some special rules, // like – multiple editing hosts (cE=true/false in use) or block limit elements like . // Those case should, again, be handled by their specific implementations. describe( 'in multi-element scenarios', () => { beforeEach( () => { doc = new Document(); doc.createRoot(); const schema = doc.schema; schema.registerItem( 'paragraph', '$block' ); schema.registerItem( 'heading1', '$block' ); schema.registerItem( 'pchild' ); schema.registerItem( 'pparent' ); schema.registerItem( 'image', '$inline' ); schema.allow( { name: 'pchild', inside: 'paragraph' } ); schema.allow( { name: '$text', inside: 'pchild' } ); schema.allow( { name: 'paragraph', inside: 'pparent' } ); schema.allow( { name: 'pparent', inside: '$root' } ); schema.allow( { name: '$text', inside: 'pparent' } ); schema.allow( { name: 'paragraph', attributes: [ 'align' ] } ); } ); test( 'do not merge when no need to', 'x[foo]y', 'x[]y' ); test( 'merges second element into the first one (same name)', 'xfo[ob]ary', 'xfo[]ary' ); test( 'does not merge second element into the first one (same name, !option.merge)', 'xfo[ob]ary', 'xfo[]ary', { leaveUnmerged: true } ); test( 'merges second element into the first one (same name)', 'xfo[ob]ary', 'xfo[]ary' ); test( 'merges second element into the first one (different name)', 'xfo[ob]ary', 'xfo[]ary' ); // Note: in all these cases we ignore the direction of merge. // If https://github.com/ckeditor/ckeditor5-engine/issues/470 was fixed we could differently treat // forward and backward delete. it( 'merges second element into the first one (different name, backward selection)', () => { setData( doc, 'xfo[ob]ary', { lastRangeBackward: true } ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc ) ).to.equal( 'xfo[]ary' ); } ); test( 'merges second element into the first one (different attrs)', 'xfo[ob]ary', 'xfo[]ary' ); test( 'merges second element to an empty first element', 'x[fo]oy', 'x[]oy' ); test( 'merges empty element into the first element', 'f[oobar]x', 'f[]x' ); test( 'leaves just one element when all selected', '[xfooy]', '[]' ); it( 'uses remove delta instead of merge delta if merged element is empty', () => { setData( doc, 'ab[cdefgh]' ); const batch = doc.batch(); const spyMerge = sinon.spy( batch, 'merge' ); const spyRemove = sinon.spy( batch, 'remove' ); deleteContent( doc.selection, batch ); expect( getData( doc ) ).to.equal( 'ab[]' ); expect( spyMerge.called ).to.be.false; expect( spyRemove.called ).to.be.true; } ); it( 'does not try to move the second block if not needed', () => { setData( doc, 'ab[cdef]gh' ); const batch = doc.batch(); const spyMerge = sinon.spy( batch, 'merge' ); const spyMove = sinon.spy( batch, 'move' ); deleteContent( doc.selection, batch ); expect( getData( doc ) ).to.equal( 'ab[]gh' ); expect( spyMove.called ).to.be.false; expect( spyMerge.called ).to.be.true; } ); // Note: in all these cases we ignore the direction of merge. // If https://github.com/ckeditor/ckeditor5-engine/issues/470 was fixed we could differently treat // forward and backward delete. describe( 'with nested elements', () => { test( 'merges elements when deep nested', 'xfo[ob]ary', 'xfo[]ary' ); it( 'merges elements when deep nested (3rd level)', () => { const root = doc.getRoot(); // We need to use the raw API due to https://github.com/ckeditor/ckeditor5-engine/issues/905. // xxfo[o // b]aryy root.appendChildren( new Element( 'pparent', null, [ 'x', new Element( 'paragraph', null, [ 'x', new Element( 'pchild', null, 'foo' ) ] ) ] ) ); root.appendChildren( new Element( 'pparent', null, [ new Element( 'paragraph', null, [ new Element( 'pchild', null, 'bar' ), 'y' ] ), 'y' ] ) ); const range = new Range( new Position( doc.getRoot(), [ 0, 1, 1, 2 ] ), // fo[o new Position( doc.getRoot(), [ 1, 0, 0, 1 ] ) // b]ar ); doc.selection.setRanges( [ range ] ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc ) ) .to.equal( 'xxfo[]aryy' ); } ); test( 'merges elements when left end deep nested', 'xfo[ob]aryx', 'xfo[]aryx' ); test( 'merges elements when right end deep nested', 'xfo[ob]arx', 'xfo[]arx' ); it( 'merges elements when left end deep nested (3rd level)', () => { const root = doc.getRoot(); // We need to use the raw API due to https://github.com/ckeditor/ckeditor5-engine/issues/905. // xfooba[rb]om root.appendChildren( new Element( 'pparent', null, [ 'x', new Element( 'paragraph', null, [ 'foo', new Element( 'pchild', null, 'bar' ) ] ) ] ) ); root.appendChildren( new Element( 'paragraph', null, 'bom' ) ); const range = new Range( new Position( doc.getRoot(), [ 0, 1, 3, 2 ] ), // ba[r new Position( doc.getRoot(), [ 1, 1 ] ) // b]om ); doc.selection.setRanges( [ range ] ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc ) ) .to.equal( 'xfooba[]om' ); } ); test( 'merges elements when right end deep nested (in an empty container)', 'fo[obar]', 'fo[]' ); test( 'merges elements when left end deep nested (in an empty container)', '[foob]arx', '[]arx' ); it( 'merges elements when left end deep nested (3rd level)', () => { const root = doc.getRoot(); // We need to use the raw API due to https://github.com/ckeditor/ckeditor5-engine/issues/905. // fo[obar] root.appendChildren( new Element( 'paragraph', null, 'foo' ) ); root.appendChildren( new Element( 'pparent', null, [ new Element( 'paragraph', null, [ new Element( 'pchild', null, 'bar' ) ] ) ] ) ); const range = new Range( new Position( doc.getRoot(), [ 0, 2 ] ), // f[oo new Position( doc.getRoot(), [ 1, 0, 0, 3 ] ) // bar] ); doc.selection.setRanges( [ range ] ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc ) ) .to.equal( 'fo[]' ); } ); } ); describe( 'with object elements', () => { beforeEach( () => { const schema = doc.schema; schema.registerItem( 'blockWidget' ); schema.registerItem( 'nestedEditable' ); schema.allow( { name: 'blockWidget', inside: '$root' } ); schema.allow( { name: 'nestedEditable', inside: 'blockWidget' } ); schema.allow( { name: '$text', inside: 'nestedEditable' } ); schema.objects.add( 'blockWidget' ); schema.limits.add( 'nestedEditable' ); } ); test( 'does not merge an object element (if it is first)', 'fo[ob]ar', 'fo[]ar' ); test( 'does not merge an object element (if it is second)', 'ba[rf]oo', 'ba[]oo' ); } ); } ); describe( 'in element selections scenarios', () => { beforeEach( () => { doc = new Document(); //

like root. doc.createRoot( 'paragraph', 'paragraphRoot' ); // like root. doc.createRoot( '$root', 'bodyRoot' ); // Special root which allows only blockWidgets inside itself. doc.createRoot( 'restrictedRoot', 'restrictedRoot' ); const schema = doc.schema; schema.registerItem( 'image', '$inline' ); schema.registerItem( 'paragraph', '$block' ); schema.registerItem( 'heading1', '$block' ); schema.registerItem( 'blockWidget' ); schema.registerItem( 'restrictedRoot' ); schema.allow( { name: '$block', inside: '$root' } ); schema.allow( { name: 'blockWidget', inside: '$root' } ); schema.allow( { name: 'blockWidget', inside: 'restrictedRoot' } ); } ); // See also "in simple scenarios => deletes an element". it( 'deletes two inline elements', () => { setData( doc, 'x[]z', { rootName: 'paragraphRoot' } ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc, { rootName: 'paragraphRoot' } ) ) .to.equal( 'x[]z' ); } ); it( 'creates a paragraph when text is not allowed (paragraph selected)', () => { setData( doc, 'x[yyy]z', { rootName: 'bodyRoot' } ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc, { rootName: 'bodyRoot' } ) ) .to.equal( 'x[]z' ); } ); it( 'creates a paragraph when text is not allowed (block widget selected)', () => { setData( doc, 'x[]z', { rootName: 'bodyRoot' } ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc, { rootName: 'bodyRoot' } ) ) .to.equal( 'x[]z' ); } ); it( 'creates paragraph when text is not allowed (heading selected)', () => { setData( doc, 'x[yyy]z', { rootName: 'bodyRoot' } ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc, { rootName: 'bodyRoot' } ) ) .to.equal( 'x[]z' ); } ); it( 'creates paragraph when text is not allowed (two blocks selected)', () => { setData( doc, 'x[yyyyyy]z', { rootName: 'bodyRoot' } ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc, { rootName: 'bodyRoot' } ) ) .to.equal( 'x[]z' ); } ); it( 'creates paragraph when text is not allowed (all content selected)', () => { setData( doc, '[xz]', { rootName: 'bodyRoot' } ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc, { rootName: 'bodyRoot' } ) ) .to.equal( '[]' ); } ); it( 'does not create a paragraph when it is not allowed', () => { setData( doc, '[]', { rootName: 'restrictedRoot' } ); deleteContent( doc.selection, doc.batch() ); expect( getData( doc, { rootName: 'restrictedRoot' } ) ) .to.equal( '[]' ); } ); } ); describe( 'integration with inline limit elements', () => { beforeEach( () => { doc = new Document(); doc.createRoot(); const schema = doc.schema; schema.registerItem( 'inlineLimit' ); schema.allow( { name: 'inlineLimit', inside: '$root' } ); schema.allow( { name: '$text', inside: 'inlineLimit' } ); schema.limits.add( 'inlineLimit' ); schema.allow( { name: '$inline', inside: '$root' } ); schema.registerItem( 'x' ); schema.allow( { name: '$text', inside: 'x' } ); schema.allow( { name: 'x', inside: '$root' } ); } ); test( 'should delete inside inline limit element', 'foo [bar] baz', 'foo [] baz' ); test( 'should delete whole inline limit element', 'x[foo bar]x', 'x[]x' ); test( 'should delete from two inline limit elements', 'foo [barbaz] qux', 'foo [] qux' ); test( 'merge option should be ignored if both elements are limits', 'foo [barbaz] qux', 'foo [] qux' ); test( 'merge option should be ignored if the first element is a limit', 'foo [barbaz] qux', 'foo [] qux' ); test( 'merge option should be ignored if the second element is a limit', 'baz [quxfoo] bar', 'baz [] bar' ); } ); describe( 'integration with block limit elements', () => { beforeEach( () => { doc = new Document(); doc.createRoot(); const schema = doc.schema; schema.registerItem( 'blockLimit' ); schema.allow( { name: 'blockLimit', inside: '$root' } ); schema.allow( { name: '$block', inside: 'blockLimit' } ); schema.limits.add( 'blockLimit' ); schema.registerItem( 'paragraph', '$block' ); } ); test( 'should delete inside block limit element', 'fo[ob]ar', 'fo[]ar', { leaveUnmerged: true } ); test( 'should delete inside block limit element (with merge)', 'fo[ob]ar', 'fo[]ar' ); test( 'should delete whole block limit element', 'x[foo]x', 'x[]x' ); test( 'should delete from two block limit elements', 'foo [barbaz] qux', 'foo [] qux' ); test( 'merge option should be ignored if any of the elements is a limit', 'foo [barbaz] qux', 'foo [] qux' ); } ); function test( title, input, output, options ) { it( title, () => { setData( doc, input ); deleteContent( doc.selection, doc.batch(), options ); expect( getData( doc ) ).to.equal( output ); } ); } } ); } );