From 6d6b080d4501293e41b321db02226c90ecaa21e2 Mon Sep 17 00:00:00 2001 From: Filip Sobol Date: Mon, 3 Apr 2023 17:39:15 +0200 Subject: [PATCH 1/3] Fix (engine): ignore empty selections in first and last selected blocks --- .../ckeditor5-engine/src/model/selection.ts | 85 +++++++++++++++++-- .../ckeditor5-engine/tests/model/selection.js | 46 +++++++++- .../ckeditor5-heading/tests/headingcommand.js | 4 +- .../documentliststartcommand.js | 6 +- .../documentliststylecommand.js | 6 +- .../tests/paragraphcommand.js | 4 +- 6 files changed, 131 insertions(+), 20 deletions(-) diff --git a/packages/ckeditor5-engine/src/model/selection.ts b/packages/ckeditor5-engine/src/model/selection.ts index 17bfdb3889d..ea3bc57b3d4 100644 --- a/packages/ckeditor5-engine/src/model/selection.ts +++ b/packages/ckeditor5-engine/src/model/selection.ts @@ -637,13 +637,23 @@ export default class Selection extends EmitterMixin( TypeCheckable ) { * * ``` * - * **Special case**: If a selection ends at the beginning of a block, that block is not returned as from user perspective - * this block wasn't selected. See [#984](https://github.com/ckeditor/ckeditor5-engine/issues/984) for more details. + * **Special case**: Selection ignores first and/or last blocks if nothing (from user perspective) is selected in them. * * ```xml + * // Selection ends and the beginning of the last block * [a * b - * ]c // this block will not be returned + * ]c // This block will not be returned + * + * // Selection begins at the end of the first block + * a[ // This block will not be returned + * b + * c] + * + * // Selection begings at the end of the first block and ends at the beginning of the last block + * a[ // This block will not be returned + * b + * ]c // This block will not be returned * ``` */ public* getSelectedBlocks(): IterableIterator { @@ -653,7 +663,7 @@ export default class Selection extends EmitterMixin( TypeCheckable ) { // Get start block of range in case of a collapsed range. const startBlock = getParentBlock( range.start, visited ); - if ( startBlock && isTopBlockInRange( startBlock, range ) ) { + if ( isStartBlockSelected( startBlock, range ) ) { yield startBlock as any; } @@ -667,8 +677,7 @@ export default class Selection extends EmitterMixin( TypeCheckable ) { const endBlock = getParentBlock( range.end, visited ); - // #984. Don't return the end block if the range ends right at its beginning. - if ( endBlock && !range.end.isTouching( Position._createAt( endBlock, 0 ) ) && isTopBlockInRange( endBlock, range ) ) { + if ( isEndBlockSelected( endBlock, range ) ) { yield endBlock as any; } } @@ -876,6 +885,70 @@ function isTopBlockInRange( block: Node, range: Range ) { return !isParentInRange; } +/** + * If a selection starts at the end of a block, that block is not returned as from user perspective this block wasn't selected. + * See [#11585](https://github.com/ckeditor/ckeditor5/issues/11585) for more details. + * + * ```xml + * a[ // this block will not be returned + * b + * c] + * ``` + * + * Collapsed selection is not affected by it: + * + * ```xml + * a[] // this block will be returned + * ``` + */ +function isStartBlockSelected( startBlock: Element | undefined, range: Range ): boolean { + if ( !startBlock ) { + return false; + } + + if ( range.isCollapsed || startBlock.isEmpty ) { + return true; + } + + if ( range.start.isTouching( Position._createAt( startBlock, startBlock.maxOffset ) ) ) { + return false; + } + + return isTopBlockInRange( startBlock, range ); +} + +/** + * If a selection ends at the beginning of a block, that block is not returned as from user perspective this block wasn't selected. + * See [#984](https://github.com/ckeditor/ckeditor5-engine/issues/984) for more details. + * + * ```xml + * [a + * b + * ]c // this block will not be returned + * ``` + * + * Collapsed selection is not affected by it: + * + * ```xml + * []a // this block will be returned + * ``` + */ +function isEndBlockSelected( endBlock: Element | undefined, range: Range ): boolean { + if ( !endBlock ) { + return false; + } + + if ( range.isCollapsed || endBlock.isEmpty ) { + return true; + } + + if ( range.end.isTouching( Position._createAt( endBlock, 0 ) ) ) { + return false; + } + + return isTopBlockInRange( endBlock, range ); +} + /** * Returns first ancestor block of a node. */ diff --git a/packages/ckeditor5-engine/tests/model/selection.js b/packages/ckeditor5-engine/tests/model/selection.js index 850ce93cb40..99ca3d0810a 100644 --- a/packages/ckeditor5-engine/tests/model/selection.js +++ b/packages/ckeditor5-engine/tests/model/selection.js @@ -1001,10 +1001,10 @@ describe( 'Selection', () => { expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'h#b', 'p#c' ] ); } ); - it( 'returns two blocks for a non collapsed selection (starts at block end)', () => { + it( 'returns one block for a non collapsed selection (starts at block end)', () => { setData( model, '

a

b[

c]

d

' ); - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'h#b', 'p#c' ] ); + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#c' ] ); } ); it( 'returns proper block for a multi-range selection', () => { @@ -1139,10 +1139,10 @@ describe( 'Selection', () => { expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b' ] ); } ); - it( 'returns only the first block for a non collapsed selection if only end of selection is touching a block', () => { + it( 'returns no blocks if selection spanning two blocks has no content', () => { setData( model, '

a

b[

]c

d

' ); - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'h#b' ] ); + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [] ); } ); it( 'does not return the last block if none of its content is selected (nested case)', () => { @@ -1182,6 +1182,44 @@ describe( 'Selection', () => { } ); } ); + + describe( '#11585', () => { + it( 'does not return the first block if none of its contents is selected', () => { + setData( model, '

a[

b

c]

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#b', 'p#c' ] ); + } ); + + it( 'returns the first block if at least one of its child nodes is selected', () => { + setData( model, '

a[

b

c]

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b', 'p#c' ] ); + } ); + + it( 'returns the block if it has a collapsed selection at the beginning', () => { + setData( model, '

[]a

b

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] ); + } ); + + it( 'returns the block if it has a collapsed selection at the end', () => { + setData( model, '

a[]

b

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] ); + } ); + + it( 'does not return first and last blocks if no content is selected', () => { + setData( model, '

a[

]b

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [] ); + } ); + + it( 'returns the first and last blocks if no content is selected but both blocks are empty', () => { + setData( model, '

[

]

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p', 'p' ] ); + } ); + } ); } ); describe( 'attributes interface', () => { diff --git a/packages/ckeditor5-heading/tests/headingcommand.js b/packages/ckeditor5-heading/tests/headingcommand.js index 9f9391000bc..5a201092cd7 100644 --- a/packages/ckeditor5-heading/tests/headingcommand.js +++ b/packages/ckeditor5-heading/tests/headingcommand.js @@ -225,12 +225,12 @@ describe( 'HeadingCommand', () => { } it( 'converts all elements where selection is applied', () => { - setData( model, 'foo[barbaz]' ); + setData( model, 'fo[obarbaz]' ); command.execute( { value: 'heading3' } ); expect( getData( model ) ).to.equal( - 'foo[barbaz]' + 'fo[obarbaz]' ); } ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js b/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js index 3169648b64f..5c588af7095 100644 --- a/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentliststartcommand.js @@ -142,9 +142,9 @@ describe( 'DocumentListStartCommand', () => { it( 'should return the value of `listStart` attribute from a list where the selection starts (selection over nested list)', () => { setData( model, modelList( ` - # 1. {start:2} - # 1.1.[ {start:3} - # 2.] + # 1. First {start:2} + # 1.1. [Second {start:3} + # 2. Third] ` ) ); expect( listStartCommand.value ).to.equal( 3 ); diff --git a/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js b/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js index b6910512128..6fac94e7684 100644 --- a/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js +++ b/packages/ckeditor5-list/tests/documentlistproperties/documentliststylecommand.js @@ -127,9 +127,9 @@ describe( 'DocumentListStyleCommand', () => { it( 'should return the value of `listStyle` attribute from a list where the selection starts (selection over nested list)', () => { setData( model, modelList( ` - * 1. {style:square} - * 1.1.[ {style:disc} - * 2.] + * 1. First {style:square} + * 1.1. [Second {style:disc} + * 2. Third] ` ) ); expect( listStyleCommand.value ).to.equal( 'disc' ); diff --git a/packages/ckeditor5-paragraph/tests/paragraphcommand.js b/packages/ckeditor5-paragraph/tests/paragraphcommand.js index ebaaa6897c1..83fe2c58af6 100644 --- a/packages/ckeditor5-paragraph/tests/paragraphcommand.js +++ b/packages/ckeditor5-paragraph/tests/paragraphcommand.js @@ -219,11 +219,11 @@ describe( 'ParagraphCommand', () => { it( 'converts all elements where selection is applied', () => { schema.register( 'heading2', { inheritAllFrom: '$block' } ); - setData( model, 'foo[barbaz]' ); + setData( model, 'fo[obarbaz]' ); command.execute(); expect( getData( model ) ).to.equal( - 'foo[barbaz]' + 'fo[obarbaz]' ); } ); From 2b36cadb67c9421217fd745801a7da2a349a28e7 Mon Sep 17 00:00:00 2001 From: Filip Sobol Date: Wed, 5 Apr 2023 10:27:07 +0200 Subject: [PATCH 2/3] Correct the case of the comment --- packages/ckeditor5-engine/src/model/selection.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ckeditor5-engine/src/model/selection.ts b/packages/ckeditor5-engine/src/model/selection.ts index ea3bc57b3d4..e533f5ea3b0 100644 --- a/packages/ckeditor5-engine/src/model/selection.ts +++ b/packages/ckeditor5-engine/src/model/selection.ts @@ -640,17 +640,17 @@ export default class Selection extends EmitterMixin( TypeCheckable ) { * **Special case**: Selection ignores first and/or last blocks if nothing (from user perspective) is selected in them. * * ```xml - * // Selection ends and the beginning of the last block + * // Selection ends and the beginning of the last block. * [a * b * ]c // This block will not be returned * - * // Selection begins at the end of the first block + * // Selection begins at the end of the first block. * a[ // This block will not be returned * b * c] * - * // Selection begings at the end of the first block and ends at the beginning of the last block + * // Selection begings at the end of the first block and ends at the beginning of the last block. * a[ // This block will not be returned * b * ]c // This block will not be returned @@ -890,7 +890,7 @@ function isTopBlockInRange( block: Node, range: Range ) { * See [#11585](https://github.com/ckeditor/ckeditor5/issues/11585) for more details. * * ```xml - * a[ // this block will not be returned + * a[ // This block will not be returned * b * c] * ``` @@ -898,7 +898,7 @@ function isTopBlockInRange( block: Node, range: Range ) { * Collapsed selection is not affected by it: * * ```xml - * a[] // this block will be returned + * a[] // This block will be returned * ``` */ function isStartBlockSelected( startBlock: Element | undefined, range: Range ): boolean { From 2b7bc95ad5052729a74d00b8bafdd988be8e88fb Mon Sep 17 00:00:00 2001 From: Filip Sobol Date: Thu, 6 Apr 2023 12:48:59 +0200 Subject: [PATCH 3/3] Internal: Move ticket related tests to separate files --- .../tests/model/_utils/utils.js | 22 ++++ .../ckeditor5-engine/tests/model/selection.js | 108 +---------------- .../ckeditor5-engine/tests/tickets/11585.js | 100 ++++++++++++++++ .../ckeditor5-engine/tests/tickets/984.js | 113 ++++++++++++++++++ 4 files changed, 237 insertions(+), 106 deletions(-) create mode 100644 packages/ckeditor5-engine/tests/tickets/11585.js create mode 100644 packages/ckeditor5-engine/tests/tickets/984.js diff --git a/packages/ckeditor5-engine/tests/model/_utils/utils.js b/packages/ckeditor5-engine/tests/model/_utils/utils.js index 6374cb6b7e0..1265a0ecf3c 100644 --- a/packages/ckeditor5-engine/tests/model/_utils/utils.js +++ b/packages/ckeditor5-engine/tests/model/_utils/utils.js @@ -78,6 +78,28 @@ export function getText( element ) { return text; } +/** + * Maps all elements to names. If element contains child text node it will be appended to name with '#'. + * + * @param {Array.} element Array of Element from which text will be returned. + * @returns {String} Text contents of the element. + */ +export function stringifyBlocks( elements ) { + return Array.from( elements ).map( el => { + const name = el.name; + + let innerText = ''; + + for ( const child of el.getChildren() ) { + if ( child.is( '$text' ) ) { + innerText += child.data; + } + } + + return innerText.length ? `${ name }#${ innerText }` : name; + } ); +} + /** * Creates a range on given {@link engine.model.Element element} only. The range starts directly before that element * and ends before the first child of that element. diff --git a/packages/ckeditor5-engine/tests/model/selection.js b/packages/ckeditor5-engine/tests/model/selection.js index 99ca3d0810a..9c451394cb0 100644 --- a/packages/ckeditor5-engine/tests/model/selection.js +++ b/packages/ckeditor5-engine/tests/model/selection.js @@ -16,6 +16,8 @@ import { parse, setData } from '../../src/dev-utils/model'; import Schema from '../../src/model/schema'; import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; +import { stringifyBlocks } from './_utils/utils'; + describe( 'Selection', () => { let model, doc, root, selection, liveRange, range, range1, range2, range3; @@ -1131,95 +1133,6 @@ describe( 'Selection', () => { expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'blk', 'p#bar' ] ); } ); - - describe( '#984', () => { - it( 'does not return the last block if none of its content is selected', () => { - setData( model, '

[a

b

]c

' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b' ] ); - } ); - - it( 'returns no blocks if selection spanning two blocks has no content', () => { - setData( model, '

a

b[

]c

d

' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [] ); - } ); - - it( 'does not return the last block if none of its content is selected (nested case)', () => { - setData( model, '

[a

]b' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] ); - } ); - - // Like a super edge case, we can live with this behavior as I don't even know what we could expect here - // since only the innermost block is considerd a block to return (so the b... needs to be ignored). - it( 'does not return the last block if none of its content is selected (nested case, wrapper with a content)', () => { - setData( model, '

[a

b]c' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] ); - } ); - - it( 'returns the last block if at least one of its child nodes is selected', () => { - setData( model, '

[a

b

]c

' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b', 'p#c' ] ); - } ); - - // I needed these last 2 cases to justify the use of isTouching() instead of simple `offset == 0` check. - it( 'returns the last block if at least one of its child nodes is selected (end in an inline element)', () => { - setData( model, '

