diff --git a/src/controller/deletecontent.js b/src/controller/deletecontent.js index c137aa4b7..17a298275 100644 --- a/src/controller/deletecontent.js +++ b/src/controller/deletecontent.js @@ -32,17 +32,24 @@ export default function deleteContent( selection, batch, options = {} ) { return; } - const selRange = selection.getFirstRange(); + // 1. Replace the entire content with paragraph. + // See: https://github.com/ckeditor/ckeditor5-engine/issues/1012#issuecomment-315017594. + if ( shouldEntireContentBeReplacedWithParagraph( batch.document.schema, selection ) ) { + replaceEntireContentWithParagraph( batch, selection ); + + return; + } + const selRange = selection.getFirstRange(); const startPos = selRange.start; const endPos = LivePosition.createFromPosition( selRange.end ); - // 1. Remove the content if there is any. + // 2. Remove the content if there is any. if ( !selRange.start.isTouching( selRange.end ) ) { batch.remove( selRange ); } - // 2. Merge elements in the right branch to the elements in the left branch. + // 3. Merge elements in the right branch to the elements in the left branch. // The only reasonable (in terms of data and selection correctness) case in which we need to do that is: // // Fo[]ar => Fo^ar @@ -56,13 +63,10 @@ export default function deleteContent( selection, batch, options = {} ) { selection.collapse( startPos ); - // 3. Autoparagraphing. + // 4. Autoparagraphing. // Check if a text is allowed in the new container. If not, try to create a new paragraph (if it's allowed here). if ( shouldAutoparagraph( batch.document, startPos ) ) { - const paragraph = new Element( 'paragraph' ); - batch.insert( startPos, paragraph ); - - selection.collapse( paragraph ); + insertParagraph( batch, startPos, selection ); } endPos.detach(); @@ -163,3 +167,61 @@ function checkCanBeMerged( leftPos, rightPos ) { return true; } + +// Returns the lowest limit element defined in `Schema.limits` for passed selection. +function getLimitElement( schema, selection ) { + let element = selection.getFirstRange().getCommonAncestor(); + + while ( !schema.limits.has( element.name ) ) { + if ( element.parent ) { + element = element.parent; + } else { + break; + } + } + + return element; +} + +function insertParagraph( batch, position, selection ) { + const paragraph = new Element( 'paragraph' ); + batch.insert( position, paragraph ); + + selection.collapse( paragraph ); +} + +function replaceEntireContentWithParagraph( batch, selection ) { + const limitElement = getLimitElement( batch.document.schema, selection ); + + batch.remove( Range.createIn( limitElement ) ); + insertParagraph( batch, Position.createAt( limitElement ), selection ); +} + +// We want to replace the entire content with a paragraph when: +// * the entire content is selected, +// * selection contains at least two elements, +// * whether the paragraph is allowed in schema in the common ancestor. +function shouldEntireContentBeReplacedWithParagraph( schema, selection ) { + const limitElement = getLimitElement( schema, selection ); + const limitStartPosition = Position.createAt( limitElement ); + const limitEndPosition = Position.createAt( limitElement, 'end' ); + + if ( + !limitStartPosition.isTouching( selection.getFirstPosition() ) || + !limitEndPosition.isTouching( selection.getLastPosition() ) + ) { + return false; + } + + const range = selection.getFirstRange(); + + if ( range.start.parent == range.end.parent ) { + return false; + } + + if ( !schema.check( { name: 'paragraph', inside: limitElement.name } ) ) { + return false; + } + + return true; +} diff --git a/src/model/schema.js b/src/model/schema.js index 9bd71be99..2a0ec0d2e 100644 --- a/src/model/schema.js +++ b/src/model/schema.js @@ -83,6 +83,8 @@ export default class Schema { this.allow( { name: '$block', inside: '$root' } ); this.allow( { name: '$inline', inside: '$block' } ); + this.limits.add( '$root' ); + // TMP! // Create an "all allowed" context in the schema for processing the pasted content. // Read: https://github.com/ckeditor/ckeditor5-engine/issues/638#issuecomment-255086588 diff --git a/tests/controller/deletecontent.js b/tests/controller/deletecontent.js index 55462ca71..2341acbd6 100644 --- a/tests/controller/deletecontent.js +++ b/tests/controller/deletecontent.js @@ -235,8 +235,8 @@ describe( 'DataController', () => { test( 'leaves just one element when all selected', - '[xfooy]', - '[]' + '[xfooy]bar', + '[]bar' ); it( 'uses remove delta instead of merge delta if merged element is empty', () => { @@ -450,6 +450,8 @@ describe( 'DataController', () => { const schema = doc.schema; + schema.limits.add( 'restrictedRoot' ); + schema.registerItem( 'image', '$inline' ); schema.registerItem( 'paragraph', '$block' ); schema.registerItem( 'heading1', '$block' ); @@ -465,6 +467,8 @@ describe( 'DataController', () => { // See also "in simple scenarios => deletes an element". it( 'deletes two inline elements', () => { + doc.schema.limits.add( 'paragraph' ); + setData( doc, 'x[]z', @@ -659,6 +663,94 @@ describe( 'DataController', () => { ); } ); + describe( 'should leave a paragraph if the entire content was selected', () => { + beforeEach( () => { + doc = new Document(); + doc.createRoot(); + + const schema = doc.schema; + + schema.registerItem( 'div', '$block' ); + schema.limits.add( 'div' ); + + schema.registerItem( 'article', '$block' ); + schema.limits.add( 'article' ); + + schema.registerItem( 'image', '$inline' ); + schema.objects.add( 'image' ); + + schema.registerItem( 'paragraph', '$block' ); + schema.registerItem( 'heading1', '$block' ); + schema.registerItem( 'heading2', '$block' ); + + schema.allow( { name: '$text', inside: '$root' } ); + + schema.allow( { name: 'image', inside: '$root' } ); + schema.allow( { name: 'image', inside: 'heading1' } ); + schema.allow( { name: 'heading1', inside: 'div' } ); + schema.allow( { name: 'paragraph', inside: 'div' } ); + schema.allow( { name: 'heading1', inside: 'article' } ); + schema.allow( { name: 'heading2', inside: '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( + doc, + 'x[]z', + { rootName: 'paragraphRoot' } + ); + + deleteContent( doc.selection, doc.batch() ); + + expect( getData( doc, { rootName: 'paragraphRoot' } ) ) + .to.equal( 'x[]z' ); + } ); + } ); + function test( title, input, output, options ) { it( title, () => { setData( doc, input ); diff --git a/tests/model/schema/schema.js b/tests/model/schema/schema.js index 32daae9e2..3b86e7788 100644 --- a/tests/model/schema/schema.js +++ b/tests/model/schema/schema.js @@ -49,6 +49,10 @@ describe( 'Schema', () => { expect( schema.limits ).to.be.instanceOf( Set ); } ); + it( 'should mark $root as a limit element', () => { + expect( schema.limits.has( '$root' ) ).to.be.true; + } ); + describe( '$clipboardHolder', () => { it( 'should allow $block', () => { expect( schema.check( { name: '$block', inside: [ '$clipboardHolder' ] } ) ).to.be.true;