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, 'foo[]' ); @@ -199,7 +219,7 @@ describe( 'ImageUploadEditing', () => { it( 'should not insert image when file is null', () => { const viewDocument = editor.editing.view.document; - const dataTransfer = new DataTransfer( { files: [ null ], types: [ 'Files' ] } ); + const dataTransfer = new DataTransfer( { files: [ null ], types: [ 'Files' ], getData: () => null } ); setModelData( model, 'foo[]' ); @@ -225,7 +245,7 @@ describe( 'ImageUploadEditing', () => { viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); - expect( getModelData( model ) ).to.equal( '[]foo' ); + expect( getModelData( model ) ).to.equal( 'SomeData[]foo' ); } ); it( 'should not insert image nor crash when pasted image could not be inserted', () => { @@ -289,7 +309,7 @@ describe( 'ImageUploadEditing', () => { viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); // Well, there's no clipboard plugin, so nothing happens. - expect( getModelData( model ) ).to.equal( '[]foo' ); + expect( getModelData( model ) ).to.equal( 'SomeData[]foo' ); } ); it( 'should not convert image\'s uploadId attribute if is consumed already', () => { @@ -340,7 +360,7 @@ describe( 'ImageUploadEditing', () => { done(); }, { priority: 'lowest' } ); - adapterMock.mockSuccess( { default: 'image.png' } ); + adapterMocks[ 0 ].mockSuccess( { default: 'image.png' } ); } ); nativeReaderMock.mockSuccess( base64Sample ); @@ -547,7 +567,7 @@ describe( 'ImageUploadEditing', () => { done(); }, { priority: 'lowest' } ); - adapterMock.mockSuccess( { default: 'image.png', 500: 'image-500.png', 800: 'image-800.png' } ); + adapterMocks[ 0 ].mockSuccess( { default: 'image.png', 500: 'image-500.png', 800: 'image-800.png' } ); } ); nativeReaderMock.mockSuccess( base64Sample ); @@ -562,4 +582,292 @@ describe( 'ImageUploadEditing', () => { expect( spy.calledOnce ).to.equal( true ); } ); + + it( 'should upload image with base64 src', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + const id = adapterMocks[ 0 ].loader.id; + const expected = 'bar' + + `[]` + + 'foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = `

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 = `[]` + + 'foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = ``; + 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 image if no loader available', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + const expected = `[]foo`; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + testUtils.sinon.stub( fileRepository, 'createLoader' ).callsFake( () => null ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = ``; + 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 if fetch failed', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + const expected = '[]foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = ``; + 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` so it can be rejected. + testUtils.sinon.stub( window, 'fetch' ).callsFake( () => { + return new Promise( ( res, rej ) => rej() ); + } ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); + + it( 'should upload only images which were successfully fetched and remove failed ones', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + const expected = 'bar' + + `` + + `[]` + + 'foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = `

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[]foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = `

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[]foo'; + + expectModel( done, getModelData( model ), expected ); + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = `

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, '[]foo' ); + + const clipboardHtml = ``; + 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` to return custom blob without type. + testUtils.sinon.stub( window, 'fetch' ).callsFake( () => { + return new Promise( res => res( { + blob() { + return new Promise( res => res( new Blob( [ 'foo', 'bar' ] ) ) ); + } + } ) ); + } ); + + 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 use fallback file extension', done => { + editor.plugins.get( 'Clipboard' ).on( 'inputTransformation', () => { + try { + expect( loader.file.name.split( '.' ).pop() ).to.equal( 'jpeg' ); + done(); + } catch ( err ) { + done( err ); + } + }, { priority: 'low' } ); + + setModelData( model, '[]foo' ); + + const clipboardHtml = ``; + 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` to return custom blob without type. + testUtils.sinon.stub( window, 'fetch' ).callsFake( () => { + return new Promise( res => res( { + blob() { + return new Promise( res => res( new Blob( [ 'foo', 'bar' ] ) ) ); + } + } ) ); + } ); + + viewDocument.fire( 'clipboardInput', { dataTransfer, targetRanges: [ targetViewRange ] } ); + } ); } ); + +// Asserts actual and expected model data. +// Note: Since this function is run inside a promise, all errors needs to be caught +// and rethrow to be correctly processed by a testing framework. +// +// @param {function} done Callback function to be called when assertion is done. +// @param {String} actual Actual model data. +// @param {String} expected Expected model data. +function expectModel( done, actual, expected ) { + try { + expect( actual ).to.equal( expected ); + done(); + } catch ( err ) { + done( err ); + } +} + +// Creates data transfer object with predefined data. +// +// @param {String} content The content returned as `text/html` when queried. +// @returns {module:clipboard/datatransfer~DataTransfer} DataTransfer object. +function mockDataTransfer( content ) { + return new DataTransfer( { + types: [ 'text/html' ], + getData: type => type === 'text/html' ? content : '' + } ); +} + +// Creates blob url from the given base64 data. +// +// @param {String} base64 The base64 string from which blob url will be generated. +// @returns {String} Blob url. +function base64ToBlobUrl( base64 ) { + return URL.createObjectURL( base64ToBlob( base64.trim() ) ); +} + +// Transforms base64 data into a blob object. +// +// @param {String} The base64 data to be transformed. +// @returns {Blob} Blob object representing given base64 data. +function base64ToBlob( base64Data ) { + const [ type, data ] = base64Data.split( ',' ); + const byteCharacters = atob( data ); + const byteArrays = []; + + for ( let offset = 0; offset < byteCharacters.length; offset += 512 ) { + const slice = byteCharacters.slice( offset, offset + 512 ); + const byteNumbers = new Array( slice.length ); + + for ( let i = 0; i < slice.length; i++ ) { + byteNumbers[ i ] = slice.charCodeAt( i ); + } + + byteArrays.push( new Uint8Array( byteNumbers ) ); + } + + return new Blob( byteArrays, { type } ); +} diff --git a/tests/imageupload/imageuploadprogress.js b/tests/imageupload/imageuploadprogress.js index 6715af20..4eb71454 100644 --- a/tests/imageupload/imageuploadprogress.js +++ b/tests/imageupload/imageuploadprogress.js @@ -13,6 +13,7 @@ import ImageUploadEditing from '../../src/imageupload/imageuploadediting'; import ImageUploadProgress from '../../src/imageupload/imageuploadprogress'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import { UploadAdapterMock, createNativeFileMock, NativeFileReaderMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks'; import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; @@ -54,7 +55,7 @@ describe( 'ImageUploadProgress', () => { return VirtualTestEditor .create( { - plugins: [ ImageEditing, Paragraph, ImageUploadEditing, ImageUploadProgress, UploadAdapterPluginMock ] + plugins: [ ImageEditing, Paragraph, ImageUploadEditing, ImageUploadProgress, UploadAdapterPluginMock, Clipboard ] } ) .then( newEditor => { editor = newEditor; diff --git a/tests/imageupload/imageuploadui.js b/tests/imageupload/imageuploadui.js index 7ae0f074..9b3cabc9 100644 --- a/tests/imageupload/imageuploadui.js +++ b/tests/imageupload/imageuploadui.js @@ -15,6 +15,7 @@ import ImageUploadUI from '../../src/imageupload/imageuploadui'; import ImageUploadEditing from '../../src/imageupload/imageuploadediting'; import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification'; +import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard'; import { createNativeFileMock, UploadAdapterMock } from '@ckeditor/ckeditor5-upload/tests/_utils/mocks'; import { setData as setModelData, getData as getModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; @@ -37,7 +38,7 @@ describe( 'ImageUploadUI', () => { return ClassicEditor .create( editorElement, { - plugins: [ Paragraph, Image, ImageUploadEditing, ImageUploadUI, FileRepository, UploadAdapterPluginMock ] + plugins: [ Paragraph, Image, ImageUploadEditing, ImageUploadUI, FileRepository, UploadAdapterPluginMock, Clipboard ] } ) .then( newEditor => { editor = newEditor;