[a

b

x]c

' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b', 'p#c' ] ); - } ); - - it( - 'does not return the last block if at least one of its child nodes is selected ' + - '(end in an inline element, no content selected)', - () => { - setData( model, '

[a

b

]xc

' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b' ] ); - } - ); - } ); - - describe( '#11585', () => { - it( 'does not return the first block if none of its contents is selected', () => { - setData( model, '

a[

b

c]

' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#b', 'p#c' ] ); - } ); - - it( 'returns the first block if at least one of its child nodes is selected', () => { - setData( model, '

a[

b

c]

' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b', 'p#c' ] ); - } ); - - it( 'returns the block if it has a collapsed selection at the beginning', () => { - setData( model, '

[]a

b

' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] ); - } ); - - it( 'returns the block if it has a collapsed selection at the end', () => { - setData( model, '

a[]

b

' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] ); - } ); - - it( 'does not return first and last blocks if no content is selected', () => { - setData( model, '

a[

]b

' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [] ); - } ); - - it( 'returns the first and last blocks if no content is selected but both blocks are empty', () => { - setData( model, '

[

]

' ); - - expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p', 'p' ] ); - } ); - } ); } ); describe( 'attributes interface', () => { @@ -1395,21 +1308,4 @@ describe( 'Selection', () => { expect( doc.selection.containsEntireContent() ).to.equal( false ); } ); } ); - - // Map all elements to names. If element contains child text node it will be appended to name with '#'. - function stringifyBlocks( elements ) { - return Array.from( elements ).map( el => { - const name = el.name; - - let innerText = ''; - - for ( const child of el.getChildren() ) { - if ( child.is( '$text' ) ) { - innerText += child.data; - } - } - - return innerText.length ? `${ name }#${ innerText }` : name; - } ); - } } ); diff --git a/packages/ckeditor5-engine/tests/tickets/11585.js b/packages/ckeditor5-engine/tests/tickets/11585.js new file mode 100644 index 00000000000..078a387ac24 --- /dev/null +++ b/packages/ckeditor5-engine/tests/tickets/11585.js @@ -0,0 +1,100 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import Model from '../../src/model/model'; +import Element from '../../src/model/element'; +import Text from '../../src/model/text'; +import Position from '../../src/model/position'; +import LiveRange from '../../src/model/liverange'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { setData } from '../../src/dev-utils/model'; + +import { stringifyBlocks } from '../model/_utils/utils'; + +describe( '#11585', () => { + let model, doc, root, liveRange; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + model = new Model(); + doc = model.document; + root = doc.createRoot(); + root._appendChild( [ + new Element( 'p' ), + new Element( 'p' ), + new Element( 'p', [], new Text( 'foobar' ) ), + new Element( 'p' ), + new Element( 'p' ), + new Element( 'p' ), + new Element( 'p', [], new Text( 'foobar' ) ) + ] ); + + liveRange = new LiveRange( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + model.schema.register( 'h', { inheritAllFrom: '$block' } ); + + model.schema.register( 'blockquote' ); + model.schema.extend( 'blockquote', { allowIn: '$root' } ); + model.schema.extend( '$block', { allowIn: 'blockquote' } ); + + model.schema.register( 'imageBlock', { + allowIn: [ '$root', '$block' ], + allowChildren: '$text' + } ); + + // Special block which can contain another blocks. + model.schema.register( 'nestedBlock', { inheritAllFrom: '$block' } ); + model.schema.extend( 'nestedBlock', { allowIn: '$block' } ); + + model.schema.register( 'table', { isBlock: true, isLimit: true, isObject: true, allowIn: '$root' } ); + model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } ); + model.schema.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: true } ); + + model.schema.extend( 'p', { allowIn: 'tableCell' } ); + } ); + + afterEach( () => { + model.destroy(); + liveRange.detach(); + } ); + + it( 'does not return the first block if none of its contents is selected', () => { + setData( model, '

