diff --git a/src/clipboard.js b/src/clipboard.js index b906516..8ee98c6 100644 --- a/src/clipboard.js +++ b/src/clipboard.js @@ -17,7 +17,7 @@ import normalizeClipboardHtml from './utils/normalizeclipboarddata'; import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/htmldataprocessor'; /** - * The clipboard feature. Currently, it's only responsible for intercepting the `paste` event and + * The clipboard feature. Currently, it's responsible for intercepting the `paste` and `drop` events and * passing the pasted content through the clipboard pipeline. * * ## Clipboard input pipeline @@ -26,22 +26,36 @@ import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/html * before it gets inserted into the editor. The pipeline consists of two events on which * the features can listen in order to modify or totally override the default behavior. * - * ### On {@link module:engine/view/document~Document#event:paste} + * ### On {@link module:engine/view/document~Document#event:paste} and {@link module:engine/view/document~Document#event:drop} * * The default action is to: * * 1. get HTML or plain text from the clipboard, - * 2. prevent the default action of the native `paste` event, - * 3. fire {@link module:engine/view/document~Document#event:clipboardInput} with the clipboard data parsed to + * 2. prevent the default action of the native `paste` or `drop` event, + * 3. fire {@link module:engine/view/document~Document#event:clipboardInput} with a + * {@link module:clipboard/datatransfer~DataTransfer `dataTransfer`} property. + * 4. fire {@link module:clipboard/clipboard~Clipboard#event:inputTransformation} with a `data` containing the clipboard data parsed to * a {@link module:engine/view/documentfragment~DocumentFragment view document fragment}. * - * This action is performed by a low priority listener, so it can be overridden by a normal one + * These action are performed by a low priority listeners, so they can be overridden by a normal ones * when a deeper change in pasting behavior is needed. For example, a feature which wants to differently read * data from the clipboard (the {@link module:clipboard/datatransfer~DataTransfer `DataTransfer`}). * should plug a listener at this stage. * * ### On {@link module:engine/view/document~Document#event:clipboardInput} * + * This action is performed by a low priority listener, so it can be overridden by a normal one. + * + * At this stage the dataTransfer object can be processed by the features, which want to transform the original dataTransform. + * + * this.listenTo( editor.editing.view, 'clipboardInput', ( evt, data ) => { + * const content = customTransform( data.dataTransfer.get( 'text/html' ) ); + * const transformedContent = transform( content ); + * data.dataTransfer.set( 'text/html', transformedContent ); + * } ); + * + * ### On {@link module:clipboard/clipboard~Clipboard#event:inputTransformation} + * * The default action is to insert the content (`data.content`, represented by a * {@link module:engine/view/documentfragment~DocumentFragment}) to an editor if the data is not empty. * @@ -50,7 +64,7 @@ import HtmlDataProcessor from '@ckeditor/ckeditor5-engine/src/dataprocessor/html * At this stage the pasted content can be processed by the features. E.g. a feature which wants to transform * a pasted text into a link can be implemented in this way: * - * this.listenTo( editor.editing.view, 'clipboardInput', ( evt, data ) => { + * this.listenTo( editor.plugins.get( 'clipboard/clipboard' ), 'inputTransformation', ( evt, data ) => { * if ( data.content.childCount == 1 && isUrlText( data.content.getChild( 0 ) ) ) { * const linkUrl = data.content.getChild( 0 ).data; * @@ -118,7 +132,7 @@ export default class Clipboard extends Plugin { // The clipboard paste pipeline. - this.listenTo( editingView, 'paste', ( evt, data ) => { + this.listenTo( editingView, 'clipboardInput', ( evt, data ) => { const dataTransfer = data.dataTransfer; let content = ''; @@ -130,17 +144,15 @@ export default class Clipboard extends Plugin { content = this._htmlDataProcessor.toView( content ); - data.preventDefault(); - - editingView.fire( 'clipboardInput', { dataTransfer, content } ); + this.fire( 'inputTransformation', { content } ); }, { priority: 'low' } ); - this.listenTo( editingView, 'clipboardInput', ( evt, data ) => { + this.listenTo( this, 'inputTransformation', ( evt, data ) => { if ( !data.content.isEmpty ) { const dataController = this.editor.data; // Convert the pasted content to a model document fragment. - // Convertion is contextual, but in this case we need an "all allowed" context and for that + // Conversion is contextual, but in this case we need an "all allowed" context and for that // we use the $clipboardHolder item. const modelFragment = dataController.toModel( data.content, '$clipboardHolder' ); @@ -179,34 +191,16 @@ export default class Clipboard extends Plugin { } /** - * Fired with a content which comes from the clipboard (was pasted or dropped) and + * Fired with a `content`, which comes from the clipboard (was pasted or dropped) and * should be processed in order to be inserted into the editor. * It's part of the {@link module:clipboard/clipboard~Clipboard "clipboard pipeline"}. * * @see module:clipboard/clipboardobserver~ClipboardObserver * @see module:clipboard/clipboard~Clipboard - * @event module:engine/view/document~Document#event:clipboardInput - * @param {module:clipboard/clipboard~ClipboardInputEventData} data Event data. - */ - -/** - * The value of the {@link module:engine/view/document~Document#event:clipboardInput} event. - * - * @class module:clipboard/clipboard~ClipboardInputEventData - */ - -/** - * Data transfer instance. - * - * @readonly - * @member {module:clipboard/datatransfer~DataTransfer} module:clipboard/clipboard~ClipboardInputEventData#dataTransfer - */ - -/** - * Content to be inserted into the editor. It can be modified by the event listeners. - * Read more about the clipboard pipelines in {@link module:clipboard/clipboard~Clipboard}. - * - * @member {module:engine/view/documentfragment~DocumentFragment} module:clipboard/clipboard~ClipboardInputEventData#content + * @event module:clipboard/clipboard~Clipboard#event:inputTransformation + * @param {Object} data Event data. + * @param {module:engine/view/documentfragment~DocumentFragment} data.content Event data. Content to be inserted into the editor. + * It can be modified by the event listeners. Read more about the clipboard pipelines in {@link module:clipboard/clipboard~Clipboard} */ /** diff --git a/src/clipboardobserver.js b/src/clipboardobserver.js index cf9d63d..d8df380 100644 --- a/src/clipboardobserver.js +++ b/src/clipboardobserver.js @@ -22,16 +22,51 @@ export default class ClipboardObserver extends DomEventObserver { constructor( doc ) { super( doc ); - this.domEventType = [ 'paste', 'copy', 'cut' ]; + this.domEventType = [ 'paste', 'copy', 'cut', 'drop' ]; + + this.listenTo( doc, 'paste', handleInput, { priority: 'low' } ); + this.listenTo( doc, 'drop', handleInput, { priority: 'low' } ); + + function handleInput( evt, data ) { + data.preventDefault(); + + doc.fire( 'clipboardInput', { dataTransfer: data.dataTransfer } ); + } } onDomEvent( domEvent ) { this.fire( domEvent.type, domEvent, { - dataTransfer: new DataTransfer( domEvent.clipboardData ) + dataTransfer: new DataTransfer( domEvent.clipboardData ? domEvent.clipboardData : domEvent.dataTransfer ) } ); } } +/** + * Fired with a `dataTransfer`, which comes from the clipboard (was pasted or dropped) and + * should be processed in order to be inserted into the editor. + * It's part of the {@link module:clipboard/clipboard~Clipboard "clipboard pipeline"}. + * + * @see module:clipboard/clipboardobserver~ClipboardObserver + * @see module:clipboard/clipboard~Clipboard + * @event module:engine/view/document~Document#event:input + * @param {Object} data Event data. + * @param {module:clipboard/datatransfer~DataTransfer} data.dataTransfer Data transfer instance. + */ + +/** + * Fired when user dropped content into one of the editables. + * + * Introduced by {@link module:clipboard/clipboardobserver~ClipboardObserver}. + * + * Note that this event is not available by default. To make it available {@link module:clipboard/clipboardobserver~ClipboardObserver} + * needs to be added to {@link module:engine/view/document~Document} by the {@link module:engine/view/document~Document#addObserver} method. + * It's done by the {@link module:clipboard/clipboard~Clipboard} feature. If it's not loaded, it must be done manually. + * + * @see module:clipboard/clipboardobserver~ClipboardObserver + * @event module:engine/view/document~Document#event:drop + * @param {module:clipboard/clipboardobserver~ClipboardEventData} data Event data. + */ + /** * Fired when user pasted content into one of the editables. * diff --git a/src/datatransfer.js b/src/datatransfer.js index f556a48..dcdcd44 100644 --- a/src/datatransfer.js +++ b/src/datatransfer.js @@ -12,6 +12,14 @@ */ export default class DataTransfer { constructor( nativeDataTransfer ) { + /** + * The array of files created from the native `DataTransfer#files` or `DataTransfer#items`. + * + * @readonly + * @member {Array.} #files + */ + this.files = getFiles( nativeDataTransfer ); + /** * The native DataTransfer object. * @@ -21,6 +29,15 @@ export default class DataTransfer { this._native = nativeDataTransfer; } + /** + * Returns an array of available native content types. + * + * @returns {Array.} + */ + get types() { + return this._native.types; + } + /** * Gets data from the data transfer by its mime type. * @@ -43,3 +60,17 @@ export default class DataTransfer { this._native.setData( type, data ); } } + +function getFiles( nativeDataTransfer ) { + // DataTransfer.files and items are Array-like and might not have an iterable interface. + const files = nativeDataTransfer.files ? Array.from( nativeDataTransfer.files ) : []; + const items = nativeDataTransfer.items ? Array.from( nativeDataTransfer.items ) : []; + + if ( files.length ) { + return files; + } + // Chrome have empty DataTransfer.files, but let get files through the items interface. + return items + .filter( item => item.kind === 'file' ) + .map( item => item.getAsFile() ); +} diff --git a/tests/clipboard.js b/tests/clipboard.js index 4280cb7..be41355 100644 --- a/tests/clipboard.js +++ b/tests/clipboard.js @@ -20,7 +20,7 @@ import ViewDocumentFragment from '@ckeditor/ckeditor5-engine/src/view/documentfr import ViewText from '@ckeditor/ckeditor5-engine/src/view/text'; describe( 'Clipboard feature', () => { - let editor, editingView; + let editor, editingView, clipboardPlugin; beforeEach( () => { return VirtualTestEditor.create( { @@ -29,6 +29,7 @@ describe( 'Clipboard feature', () => { .then( ( newEditor ) => { editor = newEditor; editingView = editor.editing.view; + clipboardPlugin = editor.plugins.get( 'clipboard/clipboard' ); } ); } ); @@ -39,60 +40,97 @@ describe( 'Clipboard feature', () => { } ); describe( 'clipboard paste pipeline', () => { - it( 'takes HTML data from the dataTransfer', ( done ) => { - const dataTransferMock = createDataTransfer( { 'text/html': '

x

', 'text/plain': 'y' } ); - const preventDefaultSpy = sinon.spy(); + describe( 'takes HTML data from the dataTransfer', () => { + it( 'and fires the clipboardInput event on the editingView', ( done ) => { + const dataTransferMock = createDataTransfer( { 'text/html': '

x

', 'text/plain': 'y' } ); + const preventDefaultSpy = sinon.spy(); + + editingView.on( 'clipboardInput', ( evt, data ) => { + expect( preventDefaultSpy.calledOnce ).to.be.true; + expect( data.dataTransfer ).to.equal( dataTransferMock ); + + done(); + } ); + + editingView.fire( 'paste', { + dataTransfer: dataTransferMock, + preventDefault: preventDefaultSpy + } ); + } ); - editingView.on( 'clipboardInput', ( evt, data ) => { - expect( preventDefaultSpy.calledOnce ).to.be.true; + it( 'and fires the inputTransformation event on the clipboardPlugin', ( done ) => { + const dataTransferMock = createDataTransfer( { 'text/html': '

x

', 'text/plain': 'y' } ); + const preventDefaultSpy = sinon.spy(); - expect( data.dataTransfer ).to.equal( dataTransferMock ); + clipboardPlugin.on( 'inputTransformation', ( evt, data ) => { + expect( data.content ).is.instanceOf( ViewDocumentFragment ); + expect( stringifyView( data.content ) ).to.equal( '

x

' ); - expect( data.content ).is.instanceOf( ViewDocumentFragment ); - expect( stringifyView( data.content ) ).to.equal( '

x

' ); + done(); + } ); - done(); - } ); - - editingView.fire( 'paste', { - dataTransfer: dataTransferMock, - preventDefault: preventDefaultSpy + editingView.fire( 'paste', { + dataTransfer: dataTransferMock, + preventDefault: preventDefaultSpy + } ); } ); } ); - it( 'takes plain text data from the dataTransfer if there is no HTML', ( done ) => { - const dataTransferMock = createDataTransfer( { 'text/plain': 'x\n\ny z' } ); - const preventDefaultSpy = sinon.spy(); + describe( 'takes plain text data from the dataTransfer if there is no HTML', () => { + it( 'and fires the clipboardInput event on the editingView', ( done ) => { + const dataTransferMock = createDataTransfer( { 'text/plain': 'x\n\ny z' } ); + const preventDefaultSpy = sinon.spy(); - editingView.on( 'clipboardInput', ( evt, data ) => { - expect( preventDefaultSpy.calledOnce ).to.be.true; - - expect( data.dataTransfer ).to.equal( dataTransferMock ); + editingView.on( 'clipboardInput', ( evt, data ) => { + expect( preventDefaultSpy.calledOnce ).to.be.true; + expect( data.dataTransfer ).to.equal( dataTransferMock ); - expect( data.content ).is.instanceOf( ViewDocumentFragment ); - expect( stringifyView( data.content ) ).to.equal( '

x

y z

' ); + done(); + } ); - done(); + editingView.fire( 'paste', { + dataTransfer: dataTransferMock, + preventDefault: preventDefaultSpy + } ); } ); - editingView.fire( 'paste', { - dataTransfer: dataTransferMock, - preventDefault: preventDefaultSpy + it( 'and fires the inputTransformation event on the clipboardPlugin', ( done ) => { + const dataTransferMock = createDataTransfer( { 'text/plain': 'x\n\ny z' } ); + const preventDefaultSpy = sinon.spy(); + + clipboardPlugin.on( 'inputTransformation', ( evt, data ) => { + expect( data.content ).is.instanceOf( ViewDocumentFragment ); + expect( stringifyView( data.content ) ).to.equal( '

x

y z

' ); + + done(); + } ); + + editingView.fire( 'paste', { + dataTransfer: dataTransferMock, + preventDefault: preventDefaultSpy + } ); } ); } ); - it( 'fires clipboardInput event with empty data if there is no HTML nor plain text', ( done ) => { + it( 'fires events with empty data if there is no HTML nor plain text', ( done ) => { const dataTransferMock = createDataTransfer( {} ); const preventDefaultSpy = sinon.spy(); + const editorViewCalled = sinon.spy(); editingView.on( 'clipboardInput', ( evt, data ) => { expect( preventDefaultSpy.calledOnce ).to.be.true; expect( data.dataTransfer ).to.equal( dataTransferMock ); + editorViewCalled(); + } ); + + clipboardPlugin.on( 'inputTransformation', ( evt, data ) => { expect( data.content ).is.instanceOf( ViewDocumentFragment ); expect( stringifyView( data.content ) ).to.equal( '' ); + expect( editorViewCalled.calledOnce ).to.be.true; + done(); } ); diff --git a/tests/clipboardobserver.js b/tests/clipboardobserver.js index b1f2151..dcb041a 100644 --- a/tests/clipboardobserver.js +++ b/tests/clipboardobserver.js @@ -18,30 +18,65 @@ describe( 'ClipboardObserver', () => { } ); it( 'should define domEventType', () => { - expect( observer.domEventType ).to.deep.equal( [ 'paste', 'copy', 'cut' ] ); + expect( observer.domEventType ).to.deep.equal( [ 'paste', 'copy', 'cut', 'drop' ] ); } ); describe( 'onDomEvent', () => { - it( 'should fire paste with the right event data', () => { - const spy = sinon.spy(); - const dataTransfer = { + let pasteSpy, preventDefaultSpy; + + function getDataTransfer() { + return { getData( type ) { return 'foo:' + type; } }; + } + + beforeEach( () => { + pasteSpy = sinon.spy(); + preventDefaultSpy = sinon.spy(); + } ); - viewDocument.on( 'paste', spy ); + it( 'should fire paste with the right event data - clipboardData', () => { + const dataTransfer = getDataTransfer(); - observer.onDomEvent( { type: 'paste', target: document.body, clipboardData: dataTransfer } ); + viewDocument.on( 'paste', pasteSpy ); - expect( spy.calledOnce ).to.be.true; + observer.onDomEvent( { + type: 'paste', + target: document.body, + clipboardData: dataTransfer, + preventDefault: preventDefaultSpy + } ); - const data = spy.args[ 0 ][ 1 ]; + expect( pasteSpy.calledOnce ).to.be.true; + + const data = pasteSpy.args[ 0 ][ 1 ]; expect( data.domTarget ).to.equal( document.body ); expect( data.dataTransfer ).to.be.instanceOf( DataTransfer ); expect( data.dataTransfer.getData( 'x/y' ) ).to.equal( 'foo:x/y' ); + expect( preventDefaultSpy.calledOnce ).to.be.true; } ); - // If it fires paste it fires all the other events too. + it( 'should fire paste with the right event data - dataTransfer', () => { + const dataTransfer = getDataTransfer(); + + viewDocument.on( 'drop', pasteSpy ); + + observer.onDomEvent( { + type: 'drop', + target: document.body, + dataTransfer, + preventDefault: preventDefaultSpy + } ); + + expect( pasteSpy.calledOnce ).to.be.true; + + const data = pasteSpy.args[ 0 ][ 1 ]; + expect( data.domTarget ).to.equal( document.body ); + expect( data.dataTransfer ).to.be.instanceOf( DataTransfer ); + expect( data.dataTransfer.getData( 'x/y' ) ).to.equal( 'foo:x/y' ); + expect( preventDefaultSpy.calledOnce ).to.be.true; + } ); } ); } ); diff --git a/tests/datatransfer.js b/tests/datatransfer.js index aa58b1a..5301ddc 100644 --- a/tests/datatransfer.js +++ b/tests/datatransfer.js @@ -6,7 +6,34 @@ import DataTransfer from '../src/datatransfer'; describe( 'DataTransfer', () => { - describe( 'getData', () => { + describe( 'constructor', () => { + it( 'should create files from the native files', () => { + const dt = new DataTransfer( { + files: { + 0: 'file1', + 1: 'file2', + length: 2 + } + } ); + + expect( dt.files ).to.deep.equal( [ 'file1', 'file2' ] ); + } ); + + it( 'should create files from the native items', () => { + const dt = new DataTransfer( { + items: { + 0: { kind: 'file', getAsFile: () => 'file1' }, + 1: { kind: 'file', getAsFile: () => 'file2' }, + 2: { kind: 'someOtherKind' }, + length: 3 + }, + files: [] + } ); + + expect( dt.files ).to.deep.equal( [ 'file1', 'file2' ] ); + } ); + } ); + describe( 'getData()', () => { it( 'should return data from the native data transfer', () => { const dt = new DataTransfer( { getData( type ) { @@ -18,7 +45,7 @@ describe( 'DataTransfer', () => { } ); } ); - describe( 'setData', () => { + describe( 'setData()', () => { it( 'should return set data in the native data transfer', () => { const spy = sinon.spy(); const dt = new DataTransfer( { @@ -30,4 +57,14 @@ describe( 'DataTransfer', () => { expect( spy.calledWithExactly( 'text/html', 'bar' ) ).to.be.true; } ); } ); + + describe( 'types', () => { + it( 'should return available types', () => { + const dt = new DataTransfer( { + types: [ 'text/html', 'text/plain' ] + } ); + + expect( dt.types ).to.deep.equal( [ 'text/html', 'text/plain' ] ); + } ); + } ); } ); diff --git a/tests/manual/copycut.js b/tests/manual/copycut.js index bbb0a5d..bca8218 100644 --- a/tests/manual/copycut.js +++ b/tests/manual/copycut.js @@ -36,6 +36,7 @@ ClassicEditor.create( document.querySelector( '#editor' ), { } ) .then( editor => { window.editor = editor; + const clipboard = editor.plugins.get( 'clipboard/clipboard' ); editor.editing.view.on( 'paste', ( evt, data ) => { console.clear(); @@ -45,7 +46,7 @@ ClassicEditor.create( document.querySelector( '#editor' ), { editor.editing.view.on( 'copy', onViewEvent, { priority: 'lowest' } ); editor.editing.view.on( 'cut', onViewEvent, { priority: 'lowest' } ); - editor.editing.view.on( 'clipboardInput', onPipelineEvent ); + clipboard.on( 'inputTransformation', onPipelineEvent ); editor.editing.view.on( 'clipboardOutput', ( evt, data ) => { console.clear(); onPipelineEvent( evt, data ); diff --git a/tests/manual/pasting.js b/tests/manual/pasting.js index 8921670..bc77c54 100644 --- a/tests/manual/pasting.js +++ b/tests/manual/pasting.js @@ -36,6 +36,7 @@ ClassicEditor.create( document.querySelector( '#editor' ), { } ) .then( editor => { window.editor = editor; + const clipboard = editor.plugins.get( 'clipboard/clipboard' ); editor.editing.view.on( 'paste', ( evt, data ) => { console.clear(); @@ -46,9 +47,9 @@ ClassicEditor.create( document.querySelector( '#editor' ), { console.log( 'text/plain\n', data.dataTransfer.getData( 'text/plain' ) ); } ); - editor.editing.view.on( 'clipboardInput', ( evt, data ) => { + clipboard.on( 'inputTransformation', ( evt, data ) => { console.log( '----- clipboardInput -----' ); - console.log( 'stringify( data.content )\n', stringifyView( data.content ) ); + console.log( 'stringify( data.dataTransfer )\n', stringifyView( data.content ) ); } ); } ) .catch( err => {