diff --git a/packages/ckeditor5-clipboard/src/clipboard.js b/packages/ckeditor5-clipboard/src/clipboard.js index fd72d74f9db..8e1bd943fe3 100644 --- a/packages/ckeditor5-clipboard/src/clipboard.js +++ b/packages/ckeditor5-clipboard/src/clipboard.js @@ -115,15 +115,25 @@ export default class Clipboard extends Plugin { return; } - // Plain text can be determined based on event flag (#7799) or auto detection (#1006). If detected - // preserve selection attributes on pasted items. - if ( data.asPlainText || isPlainTextFragment( modelFragment ) ) { - // Consider only formatting attributes. - const textAttributes = new Map( Array.from( modelDocument.selection.getAttributes() ).filter( - keyValuePair => editor.model.schema.getAttributeProperties( keyValuePair[ 0 ] ).isFormatting - ) ); - - model.change( writer => { + model.change( writer => { + const selection = model.document.selection; + + // Plain text can be determined based on event flag (#7799) or auto detection (#1006). If detected + // preserve selection attributes on pasted items. + if ( data.asPlainText || isPlainTextFragment( modelFragment, model.schema ) ) { + // Formatting attributes should be preserved. + const textAttributes = Array.from( selection.getAttributes() ) + .filter( ( [ key ] ) => model.schema.getAttributeProperties( key ).isFormatting ); + + if ( !selection.isCollapsed ) { + model.deleteContent( selection, { doNotAutoparagraph: true } ); + } + + // But also preserve other attributes if they survived the content deletion (because they were not fully selected). + // For example linkHref is not a formatting attribute but it should be preserved if pasted text was in the middle + // of a link. + textAttributes.push( ...selection.getAttributes() ); + const range = writer.createRangeIn( modelFragment ); for ( const item of range.getItems() ) { @@ -131,10 +141,10 @@ export default class Clipboard extends Plugin { writer.setAttributes( textAttributes, item ); } } - } ); - } + } - model.insertContent( modelFragment ); + model.insertContent( modelFragment ); + } ); evt.stop(); } @@ -235,13 +245,18 @@ export default class Clipboard extends Plugin { // Returns true if specified `documentFragment` represents a plain text. // // @param {module:engine/view/documentfragment~DocumentFragment} documentFragment +// @param {module:engine/model/schema~Schema} schema // @returns {Boolean} -function isPlainTextFragment( documentFragment ) { +function isPlainTextFragment( documentFragment, schema ) { if ( documentFragment.childCount > 1 ) { return false; } const child = documentFragment.getChild( 0 ); + if ( schema.isObject( child ) ) { + return false; + } + return [ ...child.getAttributeKeys() ].length == 0; } diff --git a/packages/ckeditor5-clipboard/tests/clipboard.js b/packages/ckeditor5-clipboard/tests/clipboard.js index a68ea5e52fa..0a7e4731774 100644 --- a/packages/ckeditor5-clipboard/tests/clipboard.js +++ b/packages/ckeditor5-clipboard/tests/clipboard.js @@ -231,6 +231,7 @@ describe( 'Clipboard feature', () => { viewDocument.fire( 'paste', { dataTransfer: dataTransferMock, + stopPropagation() {}, preventDefault() {} } ); @@ -526,23 +527,61 @@ describe( 'Clipboard feature', () => { expect( getModelData( model ) ).to.equal( '<$text bold="true">Bolded []text.' ); } ); - it( 'ignores non-formatting text attributes', () => { - setModelData( model, '<$text test="true">Bolded []text.' ); + it( 'should preserve non formatting attribute if it wasn\'t fully selected', () => { + setModelData( model, '<$text test="true">Linked [text].' ); - const dataTransferMock = createDataTransfer( { - 'text/html': 'foo', - 'text/plain': 'foo' + viewDocument.fire( 'clipboardInput', { + dataTransfer: createDataTransfer( { + 'text/html': 'foo', + 'text/plain': 'foo' + } ), + stopPropagation() {}, + preventDefault() {} } ); + expect( getModelData( model ) ).to.equal( '<$text test="true">Linked foo[].' ); + } ); + + it( 'should not preserve non formatting attribute if it was fully selected', () => { + setModelData( model, '<$text test="true">[Linked text.]' ); + viewDocument.fire( 'clipboardInput', { - dataTransfer: dataTransferMock, - asPlainText: false, + dataTransfer: createDataTransfer( { + 'text/html': 'foo', + 'text/plain': 'foo' + } ), + stopPropagation() {}, + preventDefault() {} + } ); + + expect( getModelData( model ) ).to.equal( 'foo[]' ); + } ); + + it( 'should not treat a pasted object as a plain text', () => { + model.schema.register( 'obj', { + allowWhere: '$block', + isObject: true, + isBlock: true + } ); + + editor.conversion.elementToElement( { model: 'obj', view: 'obj' } ); + + setModelData( model, '<$text bold="true">Bolded [text].' ); + + viewDocument.fire( 'clipboardInput', { + dataTransfer: createDataTransfer( { + 'text/html': '', + 'text/plain': 'foo' + } ), stopPropagation() {}, preventDefault() {} } ); expect( getModelData( model ) ).to.equal( - '<$text test="true">Bolded foo[]<$text test="true">text.' ); + '<$text bold="true">Bolded ' + + '[]' + + '<$text bold="true">.' + ); } ); } ); diff --git a/packages/ckeditor5-link/tests/linkediting.js b/packages/ckeditor5-link/tests/linkediting.js index 7d0dc37c65f..ee13d361934 100644 --- a/packages/ckeditor5-link/tests/linkediting.js +++ b/packages/ckeditor5-link/tests/linkediting.js @@ -51,6 +51,9 @@ describe( 'LinkEditing', () => { } ); editor.model.schema.extend( '$text', { allowAttributes: 'bold' } ); + editor.model.schema.setAttributeProperties( 'bold', { + isFormatting: true + } ); editor.conversion.attributeToElement( { model: 'bold', @@ -143,7 +146,7 @@ describe( 'LinkEditing', () => { // https://github.com/ckeditor/ckeditor5/issues/6053 describe( 'selection attribute management on paste', () => { - it( 'should remove link atttributes when pasting a link', () => { + it( 'should remove link attributes when pasting a link', () => { setModelData( model, 'foo[]' ); model.change( writer => { @@ -155,7 +158,7 @@ describe( 'LinkEditing', () => { expect( [ ...model.document.selection.getAttributeKeys() ] ).to.be.empty; } ); - it( 'should remove all atttributes starting with "link" (e.g. decorator attributes) when pasting a link', () => { + it( 'should remove all attributes starting with "link" (e.g. decorator attributes) when pasting a link', () => { setModelData( model, 'foo[]' ); model.change( writer => { @@ -171,7 +174,7 @@ describe( 'LinkEditing', () => { expect( [ ...model.document.selection.getAttributeKeys() ] ).to.be.empty; } ); - it( 'should not remove link atttributes when pasting a non-link content', () => { + it( 'should not remove link attributes when pasting a non-link content', () => { setModelData( model, '<$text linkHref="ckeditor.com">foo[]' ); model.change( writer => { @@ -188,7 +191,7 @@ describe( 'LinkEditing', () => { expect( model.document.selection ).to.have.attribute( 'bold' ); } ); - it( 'should not remove link atttributes when pasting in the middle of a link with the same URL', () => { + it( 'should not remove link attributes when pasting in the middle of a link with the same URL', () => { setModelData( model, '<$text linkHref="ckeditor.com">fo[]o' ); model.change( writer => { @@ -199,7 +202,7 @@ describe( 'LinkEditing', () => { expect( model.document.selection ).to.have.attribute( 'linkHref' ); } ); - it( 'should not remove link atttributes from the selection when pasting before a link when the gravity is overridden', () => { + it( 'should not remove link attributes from the selection when pasting before a link when the gravity is overridden', () => { setModelData( model, 'foo[]<$text linkHref="ckeditor.com">bar' ); view.document.fire( 'keydown', { @@ -226,7 +229,7 @@ describe( 'LinkEditing', () => { expect( model.document.selection ).to.have.attribute( 'linkHref' ); } ); - it( 'should not remove link atttributes when pasting a link into another link (different URLs, no merge)', () => { + it( 'should not remove link attributes when pasting a link into another link (different URLs, no merge)', () => { setModelData( model, '<$text linkHref="ckeditor.com">f[]oo' ); model.change( writer => { @@ -244,7 +247,7 @@ describe( 'LinkEditing', () => { expect( model.document.selection ).to.have.attribute( 'linkHref' ); } ); - it( 'should not remove link atttributes when pasting before another link (different URLs, no merge)', () => { + it( 'should not remove link attributes when pasting before another link (different URLs, no merge)', () => { setModelData( model, '[]<$text linkHref="ckeditor.com">foo' ); expect( model.document.selection ).to.have.property( 'isGravityOverridden', false ); @@ -263,6 +266,43 @@ describe( 'LinkEditing', () => { expect( model.document.selection ).to.have.attribute( 'linkHref' ); expect( model.document.selection ).to.have.attribute( 'linkHref', 'http://INSERTED' ); } ); + + // https://github.com/ckeditor/ckeditor5/issues/8158 + it( 'should expand link text on pasting plain text', () => { + setModelData( model, '<$text linkHref="ckeditor.com">f[]oo' ); + + view.document.fire( 'paste', { + dataTransfer: createDataTransfer( { + 'text/html': '

