/** * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md. */ import Model from '../../../src/model/model'; import Position from '../../../src/model/position'; import Range from '../../../src/model/range'; import Element from '../../../src/model/element'; import deleteContent from '../../../src/model/utils/deletecontent'; import { setData, getData } from '../../../src/dev-utils/model'; describe( 'DataController utils', () => { let model, doc; describe( 'deleteContent', () => { it( 'should use parent batch', () => { model = new Model(); doc = model.document; doc.createRoot(); model.schema.extend( '$text', { allowIn: '$root' } ); setData( model, 'x[abc]x' ); model.change( writer => { deleteContent( model, doc.selection ); expect( writer.batch.deltas ).to.length( 1 ); } ); } ); describe( 'in simple scenarios', () => { beforeEach( () => { model = new Model(); doc = model.document; doc.createRoot(); const schema = model.schema; schema.register( 'image', { allowWhere: '$text' } ); schema.extend( '$text', { allowIn: '$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( model, 'f[o]o', { lastRangeBackward: true } ); deleteContent( model, doc.selection ); expect( getData( model ) ).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( () => { model = new Model(); doc = model.document; doc.createRoot(); const schema = model.schema; schema.register( 'image', { allowWhere: '$text' } ); schema.register( 'paragraph', { inheritAllFrom: '$block' } ); schema.extend( '$text', { allowIn: '$root', allowAttributes: [ 'bold', 'italic' ] } ); } ); it( 'deletes characters (first half has attrs)', () => { setData( model, '<$text bold="true">fo[ob]ar' ); deleteContent( model, doc.selection ); expect( getData( model ) ).to.equal( '<$text bold="true">fo[]ar' ); expect( doc.selection.getAttribute( 'bold' ) ).to.equal( true ); } ); it( 'deletes characters (2nd half has attrs)', () => { setData( model, 'fo[o<$text bold="true">b]ar' ); deleteContent( model, doc.selection ); expect( getData( model ) ).to.equal( 'fo[]<$text bold="true">ar' ); expect( doc.selection.getAttribute( 'bold' ) ).to.undefined; } ); it( 'clears selection attrs when emptied content', () => { setData( model, 'x[<$text bold="true">foo]y' ); deleteContent( model, doc.selection ); expect( getData( model ) ).to.equal( 'x[]y' ); expect( doc.selection.getAttribute( 'bold' ) ).to.undefined; } ); it( 'leaves selection attributes when text contains them', () => { setData( model, 'x<$text bold="true">a[foo]by', { selectionAttributes: { bold: true } } ); deleteContent( model, doc.selection ); expect( getData( model ) ).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( () => { model = new Model(); doc = model.document; doc.createRoot(); const schema = model.schema; schema.register( 'paragraph', { inheritAllFrom: '$block', allowIn: 'pparent', allowAttributes: 'align' } ); schema.register( 'heading1', { inheritAllFrom: '$block' } ); schema.register( 'image', { inheritAllFrom: '$text' } ); schema.register( 'pchild', { allowIn: 'paragraph' } ); schema.register( 'pparent', { allowIn: '$root' } ); schema.extend( '$text', { allowIn: [ 'pchild', 'pparent' ] } ); } ); 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 (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( model, 'xfo[ob]ary', { lastRangeBackward: true } ); deleteContent( model, doc.selection ); expect( getData( model ) ).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]bar', '[]bar' ); it( 'uses merge delta even if merged element is empty', () => { let mergeSpy; setData( model, 'ab[cdefgh]' ); model.change( writer => { mergeSpy = sinon.spy( writer, 'merge' ); deleteContent( model, doc.selection ); } ); expect( getData( model ) ).to.equal( 'ab[]' ); expect( mergeSpy.called ).to.be.true; } ); it( 'uses merge delta even if merged element is empty #2', () => { let mergeSpy; setData( model, 'ab[]' ); model.change( writer => { mergeSpy = sinon.spy( writer, 'merge' ); deleteContent( model, doc.selection ); } ); expect( getData( model ) ).to.equal( 'ab[]' ); expect( mergeSpy.called ).to.be.true; } ); it( 'does not try to move the second block if not needed', () => { let mergeSpy, moveSpy; setData( model, 'ab[cdef]gh' ); model.change( writer => { mergeSpy = sinon.spy( writer, 'merge' ); moveSpy = sinon.spy( writer, 'move' ); deleteContent( model, doc.selection ); } ); expect( getData( model ) ).to.equal( 'ab[]gh' ); expect( moveSpy.called ).to.be.false; expect( mergeSpy.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( model, doc.selection ); expect( getData( model ) ) .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( model, doc.selection ); expect( getData( model ) ) .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( model, doc.selection ); expect( getData( model ) ) .to.equal( 'fo[]' ); } ); } ); describe( 'with object elements', () => { beforeEach( () => { const schema = model.schema; schema.register( 'blockWidget', { isObject: true } ); schema.register( 'nestedEditable', { isLimit: true } ); schema.extend( 'blockWidget', { allowIn: '$root' } ); schema.extend( 'nestedEditable', { allowIn: 'blockWidget' } ); schema.extend( '$text', { allowIn: '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( 'filtering out', () => { beforeEach( () => { const schema = model.schema; schema.on( 'checkAttribute', ( evt, args ) => { const ctx = args[ 0 ]; const attributeName = args[ 1 ]; // Allow 'a' and 'b' on paragraph>$text. if ( ctx.matchEnd( 'paragraph $text' ) && [ 'a', 'b' ].includes( attributeName ) ) { evt.stop(); evt.return = true; } // Allow 'b' and 'c' in pchild>$text. if ( ctx.matchEnd( 'pchild $text' ) && [ 'b', 'c' ].includes( attributeName ) ) { evt.stop(); evt.return = true; } // Disallow 'c' on pchild>pchild>$text. if ( ctx.matchEnd( 'pchild pchild $text' ) && attributeName == 'c' ) { evt.stop(); evt.return = false; } }, { priority: 'high' } ); schema.extend( 'pchild', { allowIn: 'pchild' } ); } ); test( 'filters out disallowed attributes after left merge', 'xfo[oy]<$text a="1" b="1">z', 'xfo[]<$text b="1">z' ); test( 'filters out disallowed attributes from nested nodes after left merge', '' + 'x' + 'fo[o' + '' + '' + 'b]a<$text a="1" b="1">r' + 'b<$text b="1" c="1">iz' + 'y' + '', '' + 'x' + '' + 'fo[]a<$text b="1">r' + 'b<$text b="1">iz' + 'y' + '' + '' ); test( 'filters out disallowed attributes after right merge', 'fo[ox<$text b="1" c="1">y]z', 'fo[]<$text b="1">z' ); } ); } ); describe( 'in element selections scenarios', () => { beforeEach( () => { model = new Model(); doc = model.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 = model.schema; schema.register( 'image', { allowWhere: '$text' } ); schema.register( 'paragraph', { inheritAllFrom: '$block' } ); schema.register( 'heading1', { inheritAllFrom: '$block' } ); schema.register( 'blockWidget' ); schema.register( 'restrictedRoot', { isLimit: true } ); schema.extend( '$block', { allowIn: '$root' } ); schema.extend( 'blockWidget', { allowIn: '$root' } ); schema.extend( 'blockWidget', { allowIn: 'restrictedRoot' } ); } ); // See also "in simple scenarios => deletes an element". it( 'deletes two inline elements', () => { model.schema.extend( 'paragraph', { isLimit: true } ); setData( model, 'x[]z', { rootName: 'paragraphRoot' } ); deleteContent( model, doc.selection ); expect( getData( model, { rootName: 'paragraphRoot' } ) ) .to.equal( 'x[]z' ); } ); it( 'creates a paragraph when text is not allowed (paragraph selected)', () => { setData( model, 'x[yyy]z', { rootName: 'bodyRoot' } ); deleteContent( model, doc.selection ); expect( getData( model, { rootName: 'bodyRoot' } ) ) .to.equal( 'x[]z' ); } ); it( 'creates a paragraph when text is not allowed (block widget selected)', () => { setData( model, 'x[]z', { rootName: 'bodyRoot' } ); deleteContent( model, doc.selection ); expect( getData( model, { rootName: 'bodyRoot' } ) ) .to.equal( 'x[]z' ); } ); it( 'creates paragraph when text is not allowed (heading selected)', () => { setData( model, 'x[yyy]z', { rootName: 'bodyRoot' } ); deleteContent( model, doc.selection ); expect( getData( model, { rootName: 'bodyRoot' } ) ) .to.equal( 'x[]z' ); } ); it( 'creates paragraph when text is not allowed (two blocks selected)', () => { setData( model, 'x[yyyyyy]z', { rootName: 'bodyRoot' } ); deleteContent( model, doc.selection ); expect( getData( model, { rootName: 'bodyRoot' } ) ) .to.equal( 'x[]z' ); } ); it( 'creates paragraph when text is not allowed (all content selected)', () => { setData( model, '[xz]', { rootName: 'bodyRoot' } ); deleteContent( model, doc.selection ); expect( getData( model, { rootName: 'bodyRoot' } ) ) .to.equal( '[]' ); } ); it( 'does not create a paragraph when it is not allowed', () => { setData( model, '[]', { rootName: 'restrictedRoot' } ); deleteContent( model, doc.selection ); expect( getData( model, { rootName: 'restrictedRoot' } ) ) .to.equal( '[]' ); } ); } ); describe( 'integration with inline limit elements', () => { beforeEach( () => { model = new Model(); doc = model.document; doc.createRoot(); const schema = model.schema; schema.register( 'inlineLimit', { isLimit: true, allowIn: '$root' } ); schema.extend( '$text', { allowIn: [ 'inlineLimit', '$root', 'x' ] } ); schema.register( 'x', { allowIn: '$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( () => { model = new Model(); doc = model.document; doc.createRoot(); const schema = model.schema; schema.register( 'blockLimit', { isLimit: true } ); schema.extend( 'blockLimit', { allowIn: '$root' } ); schema.extend( '$block', { allowIn: 'blockLimit' } ); schema.register( 'paragraph', { inheritAllFrom: '$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' ); } ); describe( 'should leave a paragraph if the entire content was selected', () => { beforeEach( () => { model = new Model(); doc = model.document; doc.createRoot(); const schema = model.schema; schema.register( 'div', { inheritAllFrom: '$block', isLimit: true } ); schema.register( 'article', { inheritAllFrom: '$block', isLimit: true } ); schema.register( 'image', { allowWhere: '$text', isObject: true } ); schema.register( 'paragraph', { inheritAllFrom: '$block' } ); schema.register( 'heading1', { inheritAllFrom: '$block' } ); schema.register( 'heading2', { inheritAllFrom: '$block' } ); schema.extend( '$text', { allowIn: '$root' } ); schema.extend( 'image', { allowIn: '$root' } ); schema.extend( 'image', { allowIn: 'heading1' } ); schema.extend( 'heading1', { allowIn: 'div' } ); schema.extend( 'paragraph', { allowIn: 'div' } ); schema.extend( 'heading1', { allowIn: 'article' } ); schema.extend( 'heading2', { allowIn: 'article' } ); } ); test( 'but not if only one block was selected', '[xx]', '[]' ); test( 'when the entire heading and paragraph were selected', '[xxyy]', '[]' ); test( 'when the entire content was selected', '[xfooy]', '[]' ); test( 'inside the limit element when the entire heading and paragraph were inside', '

[xxyy]
', '
[]
' ); test( 'but not if schema does not accept paragraph in limit element', '
[xxyy]
', '
[]
' ); test( 'but not if selection is not containing the whole content', '[xxyy]', '[]' ); test( 'but not if only single element is selected', '[xx]', '[]' ); it( 'when root element was not added as Schema.limits works fine as well', () => { doc.createRoot( 'paragraph', 'paragraphRoot' ); setData( model, 'x[]z', { rootName: 'paragraphRoot' } ); deleteContent( model, doc.selection ); expect( getData( model, { rootName: 'paragraphRoot' } ) ) .to.equal( 'x[]z' ); } ); test( 'but not if the flag "doNotResetEntireContent" is set to true', '[]', '[]', { doNotResetEntireContent: true } ); } ); function test( title, input, output, options ) { it( title, () => { setData( model, input ); deleteContent( model, doc.selection, options ); expect( getData( model ) ).to.equal( output ); } ); } } ); } );