8
0

filerepository.js 19 KB

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