imageuploadediting.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. /**
  2. * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module image/imageupload/imageuploadediting
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import FileRepository from '@ckeditor/ckeditor5-upload/src/filerepository';
  10. import Notification from '@ckeditor/ckeditor5-ui/src/notification/notification';
  11. import UpcastWriter from '@ckeditor/ckeditor5-engine/src/view/upcastwriter';
  12. import env from '@ckeditor/ckeditor5-utils/src/env';
  13. import ImageUploadCommand from '../../src/imageupload/imageuploadcommand';
  14. import { isImageType, isLocalImage, fetchLocalImage } from '../../src/imageupload/utils';
  15. /**
  16. * The editing part of the image upload feature. It registers the `'imageUpload'` command.
  17. *
  18. * @extends module:core/plugin~Plugin
  19. */
  20. export default class ImageUploadEditing extends Plugin {
  21. /**
  22. * @inheritDoc
  23. */
  24. static get requires() {
  25. return [ FileRepository, Notification ];
  26. }
  27. /**
  28. * @inheritDoc
  29. */
  30. init() {
  31. const editor = this.editor;
  32. const doc = editor.model.document;
  33. const schema = editor.model.schema;
  34. const conversion = editor.conversion;
  35. const fileRepository = editor.plugins.get( FileRepository );
  36. // Setup schema to allow uploadId and uploadStatus for images.
  37. schema.extend( 'image', {
  38. allowAttributes: [ 'uploadId', 'uploadStatus' ]
  39. } );
  40. // Register imageUpload command.
  41. editor.commands.add( 'imageUpload', new ImageUploadCommand( editor ) );
  42. // Register upcast converter for uploadId.
  43. conversion.for( 'upcast' )
  44. .attributeToAttribute( {
  45. view: {
  46. name: 'img',
  47. key: 'uploadId'
  48. },
  49. model: 'uploadId'
  50. } );
  51. // Handle pasted images.
  52. // For every image file, a new file loader is created and a placeholder image is
  53. // inserted into the content. Then, those images are uploaded once they appear in the model
  54. // (see Document#change listener below).
  55. this.listenTo( editor.editing.view.document, 'clipboardInput', ( evt, data ) => {
  56. // Skip if non empty HTML data is included.
  57. // https://github.com/ckeditor/ckeditor5-upload/issues/68
  58. if ( isHtmlIncluded( data.dataTransfer ) ) {
  59. return;
  60. }
  61. const images = Array.from( data.dataTransfer.files ).filter( file => {
  62. // See https://github.com/ckeditor/ckeditor5-image/pull/254.
  63. if ( !file ) {
  64. return false;
  65. }
  66. return isImageType( file );
  67. } );
  68. const ranges = data.targetRanges.map( viewRange => editor.editing.mapper.toModelRange( viewRange ) );
  69. editor.model.change( writer => {
  70. // Set selection to paste target.
  71. writer.setSelection( ranges );
  72. if ( images.length ) {
  73. evt.stop();
  74. // Upload images after the selection has changed in order to ensure the command's state is refreshed.
  75. editor.model.enqueueChange( 'default', () => {
  76. editor.execute( 'imageUpload', { file: images } );
  77. } );
  78. }
  79. } );
  80. } );
  81. // Handle HTML pasted with images with base64 or blob sources.
  82. // For every image file, a new file loader is created and a placeholder image is
  83. // inserted into the content. Then, those images are uploaded once they appear in the model
  84. // (see Document#change listener below).
  85. if ( editor.plugins.has( 'Clipboard' ) ) {
  86. this.listenTo( editor.plugins.get( 'Clipboard' ), 'inputTransformation', ( evt, data ) => {
  87. const fetchableImages = Array.from( editor.editing.view.createRangeIn( data.content ) )
  88. .filter( value => isLocalImage( value.item ) && !value.item.getAttribute( 'uploadProcessed' ) )
  89. .map( value => { return { promise: fetchLocalImage( value.item ), imageElement: value.item }; } );
  90. if ( !fetchableImages.length ) {
  91. return;
  92. }
  93. const writer = new UpcastWriter();
  94. for ( const fetchableImage of fetchableImages ) {
  95. // Set attribute marking that the image was processed already.
  96. writer.setAttribute( 'uploadProcessed', true, fetchableImage.imageElement );
  97. const loader = fileRepository.createLoader( fetchableImage.promise );
  98. if ( loader ) {
  99. writer.setAttribute( 'src', '', fetchableImage.imageElement );
  100. writer.setAttribute( 'uploadId', loader.id, fetchableImage.imageElement );
  101. }
  102. }
  103. } );
  104. }
  105. // Prevents from the browser redirecting to the dropped image.
  106. editor.editing.view.document.on( 'dragover', ( evt, data ) => {
  107. data.preventDefault();
  108. } );
  109. // Upload placeholder images that appeared in the model.
  110. doc.on( 'change', () => {
  111. const changes = doc.differ.getChanges( { includeChangesInGraveyard: true } );
  112. for ( const entry of changes ) {
  113. if ( entry.type == 'insert' && entry.name == 'image' ) {
  114. const item = entry.position.nodeAfter;
  115. const isInGraveyard = entry.position.root.rootName == '$graveyard';
  116. // Check if the image element still has upload id.
  117. const uploadId = item.getAttribute( 'uploadId' );
  118. if ( !uploadId ) {
  119. continue;
  120. }
  121. // Check if the image is loaded on this client.
  122. const loader = fileRepository.loaders.get( uploadId );
  123. if ( !loader ) {
  124. continue;
  125. }
  126. if ( isInGraveyard ) {
  127. // If the image was inserted to the graveyard - abort the loading process.
  128. loader.abort();
  129. } else if ( loader.status == 'idle' ) {
  130. // If the image was inserted into content and has not been loaded yet, start loading it.
  131. this._readAndUpload( loader, item );
  132. }
  133. }
  134. }
  135. } );
  136. }
  137. /**
  138. * Read and upload an image.
  139. *
  140. * The image is read from the disk and as a base64 encoded string it is set temporarily to
  141. * `image[src]`. When the image is successfully uploaded the temporary data is replaced with the target
  142. * image's URL (the URL to the uploaded image on the server).
  143. *
  144. * @protected
  145. * @param {module:upload/filerepository~FileLoader} loader
  146. * @param {module:engine/model/element~Element} imageElement
  147. * @returns {Promise}
  148. */
  149. _readAndUpload( loader, imageElement ) {
  150. const editor = this.editor;
  151. const model = editor.model;
  152. const t = editor.locale.t;
  153. const fileRepository = editor.plugins.get( FileRepository );
  154. const notification = editor.plugins.get( Notification );
  155. model.enqueueChange( 'transparent', writer => {
  156. writer.setAttribute( 'uploadStatus', 'reading', imageElement );
  157. } );
  158. return loader.read()
  159. .then( data => {
  160. const viewFigure = editor.editing.mapper.toViewElement( imageElement );
  161. const viewImg = viewFigure.getChild( 0 );
  162. const promise = loader.upload();
  163. // Force re–paint in Safari. Without it, the image will display with a wrong size.
  164. // https://github.com/ckeditor/ckeditor5/issues/1975
  165. if ( env.isSafari ) {
  166. editor.ui.once( 'update', () => {
  167. // Early returns just to be safe. There might be some code ran
  168. // in between the outer scope and this callback.
  169. if ( !viewImg.parent ) {
  170. return;
  171. }
  172. const domFigure = editor.editing.view.domConverter.viewToDom( viewImg.parent );
  173. if ( !domFigure ) {
  174. return;
  175. }
  176. const originalDisplay = domFigure.style.display;
  177. domFigure.style.display = 'none';
  178. const offsetHeightBefore = domFigure.offsetHeight; // eslint-disable-line no-unused-vars
  179. domFigure.style.display = originalDisplay;
  180. } );
  181. }
  182. editor.editing.view.change( writer => {
  183. writer.setAttribute( 'src', data, viewImg );
  184. } );
  185. model.enqueueChange( 'transparent', writer => {
  186. writer.setAttribute( 'uploadStatus', 'uploading', imageElement );
  187. } );
  188. return promise;
  189. } )
  190. .then( data => {
  191. model.enqueueChange( 'transparent', writer => {
  192. writer.setAttributes( { uploadStatus: 'complete', src: data.default }, imageElement );
  193. this._parseAndSetSrcsetAttributeOnImage( data, imageElement, writer );
  194. } );
  195. clean();
  196. } )
  197. .catch( error => {
  198. // If status is not 'error' nor 'aborted' - throw error because it means that something else went wrong,
  199. // it might be generic error and it would be real pain to find what is going on.
  200. if ( loader.status !== 'error' && loader.status !== 'aborted' ) {
  201. throw error;
  202. }
  203. // Might be 'aborted'.
  204. if ( loader.status == 'error' && error ) {
  205. notification.showWarning( error, {
  206. title: t( 'Upload failed' ),
  207. namespace: 'upload'
  208. } );
  209. }
  210. clean();
  211. // Permanently remove image from insertion batch.
  212. model.enqueueChange( 'transparent', writer => {
  213. writer.remove( imageElement );
  214. } );
  215. } );
  216. function clean() {
  217. model.enqueueChange( 'transparent', writer => {
  218. writer.removeAttribute( 'uploadId', imageElement );
  219. writer.removeAttribute( 'uploadStatus', imageElement );
  220. } );
  221. fileRepository.destroyLoader( loader );
  222. }
  223. }
  224. /**
  225. * Creates `srcset` attribute based on a given file upload response and sets it as an attribute to a specific image element.
  226. *
  227. * @protected
  228. * @param {Object} data Data object from which `srcset` will be created.
  229. * @param {module:engine/model/element~Element} image The image element on which `srcset` attribute will be set.
  230. * @param {module:engine/model/writer~Writer} writer
  231. */
  232. _parseAndSetSrcsetAttributeOnImage( data, image, writer ) {
  233. // Srcset attribute for responsive images support.
  234. let maxWidth = 0;
  235. const srcsetAttribute = Object.keys( data )
  236. // Filter out keys that are not integers.
  237. .filter( key => {
  238. const width = parseInt( key, 10 );
  239. if ( !isNaN( width ) ) {
  240. maxWidth = Math.max( maxWidth, width );
  241. return true;
  242. }
  243. } )
  244. // Convert each key to srcset entry.
  245. .map( key => `${ data[ key ] } ${ key }w` )
  246. // Join all entries.
  247. .join( ', ' );
  248. if ( srcsetAttribute != '' ) {
  249. writer.setAttribute( 'srcset', {
  250. data: srcsetAttribute,
  251. width: maxWidth
  252. }, image );
  253. }
  254. }
  255. }
  256. // Returns `true` if non-empty `text/html` is included in the data transfer.
  257. //
  258. // @param {module:clipboard/datatransfer~DataTransfer} dataTransfer
  259. // @returns {Boolean}
  260. export function isHtmlIncluded( dataTransfer ) {
  261. return Array.from( dataTransfer.types ).includes( 'text/html' ) && dataTransfer.getData( 'text/html' ) !== '';
  262. }