imageuploadediting.js 11 KB

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