diff --git a/src/imageupload/imageuploadediting.js b/src/imageupload/imageuploadediting.js
index 4a9d4b81..20fa317f 100644
--- a/src/imageupload/imageuploadediting.js
+++ b/src/imageupload/imageuploadediting.js
@@ -10,9 +10,11 @@
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
+import UpcastWriter from '@ckeditor/ckeditor5-engine/src/view/upcastwriter';
+import { upcastAttributeToAttribute } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
import ImageUploadCommand from '../../src/imageupload/imageuploadcommand';
-import { isImageType } from '../../src/imageupload/utils';
+import { isImageType, isLocalImage, fetchLocalImage } from '../../src/imageupload/utils';
/**
* The editing part of the image upload feature. It registers the `'imageUpload'` command.
@@ -34,6 +36,7 @@ export default class ImageUploadEditing extends Plugin {
const editor = this.editor;
const doc = editor.model.document;
const schema = editor.model.schema;
+ const conversion = editor.conversion;
const fileRepository = editor.plugins.get( FileRepository );
// Setup schema to allow uploadId and uploadStatus for images.
@@ -44,6 +47,16 @@ export default class ImageUploadEditing extends Plugin {
// Register imageUpload command.
editor.commands.add( 'imageUpload', new ImageUploadCommand( editor ) );
+ // Register upcast converter for uploadId.
+ conversion.for( 'upcast' )
+ .add( upcastAttributeToAttribute( {
+ view: {
+ name: 'img',
+ key: 'uploadId'
+ },
+ model: 'uploadId'
+ } ) );
+
// Handle pasted images.
// For every image file, a new file loader is created and a placeholder image is
// inserted into the content. Then, those images are uploaded once they appear in the model
@@ -81,6 +94,48 @@ export default class ImageUploadEditing extends Plugin {
} );
} );
+ // Handle HTML pasted with images with base64 or blob sources.
+ // For every image file, a new file loader is created and a placeholder image is
+ // inserted into the content. Then, those images are uploaded once they appear in the model
+ // (see Document#change listener below).
+ this.listenTo( editor.plugins.get( 'Clipboard' ), 'inputTransformation', ( evt, data ) => {
+ const fetchableImages = Array.from( editor.editing.view.createRangeIn( data.content ) )
+ .filter( value => isLocalImage( value.item ) && !value.item.getAttribute( 'uploadProcessed' ) )
+ .map( value => fetchLocalImage( value.item ) );
+
+ if ( !fetchableImages.length ) {
+ return;
+ }
+
+ evt.stop();
+
+ Promise.all( fetchableImages ).then( items => {
+ const writer = new UpcastWriter();
+
+ for ( const item of items ) {
+ if ( !item.file ) {
+ // Failed to fetch image or create a file instance, remove image element.
+ writer.remove( item.image );
+ } else {
+ // Set attribute marking the image as processed.
+ writer.setAttribute( 'uploadProcessed', true, item.image );
+
+ const loader = fileRepository.createLoader( item.file );
+
+ if ( loader ) {
+ writer.setAttribute( 'src', '', item.image );
+ writer.setAttribute( 'uploadId', loader.id, item.image );
+ }
+ }
+ }
+
+ editor.plugins.get( 'Clipboard' ).fire( 'inputTransformation', {
+ content: data.content,
+ dataTransfer: data.dataTransfer
+ } );
+ } );
+ } );
+
// Prevents from the browser redirecting to the dropped image.
editor.editing.view.document.on( 'dragover', ( evt, data ) => {
data.preventDefault();
@@ -163,33 +218,7 @@ export default class ImageUploadEditing extends Plugin {
.then( data => {
model.enqueueChange( 'transparent', writer => {
writer.setAttributes( { uploadStatus: 'complete', src: data.default }, imageElement );
-
- // Srcset attribute for responsive images support.
- let maxWidth = 0;
- const srcsetAttribute = Object.keys( data )
- // Filter out keys that are not integers.
- .filter( key => {
- const width = parseInt( key, 10 );
-
- if ( !isNaN( width ) ) {
- maxWidth = Math.max( maxWidth, width );
-
- return true;
- }
- } )
-
- // Convert each key to srcset entry.
- .map( key => `${ data[ key ] } ${ key }w` )
-
- // Join all entries.
- .join( ', ' );
-
- if ( srcsetAttribute != '' ) {
- writer.setAttribute( 'srcset', {
- data: srcsetAttribute,
- width: maxWidth
- }, imageElement );
- }
+ this._parseAndSetSrcsetAttributeOnImage( data, imageElement, writer );
} );
clean();
@@ -226,6 +255,44 @@ export default class ImageUploadEditing extends Plugin {
fileRepository.destroyLoader( loader );
}
}
+
+ /**
+ * Creates `srcset` attribute based on a given file upload response and sets it as an attribute to a specific image element.
+ *
+ * @protected
+ * @param {Object} data Data object from which `srcset` will be created.
+ * @param {module:engine/model/element~Element} image The image element on which `srcset` attribute will be set.
+ * @param {module:engine/model/writer~Writer} writer
+ */
+ _parseAndSetSrcsetAttributeOnImage( data, image, writer ) {
+ // Srcset attribute for responsive images support.
+ let maxWidth = 0;
+
+ const srcsetAttribute = Object.keys( data )
+ // Filter out keys that are not integers.
+ .filter( key => {
+ const width = parseInt( key, 10 );
+
+ if ( !isNaN( width ) ) {
+ maxWidth = Math.max( maxWidth, width );
+
+ return true;
+ }
+ } )
+
+ // Convert each key to srcset entry.
+ .map( key => `${ data[ key ] } ${ key }w` )
+
+ // Join all entries.
+ .join( ', ' );
+
+ if ( srcsetAttribute != '' ) {
+ writer.setAttribute( 'srcset', {
+ data: srcsetAttribute,
+ width: maxWidth
+ }, image );
+ }
+ }
}
// Returns `true` if non-empty `text/html` is included in the data transfer.
diff --git a/src/imageupload/utils.js b/src/imageupload/utils.js
index 89943aa4..fc61910c 100644
--- a/src/imageupload/utils.js
+++ b/src/imageupload/utils.js
@@ -7,6 +7,8 @@
* @module image/imageupload/utils
*/
+/* global fetch, File */
+
/**
* Checks if a given file is an image.
*
@@ -18,3 +20,80 @@ export function isImageType( file ) {
return types.test( file.type );
}
+
+/**
+ * Creates a promise which fetches the image local source (base64 or blob) and returns as a `File` object.
+ *
+ * @param {module:engine/view/element~Element} image Image which source to fetch.
+ * @returns {Promise} A promise which resolves when image source is fetched and converted to `File` instance.
+ * It resolves with object holding initial image element (as `image`) and its file source (as `file`). If
+ * the `file` attribute is null, it means fetching failed.
+ */
+export function fetchLocalImage( image ) {
+ return new Promise( resolve => {
+ // Fetch works asynchronously and so does not block browser UI when processing data.
+ fetch( image.getAttribute( 'src' ) )
+ .then( resource => resource.blob() )
+ .then( blob => {
+ const mimeType = getImageMimeType( blob, image.getAttribute( 'src' ) );
+ const ext = mimeType.replace( 'image/', '' );
+ const filename = `image.${ ext }`;
+ const file = createFileFromBlob( blob, filename, mimeType );
+
+ resolve( { image, file } );
+ } )
+ .catch( () => {
+ // We always resolve a promise so `Promise.all` will not reject if one of many fetch fails.
+ resolve( { image, file: null } );
+ } );
+ } );
+}
+
+/**
+ * Checks whether given node is an image element with local source (base64 or blob).
+ *
+ * @param {module:engine/view/node~Node} node Node to check.
+ * @returns {Boolean}
+ */
+export function isLocalImage( node ) {
+ if ( !node.is( 'element', 'img' ) || !node.getAttribute( 'src' ) ) {
+ return false;
+ }
+
+ return node.getAttribute( 'src' ).match( /^data:image\/\w+;base64,/g ) ||
+ node.getAttribute( 'src' ).match( /^blob:/g );
+}
+
+// Extracts image type based on its blob representation or its source.
+//
+// @param {String} src Image src attribute value.
+// @param {Blob} blob Image blob representation.
+// @returns {String}
+function getImageMimeType( blob, src ) {
+ if ( blob.type ) {
+ return blob.type;
+ } else if ( src.match( /data:(image\/\w+);base64/ ) ) {
+ return src.match( /data:(image\/\w+);base64/ )[ 1 ].toLowerCase();
+ } else {
+ // Fallback to 'jpeg' as common extension.
+ return 'image/jpeg';
+ }
+}
+
+// Creates `File` instance from the given `Blob` instance using specified filename.
+//
+// @param {Blob} blob The `Blob` instance from which file will be created.
+// @param {String} filename Filename used during file creation.
+// @param {String} mimeType File mime type.
+// @returns {File|null} The `File` instance created from the given blob or `null` if `File API` is not available.
+function createFileFromBlob( blob, filename, mimeType ) {
+ try {
+ return new File( [ blob ], filename, { type: mimeType } );
+ } catch ( err ) {
+ // Edge does not support `File` constructor ATM, see https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/9551546/.
+ // However, the `File` function is present (so cannot be checked with `!window.File` or `typeof File === 'function'`), but
+ // calling it with `new File( ... )` throws an error. This try-catch prevents that. Also when the function will
+ // be implemented correctly in Edge the code will start working without any changes (see #247).
+ return null;
+ }
+}
diff --git a/tests/imageupload.js b/tests/imageupload.js
index 79ef97a5..a63f6e98 100644
--- a/tests/imageupload.js
+++ b/tests/imageupload.js
@@ -6,6 +6,7 @@
/* globals document */
import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
import Image from '../src/image';
import ImageUpload from '../src/imageupload';
import ImageUploadEditing from '../src/imageupload/imageuploadediting';
@@ -23,7 +24,7 @@ describe( 'ImageUpload', () => {
return ClassicEditor
.create( editorElement, {
- plugins: [ Image, ImageUpload, UploadAdapterPluginMock ]
+ plugins: [ Image, ImageUpload, UploadAdapterPluginMock, Clipboard ]
} )
.then( newEditor => {
editor = newEditor;
diff --git a/tests/imageupload/imageuploadediting.js b/tests/imageupload/imageuploadediting.js
index c2c0fc35..afaafe35 100644
--- a/tests/imageupload/imageuploadediting.js
+++ b/tests/imageupload/imageuploadediting.js
@@ -3,7 +3,7 @@
* For licensing, see LICENSE.md.
*/
-/* globals window, setTimeout */
+/* globals window, setTimeout, atob, URL, Blob */
import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
@@ -30,7 +30,10 @@ import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
describe( 'ImageUploadEditing', () => {
// eslint-disable-next-line max-len
const base64Sample = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
- let editor, model, view, doc, fileRepository, viewDocument, nativeReaderMock, loader, adapterMock;
+ const isEdgeEnv = env.isEdge;
+
+ let adapterMocks = [];
+ let editor, model, view, doc, fileRepository, viewDocument, nativeReaderMock, loader;
testUtils.createSinonSandbox();
@@ -39,7 +42,9 @@ describe( 'ImageUploadEditing', () => {
fileRepository = this.editor.plugins.get( FileRepository );
fileRepository.createUploadAdapter = newLoader => {
loader = newLoader;
- adapterMock = new UploadAdapterMock( loader );
+ const adapterMock = new UploadAdapterMock( loader );
+
+ adapterMocks.push( adapterMock );
return adapterMock;
};
@@ -47,6 +52,12 @@ describe( 'ImageUploadEditing', () => {
}
beforeEach( () => {
+ if ( isEdgeEnv ) {
+ testUtils.sinon.stub( window, 'File' ).callsFake( () => {
+ return { name: 'file.jpg' };
+ } );
+ }
+
// Most tests assume non-edge environment but we do not set `contenteditable=false` on Edge so stub `env.isEdge`.
testUtils.sinon.stub( env, 'isEdge' ).get( () => false );
@@ -58,7 +69,7 @@ describe( 'ImageUploadEditing', () => {
return VirtualTestEditor
.create( {
- plugins: [ ImageEditing, ImageUploadEditing, Paragraph, UndoEditing, UploadAdapterPluginMock ]
+ plugins: [ ImageEditing, ImageUploadEditing, Paragraph, UndoEditing, UploadAdapterPluginMock, Clipboard ]
} )
.then( newEditor => {
editor = newEditor;
@@ -66,10 +77,15 @@ describe( 'ImageUploadEditing', () => {
doc = model.document;
view = editor.editing.view;
viewDocument = view.document;
+
+ // Stub `view.scrollToTheSelection` as it will fail on VirtualTestEditor without DOM.
+ testUtils.sinon.stub( view, 'scrollToTheSelection' ).callsFake( () => {} );
} );
} );
afterEach( () => {
+ adapterMocks = [];
+
return editor.destroy();
} );
@@ -185,7 +201,11 @@ describe( 'ImageUploadEditing', () => {
type: 'media/mp3',
size: 1024
};
- const dataTransfer = new DataTransfer( { files: [ fileMock ], types: [ 'Files' ] } );
+ const dataTransfer = new DataTransfer( {
+ files: [ fileMock ],
+ types: [ 'Files' ],
+ getData: () => ''
+ } );
setModelData( model, '
bar
`; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + it( 'should upload image with blob src', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + const id = adapterMocks[ 0 ].loader.id; + const expected = `[bar
` + + ``; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + // Stub `fetch` in a way that 2 first calls are successful and 3rd fails. + let counter = 0; + const fetch = window.fetch; + testUtils.sinon.stub( window, 'fetch' ).callsFake( src => { + counter++; + if ( counter < 3 ) { + return fetch( src ); + } else { + return new Promise( ( res, rej ) => rej() ); + } + } ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + it( 'should not upload and remove image when `File` constructor is not present', done => { + const fileFn = window.File; + + window.File = undefined; + + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + window.File = fileFn; + + const expected = 'baz
`; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + it( 'should not upload and remove image when `File` constructor is not supported', done => { + const fileFn = window.File; + + window.File = function() { + throw new Error( 'Function expected.' ); // Simulating Edge browser behaviour here. + }; + + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + window.File = fileFn; + + const expected = 'baz
`; + const dataTransfer = mockDataTransfer( clipboardHtml ); + + const targetRange = model.createRange( model.createPositionAt( doc.getRoot(), 1 ), model.createPositionAt( doc.getRoot(), 1 ) ); + const targetViewRange = editor.editing.mapper.toViewRange( targetRange ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + // Skip this test on Edge as we mock `File` object there so there is no sense in testing it. + ( isEdgeEnv ? it.skip : it )( 'should get file extension from base64 string', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + try { + expect( loader.file.name.split( '.' ).pop() ).to.equal( 'png' ); + done(); + } catch ( err ) { + done( err ); + } + }, { priority: 'low' } ); + + setModelData( model, '