a[

b

c]

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#b', 'p#c' ] ); + } ); + + it( 'returns the first block if at least one of its child nodes is selected', () => { + setData( model, '

a[

b

c]

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b', 'p#c' ] ); + } ); + + it( 'returns the block if it has a collapsed selection at the beginning', () => { + setData( model, '

[]a

b

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] ); + } ); + + it( 'returns the block if it has a collapsed selection at the end', () => { + setData( model, '

a[]

b

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] ); + } ); + + it( 'does not return first and last blocks if no content is selected', () => { + setData( model, '

a[

]b

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [] ); + } ); + + it( 'returns the first and last blocks if no content is selected but both blocks are empty', () => { + setData( model, '

[

]

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p', 'p' ] ); + } ); +} ); diff --git a/packages/ckeditor5-engine/tests/tickets/984.js b/packages/ckeditor5-engine/tests/tickets/984.js new file mode 100644 index 00000000000..cfafad7dd78 --- /dev/null +++ b/packages/ckeditor5-engine/tests/tickets/984.js @@ -0,0 +1,113 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import Model from '../../src/model/model'; +import Element from '../../src/model/element'; +import Text from '../../src/model/text'; +import Position from '../../src/model/position'; +import LiveRange from '../../src/model/liverange'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { setData } from '../../src/dev-utils/model'; + +import { stringifyBlocks } from '../model/_utils/utils'; + +describe( '#984', () => { + let model, doc, root, liveRange; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + model = new Model(); + doc = model.document; + root = doc.createRoot(); + root._appendChild( [ + new Element( 'p' ), + new Element( 'p' ), + new Element( 'p', [], new Text( 'foobar' ) ), + new Element( 'p' ), + new Element( 'p' ), + new Element( 'p' ), + new Element( 'p', [], new Text( 'foobar' ) ) + ] ); + + liveRange = new LiveRange( new Position( root, [ 0 ] ), new Position( root, [ 1 ] ) ); + + model.schema.register( 'p', { inheritAllFrom: '$block' } ); + model.schema.register( 'h', { inheritAllFrom: '$block' } ); + + model.schema.register( 'blockquote' ); + model.schema.extend( 'blockquote', { allowIn: '$root' } ); + model.schema.extend( '$block', { allowIn: 'blockquote' } ); + + model.schema.register( 'imageBlock', { + allowIn: [ '$root', '$block' ], + allowChildren: '$text' + } ); + + // Special block which can contain another blocks. + model.schema.register( 'nestedBlock', { inheritAllFrom: '$block' } ); + model.schema.extend( 'nestedBlock', { allowIn: '$block' } ); + + model.schema.register( 'table', { isBlock: true, isLimit: true, isObject: true, allowIn: '$root' } ); + model.schema.register( 'tableRow', { allowIn: 'table', isLimit: true } ); + model.schema.register( 'tableCell', { allowIn: 'tableRow', isLimit: true, isSelectable: true } ); + + model.schema.extend( 'p', { allowIn: 'tableCell' } ); + } ); + + afterEach( () => { + model.destroy(); + liveRange.detach(); + } ); + + it( 'does not return the last block if none of its content is selected', () => { + setData( model, '

[a

b

]c

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b' ] ); + } ); + + it( 'returns no blocks if selection spanning two blocks has no content', () => { + setData( model, '

a

b[

]c

d

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [] ); + } ); + + it( 'does not return the last block if none of its content is selected (nested case)', () => { + setData( model, '

[a

]b' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] ); + } ); + + // Like a super edge case, we can live with this behavior as I don't even know what we could expect here + // since only the innermost block is considerd a block to return (so the b... needs to be ignored). + it( 'does not return the last block if none of its content is selected (nested case, wrapper with a content)', () => { + setData( model, '

[a

b]c' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a' ] ); + } ); + + it( 'returns the last block if at least one of its child nodes is selected', () => { + setData( model, '

[a

b

]c

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b', 'p#c' ] ); + } ); + + // I needed these last 2 cases to justify the use of isTouching() instead of simple `offset == 0` check. + it( 'returns the last block if at least one of its child nodes is selected (end in an inline element)', () => { + setData( model, '

[a

b

x]c

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b', 'p#c' ] ); + } ); + + it( + 'does not return the last block if at least one of its child nodes is selected ' + + '(end in an inline element, no content selected)', + () => { + setData( model, '

[a

b

]xc

' ); + + expect( stringifyBlocks( doc.selection.getSelectedBlocks() ) ).to.deep.equal( [ 'p#a', 'p#b' ] ); + } + ); +} );