Skip to content

Commit

Permalink
Merge pull request #9390 from ckeditor/i/5204
Browse files Browse the repository at this point in the history
Feature (image): Introduced the `uploadComplete` event in `ImageUploadEditing` that allows customizing the image element (e.g. setting custom attributes) based on data retrieved from the upload adapter. Closes #5204.

Feature (upload): Upload adapters' async `upload` method can now resolve to an object with additional properties along with the `urls` hash. See more in #5204.

MINOR BREAKING CHANGE (upload): The async `SimpleUploadAdapter#upload()` resolves to an object with normalized data including the `urls` object, which was only returned before. This may affect all integrations depending on the `SimpleUploadAdapter` uploading mechanism.
  • Loading branch information
niegowski authored Apr 2, 2021
2 parents 0b4b2a7 + b09ba76 commit bf5b561
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,22 @@ xhr.addEventListener( 'load', () => {
} );
```

### Passing additional data to the response

There is a chance you might need to pass additional data from the server to provide additional data to some features. In order to do that you need to wrap all URLs in the `urls` property and pass additional data in the top level of the object.

For image uploading, you can later retrieve the data in the {@link module:image/imageupload/imageuploadediting~ImageUploadEditing#event:uploadComplete `uploadComplete`} event, which allows setting new attributes and overriding the existing ones on the model image based on the data just after the image is uploaded.

```js
{
urls: {
default: 'http://example.com/images/image–default-size.png',
// Optional different sizes of images.
},
customProperty: 'foo'
}
```

### Activating a custom upload adapter

Having implemented the adapter, you must figure out how to enable it in the WYSIWYG editor. The good news is that it is pretty easy, and you do not need to {@link builds/guides/development/custom-builds rebuild the editor} to do that!
Expand Down
48 changes: 45 additions & 3 deletions packages/ckeditor5-image/src/imageupload/imageuploadediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import { getViewImgFromWidget } from '../image/utils';
* The editing part of the image upload feature. It registers the `'uploadImage'` command
* and `imageUpload` command as an aliased name.
*
* When an image is uploaded it fires the {@link ~ImageUploadEditing#event:uploadComplete `uploadComplete` event}
* that allows adding custom attributes to the {@link module:engine/model/element~Element image element}.
*
* @extends module:core/plugin~Plugin
*/
export default class ImageUploadEditing extends Plugin {
Expand Down Expand Up @@ -192,6 +195,16 @@ export default class ImageUploadEditing extends Plugin {
}
}
} );

// Set the default handler for feeding the image element with `src` and `srcset` attributes.
this.on( 'uploadComplete', ( evt, { imageElement, data } ) => {
const urls = data.urls ? data.urls : data;

this.editor.model.change( writer => {
writer.setAttribute( 'src', urls.default, imageElement );
this._parseAndSetSrcsetAttributeOnImage( urls, imageElement, writer );
} );
}, { priority: 'low' } );
}

/**
Expand Down Expand Up @@ -260,8 +273,37 @@ export default class ImageUploadEditing extends Plugin {
} )
.then( data => {
model.enqueueChange( 'transparent', writer => {
writer.setAttributes( { uploadStatus: 'complete', src: data.default }, imageElement );
this._parseAndSetSrcsetAttributeOnImage( data, imageElement, writer );
writer.setAttribute( 'uploadStatus', 'complete', imageElement );

/**
* An event fired when an image is uploaded. You can hook into this event to provide
* custom attributes to the {@link module:engine/model/element~Element image element} based on the data from
* the back-end.
*
* const imageUploadEditing = editor.plugins.get( 'ImageUploadEditing );
*
* imageUploadEditing.on( 'uploadComplete', ( evt, { data, imageElement } ) => {
* editor.model.change( writer => {
* writer.setAttribute( 'someAttribute', 'foo', imageElement );
* } );
* } );
*
* You can also stop the default handler that sets the `src` and `srcset` attributes
* if you want to provide custom values for these attributes.
*
* imageUploadEditing.on( 'uploadComplete', ( evt, { data, imageElement } ) => {
* evt.stop();
* } );
*
* **Note**: This event is fired by the {@link module:image/imageupload/imageuploadediting~ImageUploadEditing} plugin.
*
* @event uploadComplete
* @param {Object} data The `uploadComplete` event data.
* @param {Object} data.data The data coming from the upload adapter.
* @param {module:engine/model/element~Element} data.imageElement The
* model {@link module:engine/model/element~Element image element} that can be customized.
*/
this.fire( 'uploadComplete', { data, imageElement } );
} );

clean();
Expand Down Expand Up @@ -312,7 +354,7 @@ export default class ImageUploadEditing extends Plugin {
let maxWidth = 0;

const srcsetAttribute = Object.keys( data )
// Filter out keys that are not integers.
// Filter out keys that are not integers.
.filter( key => {
const width = parseInt( key, 10 );

Expand Down
208 changes: 193 additions & 15 deletions packages/ckeditor5-image/tests/imageupload/imageuploadediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { setData as setModelData, getData as getModelData } from '@ckeditor/cked
import { getData as getViewData, stringify as stringifyView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';

import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
import { modelToViewAttributeConverter } from '../../src/image/converters';

describe( 'ImageUploadEditing', () => {
// eslint-disable-next-line max-len
Expand Down Expand Up @@ -381,9 +382,9 @@ describe( 'ImageUploadEditing', () => {
tryExpect( done, () => {
expect( getViewData( view ) ).to.equal(
'[<figure class="ck-widget image" contenteditable="false">' +
// Rendering the image data is left to a upload progress converter.
'<img></img>' +
'</figure>]' +
// Rendering the image data is left to a upload progress converter.
'<img></img>' +
'</figure>]' +
'<p>foo bar</p>'
);

Expand All @@ -396,25 +397,46 @@ describe( 'ImageUploadEditing', () => {
loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
} );

it( 'should replace read data with server response once it is present', done => {
it( 'should replace read data with server response once it is present', async () => {
const file = createNativeFileMock();
setModelData( model, '<paragraph>{}foo bar</paragraph>' );
editor.execute( 'uploadImage', { file } );

model.document.once( 'change', () => {
model.document.once( 'change', () => {
tryExpect( done, () => {
expect( getViewData( view ) ).to.equal(
'[<figure class="ck-widget image" contenteditable="false"><img src="image.png"></img></figure>]<p>foo bar</p>'
);
expect( loader.status ).to.equal( 'idle' );
} );
}, { priority: 'lowest' } );
await new Promise( res => {
model.document.once( 'change', res );
loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
} );

await new Promise( res => {
model.document.once( 'change', res, { priority: 'lowest' } );
loader.file.then( () => adapterMocks[ 0 ].mockSuccess( { default: 'image.png' } ) );
} );

loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
expect( getViewData( view ) ).to.equal(
'[<figure class="ck-widget image" contenteditable="false"><img src="image.png"></img></figure>]<p>foo bar</p>'
);
expect( loader.status ).to.equal( 'idle' );
} );

it( 'should support adapter response with the normalized `urls` property', async () => {
const file = createNativeFileMock();
setModelData( model, '<paragraph>{}foo bar</paragraph>' );
editor.execute( 'uploadImage', { file } );

await new Promise( res => {
model.document.once( 'change', res );
loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
} );

await new Promise( res => {
model.document.once( 'change', res, { priority: 'lowest' } );
loader.file.then( () => adapterMocks[ 0 ].mockSuccess( { urls: { default: 'image.png' } } ) );
} );

expect( getViewData( view ) ).to.equal(
'[<figure class="ck-widget image" contenteditable="false"><img src="image.png"></img></figure>]<p>foo bar</p>'
);
expect( loader.status ).to.equal( 'idle' );
} );

it( 'should fire notification event in case of error', done => {
Expand Down Expand Up @@ -605,7 +627,7 @@ describe( 'ImageUploadEditing', () => {
} );
} );

it( 'should create responsive image if server return multiple images', done => {
it( 'should create responsive image if the server returns multiple images', done => {
const file = createNativeFileMock();
setModelData( model, '<paragraph>{}foo bar</paragraph>' );
editor.execute( 'uploadImage', { file } );
Expand All @@ -628,6 +650,162 @@ describe( 'ImageUploadEditing', () => {
loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
} );

describe( 'uploadComplete event', () => {
it( 'should be fired when the upload adapter resolves with the image data', async () => {
const file = createNativeFileMock();
setModelData( model, '<paragraph>[]foo bar</paragraph>' );

const imageUploadEditing = editor.plugins.get( 'ImageUploadEditing' );
const uploadCompleteSpy = sinon.spy();

imageUploadEditing.on( 'uploadComplete', uploadCompleteSpy );

editor.execute( 'uploadImage', { file } );

await new Promise( res => {
model.document.once( 'change', res );
loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
} );

sinon.assert.notCalled( uploadCompleteSpy );

await new Promise( res => {
model.document.once( 'change', res, { priority: 'lowest' } );
loader.file.then( () => adapterMocks[ 0 ].mockSuccess( { default: 'image.png' } ) );
} );

sinon.assert.calledOnce( uploadCompleteSpy );

const eventArgs = uploadCompleteSpy.firstCall.args[ 1 ];

expect( eventArgs ).to.be.an( 'object' );
expect( eventArgs.imageElement.is( 'model:element', 'image' ) ).to.be.true;
expect( eventArgs.data ).to.deep.equal( { default: 'image.png' } );
} );

it( 'should allow modifying the image element once the original image is uploaded', async () => {
const file = createNativeFileMock();
setModelData( model, '<paragraph>[]foo bar</paragraph>' );

editor.model.schema.extend( 'image', { allowAttributes: 'data-original' } );

editor.conversion.for( 'downcast' )
.add( modelToViewAttributeConverter( 'data-original' ) );

editor.conversion.for( 'upcast' )
.attributeToAttribute( {
view: {
name: 'img',
key: 'data-original'
},
model: 'data-original'
} );

const imageUploadEditing = editor.plugins.get( 'ImageUploadEditing' );
let batch;

imageUploadEditing.on( 'uploadComplete', ( evt, { imageElement, data } ) => {
editor.model.change( writer => {
writer.setAttribute( 'data-original', data.originalUrl, imageElement );
batch = writer.batch;
} );
} );

editor.execute( 'uploadImage', { file } );

await new Promise( res => {
model.document.once( 'change', res );
loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
} );

await new Promise( res => {
model.document.once( 'change', res, { priority: 'lowest' } );
loader.file.then( () => adapterMocks[ 0 ].mockSuccess( { originalUrl: 'original.jpg', default: 'image.jpg' } ) );
} );

// Make sure the custom attribute was set in the same transparent batch as the default handling (setting src and status).
expect( batch.type ).to.equal( 'transparent' );
expect( batch.operations.length ).to.equal( 3 );

expect( batch.operations[ 0 ].type ).to.equal( 'changeAttribute' );
expect( batch.operations[ 0 ].key ).to.equal( 'uploadStatus' );
expect( batch.operations[ 0 ].newValue ).to.equal( 'complete' );

expect( batch.operations[ 1 ].type ).to.equal( 'addAttribute' );
expect( batch.operations[ 1 ].key ).to.equal( 'data-original' );
expect( batch.operations[ 1 ].newValue ).to.equal( 'original.jpg' );

expect( batch.operations[ 2 ].type ).to.equal( 'addAttribute' );
expect( batch.operations[ 2 ].key ).to.equal( 'src' );
expect( batch.operations[ 2 ].newValue ).to.equal( 'image.jpg' );

expect( getModelData( model ) ).to.equal(
'[<image data-original="original.jpg" src="image.jpg"></image>]<paragraph>foo bar</paragraph>'
);

expect( getViewData( view ) ).to.equal(
'[<figure class="ck-widget image" contenteditable="false">' +
'<img data-original="original.jpg" src="image.jpg"></img>' +
'</figure>]' +
'<p>foo bar</p>'
);
} );

it( 'should allow stopping the original listener that sets image attributes based on the data', async () => {
const file = createNativeFileMock();
setModelData( model, '<paragraph>[]foo bar</paragraph>' );

const imageUploadEditing = editor.plugins.get( 'ImageUploadEditing' );
let batch;

imageUploadEditing.on( 'uploadComplete', ( evt, { imageElement } ) => {
evt.stop();

model.change( writer => {
writer.setAttribute( 'src', 'foo.jpg', imageElement );
batch = writer.batch;
} );
} );

editor.execute( 'uploadImage', { file } );

await new Promise( res => {
model.document.once( 'change', res );
loader.file.then( () => nativeReaderMock.mockSuccess( base64Sample ) );
} );

await new Promise( res => {
model.document.once( 'change', res, { priority: 'lowest' } );
loader.file.then( () => adapterMocks[ 0 ].mockSuccess(
{ default: 'image.png', 500: 'image-500.png', 800: 'image-800.png' }
) );
} );

// Make sure the custom attribute was set in the same transparent batch as the default handling (setting src and status).
expect( batch.type ).to.equal( 'transparent' );
expect( batch.operations.length ).to.equal( 2 );

expect( batch.operations[ 0 ].type ).to.equal( 'changeAttribute' );
expect( batch.operations[ 0 ].key ).to.equal( 'uploadStatus' );
expect( batch.operations[ 0 ].newValue ).to.equal( 'complete' );

expect( batch.operations[ 1 ].type ).to.equal( 'addAttribute' );
expect( batch.operations[ 1 ].key ).to.equal( 'src' );
expect( batch.operations[ 1 ].newValue ).to.equal( 'foo.jpg' );

expect( getModelData( model ) ).to.equal(
'[<image src="foo.jpg"></image>]<paragraph>foo bar</paragraph>'
);

expect( getViewData( view ) ).to.equal(
'[<figure class="ck-widget image" contenteditable="false">' +
'<img src="foo.jpg"></img>' +
'</figure>]' +
'<p>foo bar</p>'
);
} );
} );

it( 'should prevent from browser redirecting when an image is dropped on another image', () => {
const spy = sinon.spy();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,14 @@ class Adapter {
return reject( response && response.error && response.error.message ? response.error.message : genericErrorText );
}

resolve( response.url ? { default: response.url } : response.urls );
const urls = response.url ? { default: response.url } : response.urls;

// Resolve with the normalized `urls` property and pass the rest of the response
// to allow customizing the behavior of features relying on the upload adapters.
resolve( {
...response,
urls
} );
} );

// Upload progress when it is supported.
Expand Down
14 changes: 14 additions & 0 deletions packages/ckeditor5-upload/src/filerepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,20 @@ mix( FileLoader, ObservableMixin );
* '1052': 'http://server/default-size.image.png'
* }
*
* You can also pass additional properties from the server. In this case you need to wrap URLs
* in the `urls` object and pass additional properties along the `urls` property.
*
* {
* myCustomProperty: 'foo',
* urls: {
* default: 'http://server/default-size.image.png',
* '160': 'http://server/size-160.image.png',
* '500': 'http://server/size-500.image.png',
* '1000': 'http://server/size-1000.image.png',
* '1052': 'http://server/default-size.image.png'
* }
* }
*
* NOTE: When returning multiple images, the widest returned one should equal the default one. It is essential to
* correctly set `width` attribute of the image. See this discussion:
* https://github.com/ckeditor/ckeditor5-easy-image/issues/4 for more information.
Expand Down
Loading

0 comments on commit bf5b561

Please sign in to comment.