Skip to content
This repository was archived by the owner on Jun 26, 2020. It is now read-only.

Commit 89ab27e

Browse files
authored
Merge pull request #248 from ckeditor/t/246
Feature: Support for uploading images with base64 source. Closes #246. Closes ckeditor/ckeditor5-paste-from-office#24.
2 parents cc1e7a3 + cd2a8fd commit 89ab27e

File tree

6 files changed

+498
-41
lines changed

6 files changed

+498
-41
lines changed

src/imageupload/imageuploadediting.js

Lines changed: 95 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
1111
import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
1212
import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
13+
import UpcastWriter from '@ckeditor/ckeditor5-engine/src/view/upcastwriter';
14+
import { upcastAttributeToAttribute } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
1315

1416
import ImageUploadCommand from '../../src/imageupload/imageuploadcommand';
15-
import { isImageType } from '../../src/imageupload/utils';
17+
import { isImageType, isLocalImage, fetchLocalImage } from '../../src/imageupload/utils';
1618

1719
/**
1820
* The editing part of the image upload feature. It registers the `'imageUpload'` command.
@@ -34,6 +36,7 @@ export default class ImageUploadEditing extends Plugin {
3436
const editor = this.editor;
3537
const doc = editor.model.document;
3638
const schema = editor.model.schema;
39+
const conversion = editor.conversion;
3740
const fileRepository = editor.plugins.get( FileRepository );
3841

3942
// Setup schema to allow uploadId and uploadStatus for images.
@@ -44,6 +47,16 @@ export default class ImageUploadEditing extends Plugin {
4447
// Register imageUpload command.
4548
editor.commands.add( 'imageUpload', new ImageUploadCommand( editor ) );
4649

50+
// Register upcast converter for uploadId.
51+
conversion.for( 'upcast' )
52+
.add( upcastAttributeToAttribute( {
53+
view: {
54+
name: 'img',
55+
key: 'uploadId'
56+
},
57+
model: 'uploadId'
58+
} ) );
59+
4760
// Handle pasted images.
4861
// For every image file, a new file loader is created and a placeholder image is
4962
// 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 {
8194
} );
8295
} );
8396

97+
// Handle HTML pasted with images with base64 or blob sources.
98+
// For every image file, a new file loader is created and a placeholder image is
99+
// inserted into the content. Then, those images are uploaded once they appear in the model
100+
// (see Document#change listener below).
101+
this.listenTo( editor.plugins.get( 'Clipboard' ), 'inputTransformation', ( evt, data ) => {
102+
const fetchableImages = Array.from( editor.editing.view.createRangeIn( data.content ) )
103+
.filter( value => isLocalImage( value.item ) && !value.item.getAttribute( 'uploadProcessed' ) )
104+
.map( value => fetchLocalImage( value.item ) );
105+
106+
if ( !fetchableImages.length ) {
107+
return;
108+
}
109+
110+
evt.stop();
111+
112+
Promise.all( fetchableImages ).then( items => {
113+
const writer = new UpcastWriter();
114+
115+
for ( const item of items ) {
116+
if ( !item.file ) {
117+
// Failed to fetch image or create a file instance, remove image element.
118+
writer.remove( item.image );
119+
} else {
120+
// Set attribute marking the image as processed.
121+
writer.setAttribute( 'uploadProcessed', true, item.image );
122+
123+
const loader = fileRepository.createLoader( item.file );
124+
125+
if ( loader ) {
126+
writer.setAttribute( 'src', '', item.image );
127+
writer.setAttribute( 'uploadId', loader.id, item.image );
128+
}
129+
}
130+
}
131+
132+
editor.plugins.get( 'Clipboard' ).fire( 'inputTransformation', {
133+
content: data.content,
134+
dataTransfer: data.dataTransfer
135+
} );
136+
} );
137+
} );
138+
84139
// Prevents from the browser redirecting to the dropped image.
85140
editor.editing.view.document.on( 'dragover', ( evt, data ) => {
86141
data.preventDefault();
@@ -163,33 +218,7 @@ export default class ImageUploadEditing extends Plugin {
163218
.then( data => {
164219
model.enqueueChange( 'transparent', writer => {
165220
writer.setAttributes( { uploadStatus: 'complete', src: data.default }, imageElement );
166-
167-
// Srcset attribute for responsive images support.
168-
let maxWidth = 0;
169-
const srcsetAttribute = Object.keys( data )
170-
// Filter out keys that are not integers.
171-
.filter( key => {
172-
const width = parseInt( key, 10 );
173-
174-
if ( !isNaN( width ) ) {
175-
maxWidth = Math.max( maxWidth, width );
176-
177-
return true;
178-
}
179-
} )
180-
181-
// Convert each key to srcset entry.
182-
.map( key => `${ data[ key ] } ${ key }w` )
183-
184-
// Join all entries.
185-
.join( ', ' );
186-
187-
if ( srcsetAttribute != '' ) {
188-
writer.setAttribute( 'srcset', {
189-
data: srcsetAttribute,
190-
width: maxWidth
191-
}, imageElement );
192-
}
221+
this._parseAndSetSrcsetAttributeOnImage( data, imageElement, writer );
193222
} );
194223

195224
clean();
@@ -226,6 +255,44 @@ export default class ImageUploadEditing extends Plugin {
226255
fileRepository.destroyLoader( loader );
227256
}
228257
}
258+
259+
/**
260+
* Creates `srcset` attribute based on a given file upload response and sets it as an attribute to a specific image element.
261+
*
262+
* @protected
263+
* @param {Object} data Data object from which `srcset` will be created.
264+
* @param {module:engine/model/element~Element} image The image element on which `srcset` attribute will be set.
265+
* @param {module:engine/model/writer~Writer} writer
266+
*/
267+
_parseAndSetSrcsetAttributeOnImage( data, image, writer ) {
268+
// Srcset attribute for responsive images support.
269+
let maxWidth = 0;
270+
271+
const srcsetAttribute = Object.keys( data )
272+
// Filter out keys that are not integers.
273+
.filter( key => {
274+
const width = parseInt( key, 10 );
275+
276+
if ( !isNaN( width ) ) {
277+
maxWidth = Math.max( maxWidth, width );
278+
279+
return true;
280+
}
281+
} )
282+
283+
// Convert each key to srcset entry.
284+
.map( key => `${ data[ key ] } ${ key }w` )
285+
286+
// Join all entries.
287+
.join( ', ' );
288+
289+
if ( srcsetAttribute != '' ) {
290+
writer.setAttribute( 'srcset', {
291+
data: srcsetAttribute,
292+
width: maxWidth
293+
}, image );
294+
}
295+
}
229296
}
230297

