diff --git a/packages/ckeditor5-clipboard/src/dragdropexperimental.ts b/packages/ckeditor5-clipboard/src/dragdropexperimental.ts index 5e577207c0d..621ae9d09fc 100644 --- a/packages/ckeditor5-clipboard/src/dragdropexperimental.ts +++ b/packages/ckeditor5-clipboard/src/dragdropexperimental.ts @@ -14,6 +14,9 @@ import { MouseObserver, type DataTransfer, type Element, + type Model, + type Range, + type Position, type ViewDocumentMouseDownEvent, type ViewDocumentMouseUpEvent, type ViewElement, @@ -580,41 +583,39 @@ export default class DragDropExperimental extends Plugin { widgetToolbarRepository.forceDisabled( 'dragDrop' ); } + + return; } // If this was not a widget we should check if we need to drag some text content. - else if ( !selection.isCollapsed || ( selection.getFirstPosition()!.parent as Element ).isEmpty ) { - const blocks = Array.from( selection.getSelectedBlocks() ); - - if ( blocks.length > 1 ) { - this._draggedRange = LiveRange.fromRange( model.createRange( - model.createPositionBefore( blocks[ 0 ] ), - model.createPositionAfter( blocks[ blocks.length - 1 ] ) - ) ); - - model.change( writer => writer.setSelection( this._draggedRange!.toRange() ) ); - this._blockMode = true; - // TODO block mode for dragging from outside editor? or inline? or both? - } - else if ( blocks.length == 1 ) { - const draggedRange = selection.getFirstRange()!; - const blockRange = model.createRange( - model.createPositionBefore( blocks[ 0 ] ), - model.createPositionAfter( blocks[ 0 ] ) - ); - - if ( - draggedRange.start.isTouching( blockRange.start ) && - draggedRange.end.isTouching( blockRange.end ) - ) { - this._draggedRange = LiveRange.fromRange( blockRange ); - this._blockMode = true; - } else { - this._draggedRange = LiveRange.fromRange( selection.getFirstRange()! ); - this._blockMode = false; - } - } + if ( selection.isCollapsed && !( selection.getFirstPosition()!.parent as Element ).isEmpty ) { + return; + } + + const blocks = Array.from( selection.getSelectedBlocks() ); + const draggedRange = selection.getFirstRange()!; + + if ( blocks.length == 0 ) { + this._draggedRange = LiveRange.fromRange( draggedRange ); + + return; + } + + const blockRange = getRangeIncludingFullySelectedParents( model, blocks ); + + if ( blocks.length > 1 ) { + this._draggedRange = LiveRange.fromRange( blockRange ); + this._blockMode = true; + // TODO block mode for dragging from outside editor? or inline? or both? + } else if ( blocks.length == 1 ) { + const touchesBlockEdges = draggedRange.start.isTouching( blockRange.start ) && + draggedRange.end.isTouching( blockRange.end ); + + this._draggedRange = LiveRange.fromRange( touchesBlockEdges ? blockRange : draggedRange ); + this._blockMode = touchesBlockEdges; } + + model.change( writer => writer.setSelection( this._draggedRange!.toRange() ) ); } /** @@ -690,3 +691,43 @@ function findDraggableWidget( target: ViewElement ): ViewElement | null { return null; } + +/** + * Recursively checks if common parent of provided elements doesn't have any other children. If that's the case, + * it returns range including this parent. Otherwise, it returns only the range from first to last element. + * + * Example: + * + *
+ * [Test 1 + * Test 2 + * Test 3] + *
+ * + * Because all elements inside the `blockQuote` are selected, the range is extended to include the `blockQuote` too. + * If only first and second paragraphs would be selected, the range would not include it. + */ +function getRangeIncludingFullySelectedParents( model: Model, elements: Array ): Range { + const firstElement = elements[ 0 ]; + const lastElement = elements[ elements.length - 1 ]; + const parent = firstElement.getCommonAncestor( lastElement ); + const startPosition: Position = model.createPositionBefore( firstElement ); + const endPosition: Position = model.createPositionAfter( lastElement ); + + if ( + parent && + parent.is( 'element' ) && + !model.schema.isLimit( parent ) + ) { + const parentRange = model.createRangeOn( parent ); + const touchesStart = startPosition.isTouching( parentRange.start ); + const touchesEnd = endPosition.isTouching( parentRange.end ); + + if ( touchesStart && touchesEnd ) { + // Selection includes all elements in the parent. + return getRangeIncludingFullySelectedParents( model, [ parent ] ); + } + } + + return model.createRange( startPosition, endPosition ); +} diff --git a/packages/ckeditor5-clipboard/tests/dragdropexperimental.js b/packages/ckeditor5-clipboard/tests/dragdropexperimental.js index dff25f5e3ec..965b10972e8 100644 --- a/packages/ckeditor5-clipboard/tests/dragdropexperimental.js +++ b/packages/ckeditor5-clipboard/tests/dragdropexperimental.js @@ -19,6 +19,7 @@ import HorizontalLine from '@ckeditor/ckeditor5-horizontal-line/src/horizontalli import ShiftEnter from '@ckeditor/ckeditor5-enter/src/shiftenter'; import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote'; import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold'; +import { Image, ImageCaption } from '@ckeditor/ckeditor5-image'; import env from '@ckeditor/ckeditor5-utils/src/env'; import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; @@ -64,7 +65,18 @@ describe( 'Drag and Drop experimental', () => { document.body.appendChild( editorElement ); editor = await ClassicTestEditor.create( editorElement, { - plugins: [ DragDropExperimental, PastePlainText, Paragraph, Table, HorizontalLine, ShiftEnter, BlockQuote, Bold ] + plugins: [ + DragDropExperimental, + PastePlainText, + Paragraph, + Table, + HorizontalLine, + ShiftEnter, + BlockQuote, + Bold, + Image, + ImageCaption + ] } ); model = editor.model; @@ -494,10 +506,8 @@ describe( 'Drag and Drop experimental', () => { } ); it( 'should not remove dragged range if insert into drop target was not allowed', () => { - editor.model.schema.register( 'caption', { - allowIn: '$root', - allowContentOf: '$block', - isObject: true + editor.model.schema.extend( 'caption', { + allowIn: '$root' } ); editor.conversion.elementToElement( { @@ -1246,6 +1256,44 @@ describe( 'Drag and Drop experimental', () => { ); } ); + it( 'should start dragging text from caption to paragraph', () => { + setModelData( model, trim` + + [World] + + Hello + ` ); + + const dataTransferMock = createDataTransfer(); + const viewElement = viewDocument.getRoot().getChild( 1 ); + const positionAfterHr = model.createPositionAt( root.getChild( 1 ), 'after' ); + + viewDocument.fire( 'dragstart', { + domTarget: domConverter.mapViewToDom( viewElement ), + target: viewElement, + domEvent: {}, + dataTransfer: dataTransferMock, + stopPropagation: () => {} + } ); + + expect( dataTransferMock.getData( 'text/html' ) ).to.equal( 'World' ); + + fireDragging( dataTransferMock, positionAfterHr ); + expectDraggingMarker( positionAfterHr ); + + fireDrop( + dataTransferMock, + model.createPositionAt( root.getChild( 1 ), 5 ) + ); + + expect( getModelData( model ) ).to.equal( trim` + + + + HelloWorld[] + ` ); + } ); + it( 'should not drag parent paragraph when only portion of content is selected', () => { setModelData( model, 'foobar' + @@ -1910,6 +1958,132 @@ describe( 'Drag and Drop experimental', () => { expect( data.targetRanges[ 0 ].isEqual( view.createRangeOn( viewDocument.getRoot().getChild( 1 ) ) ) ).to.be.true; } ); } ); + + describe( 'extending selection range when all parent elements are selected', () => { + it( 'extends flat selection', () => { + setModelData( model, trim` +
+ [one + two + three] +
+ + ` ); + + const dataTransferMock = createDataTransfer(); + const positionAfterHr = model.createPositionAt( root.getChild( 1 ), 'after' ); + + fireDragStart( dataTransferMock ); + expectDragStarted( dataTransferMock, trim` +
+

one

+

two

+

three

+
+ ` ); + + fireDragging( dataTransferMock, positionAfterHr ); + expectDraggingMarker( positionAfterHr ); + } ); + + it( 'extends nested selection', () => { + setModelData( model, trim` +
+ [one +
+ two + three + four +
+ five] +
+ + ` ); + + const dataTransferMock = createDataTransfer(); + const positionAfterHr = model.createPositionAt( root.getChild( 1 ), 'after' ); + + fireDragStart( dataTransferMock ); + expectDragStarted( dataTransferMock, trim` +
+

one

+
+

two

+

three

+

four

+
+

five

+
+ ` ); + + fireDragging( dataTransferMock, positionAfterHr ); + expectDraggingMarker( positionAfterHr ); + } ); + + it( 'extends selection when it starts at different level than it ends', () => { + setModelData( model, trim` +
+
+ [one + two + three +
+ four] +
+ + ` ); + + const dataTransferMock = createDataTransfer(); + const positionAfterHr = model.createPositionAt( root.getChild( 1 ), 'after' ); + + fireDragStart( dataTransferMock ); + expectDragStarted( dataTransferMock, trim` +
+
+

one

+

two

+

three

+
+

four

+
+ ` ); + + fireDragging( dataTransferMock, positionAfterHr ); + expectDraggingMarker( positionAfterHr ); + } ); + + it( 'extends selection when it ends at different level than it starts', () => { + setModelData( model, trim` +
+ [one +
+ two + three + four] +
+
+ + ` ); + + const dataTransferMock = createDataTransfer(); + const positionAfterHr = model.createPositionAt( root.getChild( 1 ), 'after' ); + + fireDragStart( dataTransferMock ); + expectDragStarted( dataTransferMock, trim` +
+

one

+
+

two

+

three

+

four

+
+
+ ` ); + + fireDragging( dataTransferMock, positionAfterHr ); + expectDraggingMarker( positionAfterHr ); + } ); + } ); } ); describe( 'integration with the WidgetToolbarRepository plugin', () => { @@ -2237,4 +2411,11 @@ describe( 'Drag and Drop experimental', () => { clientY: y + extraOffset }; } + + function trim( strings ) { + return strings + .join( '' ) + .trim() + .replace( />\s+<' ); + } } );