/** * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ import Model from '../../../src/model/model'; import Position from '../../../src/model/position'; import Range from '../../../src/model/range'; import Selection from '../../../src/model/selection'; import Element from '../../../src/model/element'; import deleteContent from '../../../src/model/utils/deletecontent'; import { setData, getData } from '../../../src/dev-utils/model'; import { stringify } from '../../../src/dev-utils/view'; 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.operations ).to.length( 1 ); } ); } ); it( 'should not do anything if the selection is already in graveyard', () => { model = new Model(); doc = model.document; const gy = model.document.graveyard; gy._appendChild( new Element( 'paragraph' ) ); const baseVersion = model.document.baseVersion; model.change( writer => { sinon.spy( writer, 'remove' ); const selection = writer.createSelection( writer.createRangeIn( gy ) ); deleteContent( model, selection ); expect( writer.remove.called ).to.be.false; } ); expect( model.document.baseVersion ).to.equal( baseVersion ); } ); 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' } ); } ); it( 'should be able to delete content at custom selection', () => { setData( model, 'a[]bcd' ); const range = new Range( new Position( doc.getRoot(), [ 2 ] ), new Position( doc.getRoot(), [ 3 ] ) ); const selection = new Selection( [ range ] ); model.change( () => { deleteContent( model, selection ); expect( getData( model ) ).to.equal( 'a[]bd' ); } ); } ); 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', allowIn: 'pparent' } ); schema.register( 'image', { inheritAllFrom: '$text' } ); schema.register( 'pchild', { allowIn: 'paragraph' } ); schema.register( 'pparent', { allowIn: '$root' } ); schema.register( 'hchild', { allowIn: 'heading1' } ); schema.register( 'widget', { isObject: true, allowWhere: '$text', isInline: true } ); schema.extend( '$text', { allowIn: [ 'pchild', 'pparent', 'hchild' ] } ); } ); 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( 'merges first element into the second element (it would become empty but second element would not) (same name)', 'x[foob]ary', 'x[]ary' ); test( 'do not remove end block if selection ends at start position of it', 'x[foo]bary', 'x[]bary' ); test( 'removes empty element (merges it into second element)', 'x[]bary', 'x[]bary' ); test( 'treats inline widget elements as content so parent element is not considered as empty after merging (same name)', 'x[foob]ary', 'x[]ary' ); test( 'does not merge second element into the first one (same name, !option.merge)', 'xfo[ob]ary', 'xfo[]ary', { leaveUnmerged: true } ); test( 'does not remove first empty element when it\'s empty but second element is not empty (same name, !option.merge)', 'x[foob]ary', 'x[]ary', { leaveUnmerged: true } ); test( 'merges second element into the first one (different name)', 'xfo[ob]ary', 'xfo[]ary' ); test( 'removes first element when it\'s empty but second element is not empty (different name)', 'x[foob]ary', 'x[]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( 'removes 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' ); test( 'leaves just one (last) element when all selected (first block would become empty) (different name)', '[xfooy]bar', '[]bar' ); test( 'leaves just one (first) element when all selected (first block would not become empty) (different name)', 'foo[xbary]', 'foo[]' ); it( 'uses merge operation 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 operation 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( 'uses "merge" operation (from OT) if first element is empty (because of content delete) and last is not', () => { let mergeSpy; setData( model, '[abcdef]gh' ); model.change( writer => { mergeSpy = sinon.spy( writer, 'merge' ); deleteContent( model, doc.selection ); } ); expect( getData( model ) ).to.equal( '[]gh' ); expect( mergeSpy.called ).to.be.true; } ); it( 'uses merge operation if first element is empty and last is not', () => { let mergeSpy; setData( model, '[ef]gh' ); model.change( writer => { mergeSpy = sinon.spy( writer, 'merge' ); deleteContent( model, doc.selection ); } ); expect( getData( model ) ).to.equal( '[]gh' ); 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' ); test( 'merges block element to the right (with nested element)', '[foob]ar', '[]ar' ); test( 'does not remove block element with nested element and object', '[foob]ar', '[]ar' ); test( 'merges nested elements', 'x[foob]ar', 'x[]ar' ); test( 'merges nested elements on multiple levels', 'x[foob]arabc', 'x[]arabc' ); test( 'merges nested elements to the right if left side element would become empty', '[foob]ar', '[]ar' ); test( 'merges to the left if first element contains object (considers it as a content of that element)', '[foob]ar', '[]ar' ); test( 'merges elements when left end deep nested', 'xfo[ob]aryx', 'xfo[]aryx' ); test( 'merges nested elements to the right (on multiple levels) if left side element would become empty', '[foob]arabc', '[]arabc' ); test( 'merges to the right element when left end deep nested and will be empty', '[foob]arx', '[]arx' ); test( 'merges elements when right end deep nested', 'xfo[ob]arx', 'xfo[]arx' ); test( 'removes element when right end deep nested but left end would be empty', 'x[foob]ar', 'x[]ar' ); test( 'merges elements when right end deep nested (in an empty container)', 'fo[obar]', 'fo[]' ); test( 'removes elements when left end deep nested (in an empty container)', '[foob]arx', '[]arx' ); describe( 'with 3rd level of nesting', () => { test( 'merges elements when deep nested (same name)', 'xxfo[o' + 'b]aryy', 'xxfo[]aryy' ); test( 'removes elements when deep nested (same name)', '[foo' + 'b]aryy', '[]aryy' ); test( 'removes elements up to common ancestor when deep nested (same name)', '' + '[foo' + 'b]aryy' + '', '[]aryy' ); test( 'merges elements when deep nested (different name)', 'xxfo[o' + 'b]aryy', 'xxfo[]aryy' ); test( 'removes elements when deep nested (different name)', '[foo' + 'b]aryy', '[]aryy' ); test( 'merges elements up to common ancestor when deep nested (different names)', '' + 'fo[o' + 'b]ar' + '', 'fo[]ar' ); test( 'removes elements up to common ancestor when deep nested (different names)', '' + '[foo' + 'b]aryy' + '', '[]aryy' ); } ); describe( 'with 3rd level of nesting o the left end', () => { test( 'merges elements', 'xfooba[r' + 'b]om', 'xfooba[]om' ); test( 'merges elements (different names)', 'xfooba[r' + 'b]om', 'xfooba[]om' ); test( 'removes elements', '[bar' + 'b]om', '[]om' ); test( 'removes elements up to common ancestor (different names)', '' + '[foo' + 'b]ary' + '', '[]ary' ); } ); describe( 'with 3rd level of nesting o the right end', () => { test( 'merges elements', 'b[om' + 'ba]r', 'b[]r' ); test( 'merges elements (different names)', 'bo[m' + 'b]ar', 'bo[]ar' ); test( 'merges elements (different names, reversed)', 'bo[m' + 'b]ar', 'bo[]ar' ); test( 'removes elements', '[bom' + 'b]ar', '[]ar' ); test( 'removes elements up to common ancestor (different names)', '' + '[bary' + 'f]oo' + '', '[]oo' ); } ); } ); 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.addAttributeCheck( ( ctx, attributeName ) => { // Disallow 'c' on pchild>pchild>$text. if ( ctx.endsWith( 'pchild pchild $text' ) && attributeName == 'c' ) { return false; } // Allow 'a' and 'b' on paragraph>$text. if ( ctx.endsWith( 'paragraph $text' ) && [ 'a', 'b' ].includes( attributeName ) ) { return true; } // Allow 'b' and 'c' in pchild>$text. if ( ctx.endsWith( 'pchild $text' ) && [ 'b', 'c' ].includes( attributeName ) ) { return true; } } ); 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', { isLimit: true } ); 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( 'moves the (custom) selection to the nearest paragraph', () => { setData( model, '[x]yyyz', { rootName: 'bodyRoot' } ); const root = doc.getRoot( 'bodyRoot' ); // [yyy] const selection = new Selection( [ new Range( new Position( root, [ 1 ] ), new Position( root, [ 2 ] ) ) ] ); deleteContent( model, selection ); expect( getData( model, { rootName: 'bodyRoot' } ) ) .to.equal( '[x]z' ); expect( stringify( root, selection ) ) .to.equal( '<$root>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, 'xyyyz', { rootName: 'bodyRoot' } ); // [yyy] const range = new Range( new Position( doc.getRoot( 'bodyRoot' ), [ 1 ] ), new Position( doc.getRoot( 'bodyRoot' ), [ 2 ] ) ); deleteContent( model, new Selection( range ) ); expect( getData( model, { rootName: 'bodyRoot', withoutSelection: true } ) ) .to.equal( 'xz' ); } ); it( 'creates paragraph when text is not allowed (two blocks selected)', () => { setData( model, 'xyyyyyyz', { rootName: 'bodyRoot' } ); // [yyyyyy] const range = new Range( new Position( doc.getRoot( 'bodyRoot' ), [ 1 ] ), new Position( doc.getRoot( 'bodyRoot' ), [ 3 ] ) ); deleteContent( model, new Selection( range ) ); expect( getData( model, { rootName: 'bodyRoot', withoutSelection: true } ) ) .to.equal( 'xz' ); } ); 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( '[]' ); } ); it( 'does not create a paragraph when doNotAutoparagraph option is set to true', () => { setData( model, 'x[]z', { rootName: 'bodyRoot' } ); deleteContent( model, doc.selection, { doNotAutoparagraph: true } ); expect( getData( model, { rootName: 'bodyRoot' } ) ) .to.equal( 'x[]z' ); } ); it( 'does not create a paragraph when after deletion there is no valid selection range (empty root)', () => { setData( model, '[]', { rootName: 'bodyRoot' } ); deleteContent( model, doc.selection, { doNotAutoparagraph: true } ); expect( getData( model, { rootName: 'bodyRoot' } ) ) .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' } ); schema.register( 'blockQuote', { allowWhere: '$block', allowContentOf: '$root' } ); } ); 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' ); // See: https://github.com/ckeditor/ckeditor5/issues/1265. it( 'should proper merge two elements which are inside limit element', () => { setData( model, '' + '

' + 'Foo' + '
' + '[]Bar' + '' ); model.modifySelection( doc.selection, { direction: 'backward' } ); deleteContent( model, doc.selection ); expect( getData( model ) ).to.equal( '' + '
' + 'Foo[]Bar' + '
' + '
' ); } ); it( 'should proper merge elements which are inside limit element (nested elements)', () => { setData( model, '
' + '' + '
' + 'Foo.' + '
' + 'Foo' + '
' + '
' + '[]Bar' + '
' + '
' ); model.modifySelection( doc.selection, { direction: 'backward' } ); deleteContent( model, doc.selection ); expect( getData( model ) ).to.equal( '
' + '' + '
' + 'Foo.' + '
' + 'Foo[]Bar' + '
' + '
' + '
' + '
' ); } ); } ); 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 work 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, () => { model.enqueueChange( 'transparent', () => { setData( model, input ); deleteContent( model, doc.selection, options ); } ); expect( getData( model ) ).to.equal( output ); } ); } } ); } );