bar

', + 'text/plain': 'bar' + } ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + expect( getModelData( model ) ).to.equal( + '' + + '<$text linkHref="ckeditor.com">fbar[]oo' + + '' + ); + } ); + + it( 'doesn\'t affect attributes other than link', () => { + setModelData( model, '<$text bold="true">[foo]' ); + + view.document.fire( 'paste', { + dataTransfer: createDataTransfer( { + 'text/html': '

bar

', + 'text/plain': 'bar' + } ), + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + expect( getModelData( model ) ).to.equal( + '<$text bold="true">bar[]' + ); + } ); } ); describe( 'command', () => { @@ -1397,15 +1437,6 @@ describe( 'LinkEditing', () => { 'This is Abcde[]from <$text linkHref="bar">Bar.' ); } ); - - function createDataTransfer( data ) { - return { - getData( type ) { - return data[ type ]; - }, - setData() {} - }; - } } ); // https://github.com/ckeditor/ckeditor5/issues/7521 @@ -1586,4 +1617,13 @@ describe( 'LinkEditing', () => { expect( model.document.selection.hasAttribute( 'linkHref' ), 'removing space after the link' ).to.equal( true ); } ); } ); + + function createDataTransfer( data ) { + return { + getData( type ) { + return data[ type ]; + }, + setData() {} + }; + } } );