diff --git a/CHANGES.md b/CHANGES.md index b145b8ec7d8..b7fa1ec5a75 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -21,6 +21,7 @@ Fixed Issues: * [#1592](https://github.com/ckeditor/ckeditor-dev/issues/1592): [Image Base](https://ckeditor.com/cke4/addon/imagebase) caption is not visible after paste. * [#620](https://github.com/ckeditor/ckeditor-dev/issues/620): Fixed: [`forcePasteAsPlainText`](https://docs.ckeditor.com/ckeditor4/latest/api/CKEDITOR_config.html#cfg-forcePasteAsPlainText) will be respected when internal and cross-editor pasting happen. * [#1467](https://github.com/ckeditor/ckeditor-dev/issues/1467): Fixed: [Table Resize](https://ckeditor.com/cke4/addon/tableresize) resizing coursor appearing in middle of merged cell. +* [#1134](https://github.com/ckeditor/ckeditor-dev/issues/1134): [Safari] Fixed: [Paste from Word](https://ckeditor.com/cke4/addon/pastefromword) does not embed images in Safari browser. API Changes: diff --git a/core/tools.js b/core/tools.js index a4487f772e4..894d1473b4f 100644 --- a/core/tools.js +++ b/core/tools.js @@ -1596,6 +1596,40 @@ return base64string; }, + /** + * Return file type based on first 4 bytes of given file. Currently supported file types: `image/png`, `image/jpeg`, `image/gif`. + * + * @since 4.10.0 + * @param {Uint8Array} bytesArray Typed array which will be analysed to obtain file type. + * @returns {String/Null} File type recognized from given typed array or null. + */ + getFileTypeFromHeader: function( bytesArray ) { + var header = '', + fileType = null, + bytesHeader = bytesArray.subarray( 0, 4 ); + + for ( var i = 0; i < bytesHeader.length; i++ ) { + header += bytesHeader[ i ].toString( 16 ); + } + + switch ( header ) { + case '89504e47': + fileType = 'image/png'; + break; + case '47494638': + fileType = 'image/gif'; + break; + case 'ffd8ffe0': + case 'ffd8ffe1': + case 'ffd8ffe2': + case 'ffd8ffe3': + case 'ffd8ffe8': + fileType = 'image/jpeg'; + break; + } + return fileType; + }, + /** * A set of functions for operations on styles. * diff --git a/plugins/ajax/plugin.js b/plugins/ajax/plugin.js index f16487ee1c8..85d3cbaaa28 100644 --- a/plugins/ajax/plugin.js +++ b/plugins/ajax/plugin.js @@ -51,21 +51,25 @@ return ( xhr.readyState == 4 && ( ( xhr.status >= 200 && xhr.status < 300 ) || xhr.status == 304 || xhr.status === 0 || xhr.status == 1223 ) ); } - function getResponseText( xhr ) { - if ( checkStatus( xhr ) ) - return xhr.responseText; - return null; - } + function getResponse( xhr, type ) { + if ( !checkStatus( xhr ) ) { + return null; + } - function getResponseXml( xhr ) { - if ( checkStatus( xhr ) ) { - var xml = xhr.responseXML; - return new CKEDITOR.xml( xml && xml.firstChild ? xml : xhr.responseText ); + switch ( type ) { + case 'text': + return xhr.responseText; + case 'xml': + var xml = xhr.responseXML; + return new CKEDITOR.xml( xml && xml.firstChild ? xml : xhr.responseText ); + case 'arraybuffer': + return xhr.response; + default: + return null; } - return null; } - function load( url, callback, getResponseFn ) { + function load( url, callback, responseType ) { var async = !!callback; var xhr = createXMLHttpRequest(); @@ -73,13 +77,17 @@ if ( !xhr ) return null; + if ( async && responseType !== 'text' && responseType !== 'xml' ) { + xhr.responseType = responseType; + } + xhr.open( 'GET', url, async ); if ( async ) { // TODO: perform leak checks on this closure. xhr.onreadystatechange = function() { if ( xhr.readyState == 4 ) { - callback( getResponseFn( xhr ) ); + callback( getResponse( xhr, responseType ) ); xhr = null; } }; @@ -87,10 +95,10 @@ xhr.send( null ); - return async ? '' : getResponseFn( xhr ); + return async ? '' : getResponse( xhr, responseType ); } - function post( url, data, contentType, callback, getResponseFn ) { + function post( url, data, contentType, callback, responseType ) { var xhr = createXMLHttpRequest(); if ( !xhr ) @@ -101,7 +109,7 @@ xhr.onreadystatechange = function() { if ( xhr.readyState == 4 ) { if ( callback ) { - callback( getResponseFn( xhr ) ); + callback( getResponse( xhr, responseType ) ); } xhr = null; } @@ -128,12 +136,16 @@ * @param {String} url The URL from which the data is loaded. * @param {Function} [callback] A callback function to be called on * data load. If not provided, the data will be loaded - * synchronously. - * @returns {String} The loaded data. For asynchronous requests, an + * synchronously. Please notice that only text data might be loaded synchrnously. + * @param {String} [responseType='text'] Defines type of returned data. + * Currently supports: `text`, `xml`, `arraybuffer`. This parameter was added in 4.10. + * @returns {String/null} The loaded data. For asynchronous requests, an * empty string. For invalid requests, `null`. */ - load: function( url, callback ) { - return load( url, callback, getResponseText ); + load: function( url, callback, responseType ) { + responseType = responseType || 'text'; + + return load( url, callback, responseType ); }, /** @@ -157,7 +169,7 @@ * depending on the `status` of the request. */ post: function( url, data, contentType, callback ) { - return post( url, data, contentType, callback, getResponseText ); + return post( url, data, contentType, callback, 'text' ); }, /** @@ -179,7 +191,27 @@ * empty string. For invalid requests, `null`. */ loadXml: function( url, callback ) { - return load( url, callback, getResponseXml ); + return load( url, callback, 'xml' ); + }, + + /** + * Converts blob url into base64 string. Conversion is happening asynchronously. + * Currently supported file types: `image/png`, `image/jpeg`, `image/gif`. + * + * @since 4.10.0 + * @param {String} blobUrlSrc Address of blob which is going to be converted + * @param {Function} callback Function to execute when blob url is be converted. + * @param {String} callback.dataUri data uri represent transformed blobUrl or empty string file type was unrecognized. + */ + convertBlobUrlToBase64: function( blobUrlSrc, callback ) { + load( blobUrlSrc, function( arrayBuffer ) { + var data = new Uint8Array( arrayBuffer ), + fileType = CKEDITOR.tools.getFileTypeFromHeader( data.subarray( 0, 4 ) ), + base64 = CKEDITOR.tools.convertBytesToBase64( data ); + + callback( fileType ? 'data:' + fileType + ';base64,' + base64 : '' ); + + } , 'arraybuffer' ); } }; } )(); diff --git a/plugins/pastefromword/filter/default.js b/plugins/pastefromword/filter/default.js index 143d877cd31..f58b9e2adba 100644 --- a/plugins/pastefromword/filter/default.js +++ b/plugins/pastefromword/filter/default.js @@ -42,7 +42,8 @@ CKEDITOR.cleanWord = function( mswordHtml, editor ) { var msoListsDetected = Boolean( mswordHtml.match( /mso-list:\s*l\d+\s+level\d+\s+lfo\d+/ ) ), - shapesIds = []; + shapesIds = [], + blobUrls = []; function shapeTagging( element ) { // Check if regular or canvas shape (#1088). @@ -140,6 +141,10 @@ return false; } + if ( element.attributes.src && element.attributes.src.match( /^blob:https?:\/\// ) ) { + blobUrls.push( element.attributes.src ); + } + }, 'p': function( element ) { element.filterChildren( filter ); @@ -515,7 +520,16 @@ fragment.writeHtml( writer ); - return writer.getHtml(); + // If there was blobUrl detected (Paste images from word in Safari browser), + // Then we need to transform those images asynchronously into base64 and replace them in editor. + if ( blobUrls.length ) { + return { + dataValue: writer.getHtml(), + blobUrls: blobUrls + }; + } else { + return writer.getHtml(); + } }; /** diff --git a/plugins/pastefromword/plugin.js b/plugins/pastefromword/plugin.js index 25a359da29c..4b901ac98d2 100755 --- a/plugins/pastefromword/plugin.js +++ b/plugins/pastefromword/plugin.js @@ -7,7 +7,7 @@ /* global confirm */ CKEDITOR.plugins.add( 'pastefromword', { - requires: 'clipboard', + requires: 'clipboard,ajax', // jscs:disable maximumLineLength lang: 'af,ar,az,bg,bn,bs,ca,cs,cy,da,de,de-ch,el,en,en-au,en-ca,en-gb,eo,es,es-mx,et,eu,fa,fi,fo,fr,fr-ca,gl,gu,he,hi,hr,hu,id,is,it,ja,ka,km,ko,ku,lt,lv,mk,mn,ms,nb,nl,no,oc,pl,pt,pt-br,ro,ru,si,sk,sl,sq,sr,sr-latn,sv,th,tr,tt,ug,uk,vi,zh,zh-cn', // %REMOVE_LINE_CORE% // jscs:enable maximumLineLength @@ -96,7 +96,16 @@ editor.fire( 'paste', data ); } else if ( !editor.config.pasteFromWordPromptCleanup || ( forceFromWord || confirm( editor.lang.pastefromword.confirmCleanup ) ) ) { - pfwEvtData.dataValue = CKEDITOR.cleanWord( pfwEvtData.dataValue, editor ); + var filteredData = CKEDITOR.cleanWord( pfwEvtData.dataValue, editor ); + + if ( typeof filteredData === 'string' ) { + pfwEvtData.dataValue = filteredData; + } else if ( typeof filteredData === 'object' ) { + pfwEvtData.dataValue = filteredData.dataValue; + handleBlobs( filteredData.blobUrls, function() { + editor.fire( 'saveSnapshot' ); + } ); + } editor.fire( 'afterPasteFromWord', pfwEvtData ); @@ -167,6 +176,45 @@ } } } + + // Method takes blob list (Array of Strings) and when finish processing it, then run callback method. + // 1. Remove blob duplicates (if exists). + // 2. Count amount of URLs to process. + // 3. For each blobUrl calculate its base64 value and store it in map blobUrl as a key and base64 as a value. + // 4. If process last blobUrl run replaceBlobUrlsInEditor and after that run callback function. + function handleBlobs( blobs, callback ) { + var arrayTools = CKEDITOR.tools.array, + blobUrlsToProcess = removeDuplicates( blobs ), + blobUrlsToBase64Map = {}, + amountOfBlobsToProcess = blobUrlsToProcess.length; + + arrayTools.forEach( blobUrlsToProcess, function( blobUrl ) { + CKEDITOR.ajax.convertBlobUrlToBase64( blobUrl, function( base64 ) { + blobUrlsToBase64Map[ blobUrl ] = base64; + amountOfBlobsToProcess--; + if ( amountOfBlobsToProcess === 0 ) { + replaceBlobUrlsInEditor( blobUrlsToBase64Map ); + callback(); + } + }, this ); + }, this ); + + function removeDuplicates( arr ) { + return arrayTools.filter( arr, function( item, index ) { + return index === arrayTools.indexOf( arr, item ); + } ); + } + + function replaceBlobUrlsInEditor( map ) { + for ( var blob in map ) { + var nodeList = editor.editable().find( 'img[src="' + blob + '"]' ).toArray(); + arrayTools.forEach( nodeList, function( element ) { + element.setAttribute( 'src', map[ blob ] ); + element.setAttribute( 'data-cke-saved-src', map[ blob ] ); + }, this ); + } + } + } } } ); diff --git a/tests/core/tools.js b/tests/core/tools.js index 372b2be1231..7eb61120e98 100644 --- a/tests/core/tools.js +++ b/tests/core/tools.js @@ -1,4 +1,5 @@ /* bender-tags: editor */ +/* bender-ckeditor-plugins: ajax */ ( function() { 'use strict'; @@ -856,7 +857,56 @@ CKEDITOR.tools.array.forEach( testCases, function( test ) { assert.areSame( test.base64, CKEDITOR.tools.convertBytesToBase64( test.bytes ) ); } ); - } + }, + + // #1134 + 'test getFileTypeFromHeader': function() { + if ( typeof Uint8Array !== 'function' ) { + assert.ignore(); + } + var test_cases = [ + { + input: '89504e47', + output: 'image/png' + }, + { + input: '47494638', + output: 'image/gif' + }, + { + input: 'ffd8ffe0', + output: 'image/jpeg' + }, + { + input: 'ffd8ffe1', + output: 'image/jpeg' + }, + { + input: 'ffd8ffe2', + output: 'image/jpeg' + }, + { + input: 'ffd8ffe3', + output: 'image/jpeg' + }, + { + input: 'ffd8ffe8', + output: 'image/jpeg' + }, + { + input: '12345678', + output: null + }, + { + input: 'ff', + output: null + } + ]; + CKEDITOR.tools.array.forEach( test_cases, function( test ) { + var header = CKEDITOR.tools.getFileTypeFromHeader( Uint8Array.from( CKEDITOR.tools.convertHexStringToBytes( test.input ) ) ); + assert.areEqual( test.output, header, 'There is problem for test case with input: ' + test.input ); + } ); + } } ); } )(); diff --git a/tests/plugins/ajax/ajax.js b/tests/plugins/ajax/ajax.js index ce8f66cbd7f..5ba70c0a5f3 100644 --- a/tests/plugins/ajax/ajax.js +++ b/tests/plugins/ajax/ajax.js @@ -179,6 +179,45 @@ } ); } ); + wait(); + }, + + // (#1134) + 'test load async arraybuffer': function() { + if ( typeof Blob !== 'function' || typeof Uint8Array !== 'function' || typeof URL !== 'function' ) { + assert.ignore(); + } + var testData = [ '0', '1', '2', '3' ]; + var blobUrl = URL.createObjectURL( new Blob( new Uint8Array( testData ) ) ); + + function cb( data ) { + resume( function() { + // Test data are saved as char codes in buffer. That's why, result is compared to 48-51. + arrayAssert.itemsAreSame( [ 48, 49, 50, 51 ], new Uint8Array( data ), 'Data in buffer are not equivalent to stored values.' ); + } ); + } + + setTimeout( function() { + CKEDITOR.ajax.load( blobUrl, cb, 'arraybuffer' ); + }, 0 ); + wait(); + }, + + // (#1134) + 'test convertBlobUrlToBase64': function() { + if ( typeof Uint8Array !== 'function' || typeof Blob !== 'function' || typeof URL !== 'function' ) { + assert.ignore(); + } + var imageHex = '89504e470d0a1a0a0000000d4948445200000001000000010804000000b51c0c020000000b4944415478da6364600000000600023081d02f0000000049454e44ae426082'; + var imageBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + var fileType = 'image/png'; + var typedArray = Uint8Array.from( CKEDITOR.tools.convertHexStringToBytes( imageHex ) ); + var blobUrl = URL.createObjectURL( new Blob( [ typedArray ], { type: fileType } ) ); + CKEDITOR.ajax.convertBlobUrlToBase64( blobUrl, function( base64 ) { + resume( function() { + assert.areEqual( 'data:' + fileType + ';base64,' + imageBase64, base64, 'obtained data uri string is different than expected' ); + } ); + } ); wait(); } } ); diff --git a/tests/plugins/pastefromword/_helpers/blob.js b/tests/plugins/pastefromword/_helpers/blob.js new file mode 100644 index 00000000000..f1002e09019 --- /dev/null +++ b/tests/plugins/pastefromword/_helpers/blob.js @@ -0,0 +1,100 @@ +/* exported blobHelpers */ + +( function() { + 'use strict'; + + var isSupportedEnvironment = typeof Uint8Array === 'function' && typeof Blob === 'function' && + typeof URL === 'function'; + + function ignoreUnsupportedEnvironment( testSuite, check ) { + testSuite._should = testSuite._should || {}; + testSuite._should.ignore = testSuite._should.ignore || {}; + + for ( var key in testSuite ) { + if ( ( typeof check !== 'undefined' && !check ) || !this.isSupportedEnvironment ) { + testSuite._should.ignore[ key ] = true; + } + } + } + + function dataUriToBlob( dataURI ) { + var base64String = atob( dataURI.split( ',' )[1] ), + fileType = dataURI.match( /^data:([^;]*);base64,/ )[ 1 ], + arrayBuffer = new Uint8Array( base64String.length ); + + for ( var i = 0; i < base64String.length; i++ ) { + arrayBuffer[ i ] = base64String.charCodeAt( i ); + } + + return new Blob( [ arrayBuffer ], { type: fileType } ); + } + + function simulatePasteBlob( editor, assertion, options ) { + // jscs:disable maximumLineLength + var imgBase64 = 'data:image/png;base64,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', + // jscs:enable maximumLineLength + url = URL.createObjectURL( dataUriToBlob( imgBase64 ) ), + template = options && options.template || '
Helloworld.