231298
// Returns `true` if non-empty `text/html` is included in the data transfer.

src/imageupload/utils.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* @module image/imageupload/utils
88
*/
99

10+
/* global fetch, File */
11+
1012
/**
1113
* Checks if a given file is an image.
1214
*
@@ -18,3 +20,80 @@ export function isImageType( file ) {
1820

1921
return types.test( file.type );
2022
}
23+
24+
/**
25+
* Creates a promise which fetches the image local source (base64 or blob) and returns as a `File` object.
26+
*
27+
* @param {module:engine/view/element~Element} image Image which source to fetch.
28+
* @returns {Promise} A promise which resolves when image source is fetched and converted to `File` instance.
29+
* It resolves with object holding initial image element (as `image`) and its file source (as `file`). If
30+
* the `file` attribute is null, it means fetching failed.
31+
*/
32+
export function fetchLocalImage( image ) {
33+
return new Promise( resolve => {
34+
// Fetch works asynchronously and so does not block browser UI when processing data.
35+
fetch( image.getAttribute( 'src' ) )
36+
.then( resource => resource.blob() )
37+
.then( blob => {
38+
const mimeType = getImageMimeType( blob, image.getAttribute( 'src' ) );
39+
const ext = mimeType.replace( 'image/', '' );
40+
const filename = `image.${ ext }`;
41+
const file = createFileFromBlob( blob, filename, mimeType );
42+
43+
resolve( { image, file } );
44+
} )
45+
.catch( () => {
46+
// We always resolve a promise so `Promise.all` will not reject if one of many fetch fails.
47+
resolve( { image, file: null } );
48+
} );
49+
} );
50+
}
51+
52+
/**
53+
* Checks whether given node is an image element with local source (base64 or blob).
54+
*
55+
* @param {module:engine/view/node~Node} node Node to check.
56+
* @returns {Boolean}
57+
*/
58+
export function isLocalImage( node ) {
59+
if ( !node.is( 'element', 'img' ) || !node.getAttribute( 'src' ) ) {
60+
return false;
61+
}
62+
63+
return node.getAttribute( 'src' ).match( /^data:image\/\w+;base64,/g ) ||
64+
node.getAttribute( 'src' ).match( /^blob:/g );
65+
}
66+
67+
// Extracts image type based on its blob representation or its source.
68+
//
69+
// @param {String} src Image src attribute value.
70+
// @param {Blob} blob Image blob representation.
71+
// @returns {String}
72+
function getImageMimeType( blob, src ) {
73+
if ( blob.type ) {
74+
return blob.type;
75+
} else if ( src.match( /data:(image\/\w+);base64/ ) ) {
76+
return src.match( /data:(image\/\w+);base64/ )[ 1 ].toLowerCase();
77+
} else {
78+
// Fallback to 'jpeg' as common extension.
79+
return 'image/jpeg';
80+
}
81+
}
82+
83+
// Creates `File` instance from the given `Blob` instance using specified filename.
84+
//
85+
// @param {Blob} blob The `Blob` instance from which file will be created.
86+
// @param {String} filename Filename used during file creation.
87+
// @param {String} mimeType File mime type.
88+
// @returns {File|null} The `File` instance created from the given blob or `null` if `File API` is not available.
89+
function createFileFromBlob( blob, filename, mimeType ) {
90+
try {
91+
return new File( [ blob ], filename, { type: mimeType } );
92+
} catch ( err ) {
93+
// Edge does not support `File` constructor ATM, see https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/9551546/.
94+
// However, the `File` function is present (so cannot be checked with `!window.File` or `typeof File === 'function'`), but
95+
// calling it with `new File( ... )` throws an error. This try-catch prevents that. Also when the function will
96+
// be implemented correctly in Edge the code will start working without any changes (see #247).
97+
return null;
98+
}
99+
}

tests/imageupload.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
/* globals document */
77

88
import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
9+
import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
910
import Image from '../src/image';
1011
import ImageUpload from '../src/imageupload';
1112
import ImageUploadEditing from '../src/imageupload/imageuploadediting';
@@ -23,7 +24,7 @@ describe( 'ImageUpload', () => {
2324

2425
return ClassicEditor
2526
.create( editorElement, {
26-
plugins: [ Image, ImageUpload, UploadAdapterPluginMock ]
27+
plugins: [ Image, ImageUpload, UploadAdapterPluginMock, Clipboard ]
2728
} )
2829
.then( newEditor => {
2930
editor = newEditor;

0 commit comments

Comments
 (0)