filerepository.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  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 upload/filerepository
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import PendingActions from '@ckeditor/ckeditor5-core/src/pendingactions';
  10. import CKEditorError, { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  11. import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
  12. import Collection from '@ckeditor/ckeditor5-utils/src/collection';
  13. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  14. import FileReader from './filereader.js';
  15. import uid from '@ckeditor/ckeditor5-utils/src/uid.js';
  16. /**
  17. * File repository plugin. A central point for managing file upload.
  18. *
  19. * To use it, first you need an upload adapter. Upload adapter's job is to handle communication with the server
  20. * (sending the file and handling server's response). You can use one of the existing plugins introducing upload adapters
  21. * (e.g. {@link module:easy-image/cloudservicesuploadadapter~CloudServicesUploadAdapter} or
  22. * {@link module:adapter-ckfinder/uploadadapter~CKFinderUploadAdapter}) or write your own one – see
  23. * the {@glink framework/guides/deep-dive/upload-adapter "Custom image upload adapter" deep dive guide}.
  24. *
  25. * Then, you can use {@link module:upload/filerepository~FileRepository#createLoader `createLoader()`} and the returned
  26. * {@link module:upload/filerepository~FileLoader} instance to load and upload files.
  27. *
  28. * @extends module:core/plugin~Plugin
  29. */
  30. export default class FileRepository extends Plugin {
  31. /**
  32. * @inheritDoc
  33. */
  34. static get pluginName() {
  35. return 'FileRepository';
  36. }
  37. /**
  38. * @inheritDoc
  39. */
  40. static get requires() {
  41. return [ PendingActions ];
  42. }
  43. /**
  44. * @inheritDoc
  45. */
  46. init() {
  47. /**
  48. * Collection of loaders associated with this repository.
  49. *
  50. * @member {module:utils/collection~Collection} #loaders
  51. */
  52. this.loaders = new Collection();
  53. // Keeps upload in a sync with pending actions.
  54. this.loaders.on( 'add', () => this._updatePendingAction() );
  55. this.loaders.on( 'remove', () => this._updatePendingAction() );
  56. /**
  57. * Loaders mappings used to retrieve loaders references.
  58. *
  59. * @private
  60. * @member {Map<File|Promise, FileLoader>} #_loadersMap
  61. */
  62. this._loadersMap = new Map();
  63. /**
  64. * Reference to a pending action registered in a {@link module:core/pendingactions~PendingActions} plugin
  65. * while upload is in progress. When there is no upload then value is `null`.
  66. *
  67. * @private
  68. * @member {Object} #_pendingAction
  69. */
  70. this._pendingAction = null;
  71. /**
  72. * A factory function which should be defined before using `FileRepository`.
  73. *
  74. * It should return a new instance of {@link module:upload/filerepository~UploadAdapter} that will be used to upload files.
  75. * {@link module:upload/filerepository~FileLoader} instance associated with the adapter
  76. * will be passed to that function.
  77. *
  78. * For more information and example see {@link module:upload/filerepository~UploadAdapter}.
  79. *
  80. * @member {Function} #createUploadAdapter
  81. */
  82. /**
  83. * Number of bytes uploaded.
  84. *
  85. * @readonly
  86. * @observable
  87. * @member {Number} #uploaded
  88. */
  89. this.set( 'uploaded', 0 );
  90. /**
  91. * Number of total bytes to upload.
  92. *
  93. * It might be different than the file size because of headers and additional data.
  94. * It contains `null` if value is not available yet, so it's better to use {@link #uploadedPercent} to monitor
  95. * the progress.
  96. *
  97. * @readonly
  98. * @observable
  99. * @member {Number|null} #uploadTotal
  100. */
  101. this.set( 'uploadTotal', null );
  102. /**
  103. * Upload progress in percents.
  104. *
  105. * @readonly
  106. * @observable
  107. * @member {Number} #uploadedPercent
  108. */
  109. this.bind( 'uploadedPercent' ).to( this, 'uploaded', this, 'uploadTotal', ( uploaded, total ) => {
  110. return total ? ( uploaded / total * 100 ) : 0;
  111. } );
  112. }
  113. /**
  114. * Returns the loader associated with specified file or promise.
  115. *
  116. * To get loader by id use `fileRepository.loaders.get( id )`.
  117. *
  118. * @param {File|Promise.<File>} fileOrPromise Native file or promise handle.
  119. * @returns {module:upload/filerepository~FileLoader|null}
  120. */
  121. getLoader( fileOrPromise ) {
  122. return this._loadersMap.get( fileOrPromise ) || null;
  123. }
  124. /**
  125. * Creates a loader instance for the given file.
  126. *
  127. * Requires {@link #createUploadAdapter} factory to be defined.
  128. *
  129. * @param {File|Promise.<File>} fileOrPromise Native File object or native Promise object which resolves to a File.
  130. * @returns {module:upload/filerepository~FileLoader|null}
  131. */
  132. createLoader( fileOrPromise ) {
  133. if ( !this.createUploadAdapter ) {
  134. /**
  135. * You need to enable an upload adapter in order to be able to upload files.
  136. *
  137. * This warning shows up when {@link module:upload/filerepository~FileRepository} is being used
  138. * without {@link #createUploadAdapter definining an upload adapter}.
  139. *
  140. * **If you see this warning when using one of the {@glink builds/index CKEditor 5 Builds}**
  141. * it means that you did not configure any of the upload adapters available by default in those builds.
  142. *
  143. * See the {@glink features/image-upload/image-upload comprehensive "Image upload overview"} to learn which upload
  144. * adapters are available in the builds and how to configure them.
  145. *
  146. * **If you see this warning when using a custom build** there is a chance that you enabled
  147. * a feature like {@link module:image/imageupload~ImageUpload},
  148. * or {@link module:image/imageupload/imageuploadui~ImageUploadUI} but you did not enable any upload adapter.
  149. * You can choose one of the existing upload adapters listed in the
  150. * {@glink features/image-upload/image-upload "Image upload overview"}.
  151. *
  152. * You can also implement your {@glink framework/guides/deep-dive/upload-adapter own image upload adapter}.
  153. *
  154. * @error filerepository-no-upload-adapter
  155. */
  156. logWarning( 'filerepository-no-upload-adapter' );
  157. return null;
  158. }
  159. const loader = new FileLoader( Promise.resolve( fileOrPromise ), this.createUploadAdapter );
  160. this.loaders.add( loader );
  161. this._loadersMap.set( fileOrPromise, loader );
  162. // Store also file => loader mapping so loader can be retrieved by file instance returned upon Promise resolution.
  163. if ( fileOrPromise instanceof Promise ) {
  164. loader.file
  165. .then( file => {
  166. this._loadersMap.set( file, loader );
  167. } )
  168. // Every then() must have a catch().
  169. // File loader state (and rejections) are handled in read() and upload().
  170. // Also, see the "does not swallow the file promise rejection" test.
  171. .catch( () => {} );
  172. }
  173. loader.on( 'change:uploaded', () => {
  174. let aggregatedUploaded = 0;
  175. for ( const loader of this.loaders ) {
  176. aggregatedUploaded += loader.uploaded;
  177. }
  178. this.uploaded = aggregatedUploaded;
  179. } );
  180. loader.on( 'change:uploadTotal', () => {
  181. let aggregatedTotal = 0;
  182. for ( const loader of this.loaders ) {
  183. if ( loader.uploadTotal ) {
  184. aggregatedTotal += loader.uploadTotal;
  185. }
  186. }
  187. this.uploadTotal = aggregatedTotal;
  188. } );
  189. return loader;
  190. }
  191. /**
  192. * Destroys the given loader.
  193. *
  194. * @param {File|Promise|module:upload/filerepository~FileLoader} fileOrPromiseOrLoader File or Promise associated
  195. * with that loader or loader itself.
  196. */
  197. destroyLoader( fileOrPromiseOrLoader ) {
  198. const loader = fileOrPromiseOrLoader instanceof FileLoader ? fileOrPromiseOrLoader : this.getLoader( fileOrPromiseOrLoader );
  199. loader._destroy();
  200. this.loaders.remove( loader );
  201. this._loadersMap.forEach( ( value, key ) => {
  202. if ( value === loader ) {
  203. this._loadersMap.delete( key );
  204. }
  205. } );
  206. }
  207. /**
  208. * Registers or deregisters pending action bound with upload progress.
  209. *
  210. * @private
  211. */
  212. _updatePendingAction() {
  213. const pendingActions = this.editor.plugins.get( PendingActions );
  214. if ( this.loaders.length ) {
  215. if ( !this._pendingAction ) {
  216. const t = this.editor.t;
  217. const getMessage = value => `${ t( 'Upload in progress' ) } ${ parseInt( value ) }%.`;
  218. this._pendingAction = pendingActions.add( getMessage( this.uploadedPercent ) );
  219. this._pendingAction.bind( 'message' ).to( this, 'uploadedPercent', getMessage );
  220. }
  221. } else {
  222. pendingActions.remove( this._pendingAction );
  223. this._pendingAction = null;
  224. }
  225. }
  226. }
  227. mix( FileRepository, ObservableMixin );
  228. /**
  229. * File loader class.
  230. *
  231. * It is used to control the process of reading the file and uploading it using the specified upload adapter.
  232. */
  233. class FileLoader {
  234. /**
  235. * Creates a new instance of `FileLoader`.
  236. *
  237. * @param {Promise.<File>} filePromise A promise which resolves to a file instance.
  238. * @param {Function} uploadAdapterCreator The function which returns {@link module:upload/filerepository~UploadAdapter} instance.
  239. */
  240. constructor( filePromise, uploadAdapterCreator ) {
  241. /**
  242. * Unique id of FileLoader instance.
  243. *
  244. * @readonly
  245. * @member {Number}
  246. */
  247. this.id = uid();
  248. /**
  249. * Additional wrapper over the initial file promise passed to this loader.
  250. *
  251. * @protected
  252. * @member {module:upload/filerepository~FilePromiseWrapper}
  253. */
  254. this._filePromiseWrapper = this._createFilePromiseWrapper( filePromise );
  255. /**
  256. * Adapter instance associated with this file loader.
  257. *
  258. * @private
  259. * @member {module:upload/filerepository~UploadAdapter}
  260. */
  261. this._adapter = uploadAdapterCreator( this );
  262. /**
  263. * FileReader used by FileLoader.
  264. *
  265. * @protected
  266. * @member {module:upload/filereader~FileReader}
  267. */
  268. this._reader = new FileReader();
  269. /**
  270. * Current status of FileLoader. It can be one of the following:
  271. *
  272. * * 'idle',
  273. * * 'reading',
  274. * * 'uploading',
  275. * * 'aborted',
  276. * * 'error'.
  277. *
  278. * When reading status can change in a following way:
  279. *
  280. * `idle` -> `reading` -> `idle`
  281. * `idle` -> `reading -> `aborted`
  282. * `idle` -> `reading -> `error`
  283. *
  284. * When uploading status can change in a following way:
  285. *
  286. * `idle` -> `uploading` -> `idle`
  287. * `idle` -> `uploading` -> `aborted`
  288. * `idle` -> `uploading` -> `error`
  289. *
  290. * @readonly
  291. * @observable
  292. * @member {String} #status
  293. */
  294. this.set( 'status', 'idle' );
  295. /**
  296. * Number of bytes uploaded.
  297. *
  298. * @readonly
  299. * @observable
  300. * @member {Number} #uploaded
  301. */
  302. this.set( 'uploaded', 0 );
  303. /**
  304. * Number of total bytes to upload.
  305. *
  306. * @readonly
  307. * @observable
  308. * @member {Number|null} #uploadTotal
  309. */
  310. this.set( 'uploadTotal', null );
  311. /**
  312. * Upload progress in percents.
  313. *
  314. * @readonly
  315. * @observable
  316. * @member {Number} #uploadedPercent
  317. */
  318. this.bind( 'uploadedPercent' ).to( this, 'uploaded', this, 'uploadTotal', ( uploaded, total ) => {
  319. return total ? ( uploaded / total * 100 ) : 0;
  320. } );
  321. /**
  322. * Response of the upload.
  323. *
  324. * @readonly
  325. * @observable
  326. * @member {Object|null} #uploadResponse
  327. */
  328. this.set( 'uploadResponse', null );
  329. }
  330. /**
  331. * A `Promise` which resolves to a `File` instance associated with this file loader.
  332. *
  333. * @type {Promise.<File|null>}
  334. */
  335. get file() {
  336. if ( !this._filePromiseWrapper ) {
  337. // Loader was destroyed, return promise which resolves to null.
  338. return Promise.resolve( null );
  339. } else {
  340. // The `this._filePromiseWrapper.promise` is chained and not simply returned to handle a case when:
  341. //
  342. // * The `loader.file.then( ... )` is called by external code (returned promise is pending).
  343. // * Then `loader._destroy()` is called (call is synchronous) which destroys the `loader`.
  344. // * Promise returned by the first `loader.file.then( ... )` call is resolved.
  345. //
  346. // Returning `this._filePromiseWrapper.promise` will still resolve to a `File` instance so there
  347. // is an additional check needed in the chain to see if `loader` was destroyed in the meantime.
  348. return this._filePromiseWrapper.promise.then( file => this._filePromiseWrapper ? file : null );
  349. }
  350. }
  351. /**
  352. * Returns the file data. To read its data, you need for first load the file
  353. * by using the {@link module:upload/filerepository~FileLoader#read `read()`} method.
  354. *
  355. * @type {File|undefined}
  356. */
  357. get data() {
  358. return this._reader.data;
  359. }
  360. /**
  361. * Reads file using {@link module:upload/filereader~FileReader}.
  362. *
  363. * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `filerepository-read-wrong-status` when status
  364. * is different than `idle`.
  365. *
  366. * Example usage:
  367. *
  368. * fileLoader.read()
  369. * .then( data => { ... } )
  370. * .catch( err => {
  371. * if ( err === 'aborted' ) {
  372. * console.log( 'Reading aborted.' );
  373. * } else {
  374. * console.log( 'Reading error.', err );
  375. * }
  376. * } );
  377. *
  378. * @returns {Promise.<String>} Returns promise that will be resolved with read data. Promise will be rejected if error
  379. * occurs or if read process is aborted.
  380. */
  381. read() {
  382. if ( this.status != 'idle' ) {
  383. /**
  384. * You cannot call read if the status is different than idle.
  385. *
  386. * @error filerepository-read-wrong-status
  387. */
  388. throw new CKEditorError( 'filerepository-read-wrong-status', this );
  389. }
  390. this.status = 'reading';
  391. return this.file
  392. .then( file => this._reader.read( file ) )
  393. .then( data => {
  394. // Edge case: reader was aborted after file was read - double check for proper status.
  395. // It can happen when image was deleted during its upload.
  396. if ( this.status !== 'reading' ) {
  397. throw this.status;
  398. }
  399. this.status = 'idle';
  400. return data;
  401. } )
  402. .catch( err => {
  403. if ( err === 'aborted' ) {
  404. this.status = 'aborted';
  405. throw 'aborted';
  406. }
  407. this.status = 'error';
  408. throw this._reader.error ? this._reader.error : err;
  409. } );
  410. }
  411. /**
  412. * Reads file using the provided {@link module:upload/filerepository~UploadAdapter}.
  413. *
  414. * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `filerepository-upload-wrong-status` when status
  415. * is different than `idle`.
  416. * Example usage:
  417. *
  418. * fileLoader.upload()
  419. * .then( data => { ... } )
  420. * .catch( e => {
  421. * if ( e === 'aborted' ) {
  422. * console.log( 'Uploading aborted.' );
  423. * } else {
  424. * console.log( 'Uploading error.', e );
  425. * }
  426. * } );
  427. *
  428. * @returns {Promise.<Object>} Returns promise that will be resolved with response data. Promise will be rejected if error
  429. * occurs or if read process is aborted.
  430. */
  431. upload() {
  432. if ( this.status != 'idle' ) {
  433. /**
  434. * You cannot call upload if the status is different than idle.
  435. *
  436. * @error filerepository-upload-wrong-status
  437. */
  438. throw new CKEditorError( 'filerepository-upload-wrong-status', this );
  439. }
  440. this.status = 'uploading';
  441. return this.file
  442. .then( () => this._adapter.upload() )
  443. .then( data => {
  444. this.uploadResponse = data;
  445. this.status = 'idle';
  446. return data;
  447. } )
  448. .catch( err => {
  449. if ( this.status === 'aborted' ) {
  450. throw 'aborted';
  451. }
  452. this.status = 'error';
  453. throw err;
  454. } );
  455. }
  456. /**
  457. * Aborts loading process.
  458. */
  459. abort() {
  460. const status = this.status;
  461. this.status = 'aborted';
  462. if ( !this._filePromiseWrapper.isFulfilled ) {
  463. // Edge case: file loader is aborted before read() is called
  464. // so it might happen that no one handled the rejection of this promise.
  465. // See https://github.com/ckeditor/ckeditor5-upload/pull/100
  466. this._filePromiseWrapper.promise.catch( () => {} );
  467. this._filePromiseWrapper.rejecter( 'aborted' );
  468. } else if ( status == 'reading' ) {
  469. this._reader.abort();
  470. } else if ( status == 'uploading' && this._adapter.abort ) {
  471. this._adapter.abort();
  472. }
  473. this._destroy();
  474. }
  475. /**
  476. * Performs cleanup.
  477. *
  478. * @private
  479. */
  480. _destroy() {
  481. this._filePromiseWrapper = undefined;
  482. this._reader = undefined;
  483. this._adapter = undefined;
  484. this.uploadResponse = undefined;
  485. }
  486. /**
  487. * Wraps a given file promise into another promise giving additional
  488. * control (resolving, rejecting, checking if fulfilled) over it.
  489. *
  490. * @private
  491. * @param filePromise The initial file promise to be wrapped.
  492. * @returns {module:upload/filerepository~FilePromiseWrapper}
  493. */
  494. _createFilePromiseWrapper( filePromise ) {
  495. const wrapper = {};
  496. wrapper.promise = new Promise( ( resolve, reject ) => {
  497. wrapper.rejecter = reject;
  498. wrapper.isFulfilled = false;
  499. filePromise
  500. .then( file => {
  501. wrapper.isFulfilled = true;
  502. resolve( file );
  503. } )
  504. .catch( err => {
  505. wrapper.isFulfilled = true;
  506. reject( err );
  507. } );
  508. } );
  509. return wrapper;
  510. }
  511. }
  512. mix( FileLoader, ObservableMixin );
  513. /**
  514. * Upload adapter interface used by the {@link module:upload/filerepository~FileRepository file repository}
  515. * to handle file upload. An upload adapter is a bridge between the editor and server that handles file uploads.
  516. * It should contain a logic necessary to initiate an upload process and monitor its progress.
  517. *
  518. * Learn how to develop your own upload adapter for CKEditor 5 in the
  519. * {@glink framework/guides/deep-dive/upload-adapter "Custom upload adapter" guide}.
  520. *
  521. * @interface UploadAdapter
  522. */
  523. /**
  524. * Executes the upload process.
  525. * This method should return a promise that will resolve when data will be uploaded to server. Promise should be
  526. * resolved with an object containing information about uploaded file:
  527. *
  528. * {
  529. * default: 'http://server/default-size.image.png'
  530. * }
  531. *
  532. * Additionally, other image sizes can be provided:
  533. *
  534. * {
  535. * default: 'http://server/default-size.image.png',
  536. * '160': 'http://server/size-160.image.png',
  537. * '500': 'http://server/size-500.image.png',
  538. * '1000': 'http://server/size-1000.image.png',
  539. * '1052': 'http://server/default-size.image.png'
  540. * }
  541. *
  542. * NOTE: When returning multiple images, the widest returned one should equal the default one. It is essential to
  543. * correctly set `width` attribute of the image. See this discussion:
  544. * https://github.com/ckeditor/ckeditor5-easy-image/issues/4 for more information.
  545. *
  546. * Take a look at {@link module:upload/filerepository~UploadAdapter example Adapter implementation} and
  547. * {@link module:upload/filerepository~FileRepository#createUploadAdapter createUploadAdapter method}.
  548. *
  549. * @method module:upload/filerepository~UploadAdapter#upload
  550. * @returns {Promise.<Object>} Promise that should be resolved when data is uploaded.
  551. */
  552. /**
  553. * Aborts the upload process.
  554. * After aborting it should reject promise returned from {@link #upload upload()}.
  555. *
  556. * Take a look at {@link module:upload/filerepository~UploadAdapter example Adapter implementation} and
  557. * {@link module:upload/filerepository~FileRepository#createUploadAdapter createUploadAdapter method}.
  558. *
  559. * @method module:upload/filerepository~UploadAdapter#abort
  560. */
  561. /**
  562. * Object returned by {@link module:upload/filerepository~FileLoader#_createFilePromiseWrapper} method
  563. * to add more control over the initial file promise passed to {@link module:upload/filerepository~FileLoader}.
  564. *
  565. * @protected
  566. * @typedef {Object} module:upload/filerepository~FilePromiseWrapper
  567. * @property {Promise.<File>} promise Wrapper promise which can be chained for further processing.
  568. * @property {Function} rejecter Rejects the promise when called.
  569. * @property {Boolean} isFulfilled Whether original promise is already fulfilled.
  570. */