filerepository.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  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 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 log from '@ckeditor/ckeditor5-utils/src/log';
  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 adpter" 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. * Reference to a pending action registered in a {@link module:core/pendingactions~PendingActions} plugin
  59. * while upload is in progress. When there is no upload then value is `null`.
  60. *
  61. * @private
  62. * @member {Object} #_pendingAction
  63. */
  64. this._pendingAction = null;
  65. /**
  66. * A factory function which should be defined before using `FileRepository`.
  67. *
  68. * It should return a new instance of {@link module:upload/filerepository~UploadAdapter} that will be used to upload files.
  69. * {@link module:upload/filerepository~FileLoader} instance associated with the adapter
  70. * will be passed to that function.
  71. *
  72. * For more information and example see {@link module:upload/filerepository~UploadAdapter}.
  73. *
  74. * @member {Function} #createUploadAdapter
  75. */
  76. /**
  77. * Number of bytes uploaded.
  78. *
  79. * @readonly
  80. * @observable
  81. * @member {Number} #uploaded
  82. */
  83. this.set( 'uploaded', 0 );
  84. /**
  85. * Number of total bytes to upload.
  86. *
  87. * It might be different than the file size because of headers and additional data.
  88. * It contains `null` if value is not available yet, so it's better to use {@link #uploadedPercent} to monitor
  89. * the progress.
  90. *
  91. * @readonly
  92. * @observable
  93. * @member {Number|null} #uploadTotal
  94. */
  95. this.set( 'uploadTotal', null );
  96. /**
  97. * Upload progress in percents.
  98. *
  99. * @readonly
  100. * @observable
  101. * @member {Number} #uploadedPercent
  102. */
  103. this.bind( 'uploadedPercent' ).to( this, 'uploaded', this, 'uploadTotal', ( uploaded, total ) => {
  104. return total ? ( uploaded / total * 100 ) : 0;
  105. } );
  106. }
  107. /**
  108. * Returns the loader associated with specified file.
  109. *
  110. * To get loader by id use `fileRepository.loaders.get( id )`.
  111. *
  112. * @param {File} file Native file handle.
  113. * @returns {module:upload/filerepository~FileLoader|null}
  114. */
  115. getLoader( file ) {
  116. for ( const loader of this.loaders ) {
  117. if ( loader.file == file ) {
  118. return loader;
  119. }
  120. }
  121. return null;
  122. }
  123. /**
  124. * Creates a loader instance for the given file.
  125. *
  126. * Requires {@link #createUploadAdapter} factory to be defined.
  127. *
  128. * @param {File} file Native File object.
  129. * @returns {module:upload/filerepository~FileLoader|null}
  130. */
  131. createLoader( file ) {
  132. if ( !this.createUploadAdapter ) {
  133. /**
  134. * You need to enable an upload adapter in order to be able to upload files.
  135. *
  136. * This warning shows up when {@link module:upload/filerepository~FileRepository} is being used
  137. * without {@link #createUploadAdapter definining an upload adapter}.
  138. *
  139. * **If you see this warning when using one of the {@glink builds/index CKEditor 5 Builds}**
  140. * it means that you did not configure any of the upload adapters available by default in those builds.
  141. *
  142. * See the {@glink features/image-upload/image-upload comprehensive "Image upload" guide} to learn which upload
  143. * adapters are available in the builds and how to configure them.
  144. *
  145. * **If you see this warning when using a custom build** there is a chance that you enabled
  146. * a feature like {@link module:image/imageupload~ImageUpload},
  147. * or {@link module:image/imageupload/imageuploadui~ImageUploadUI} but you did not enable any upload adapter.
  148. * You can choose one of the existing upload adapters listed in the
  149. * {@glink features/image-upload/image-upload "Image upload" guide}.
  150. *
  151. * You can also implement your {@glink framework/guides/deep-dive/upload-adapter own upload adapter}.
  152. *
  153. * @error filerepository-no-upload-adapter
  154. */
  155. log.error( 'filerepository-no-upload-adapter: Upload adapter is not defined.' );
  156. return null;
  157. }
  158. const loader = new FileLoader( file );
  159. loader._adapter = this.createUploadAdapter( loader );
  160. this.loaders.add( loader );
  161. loader.on( 'change:uploaded', () => {
  162. let aggregatedUploaded = 0;
  163. for ( const loader of this.loaders ) {
  164. aggregatedUploaded += loader.uploaded;
  165. }
  166. this.uploaded = aggregatedUploaded;
  167. } );
  168. loader.on( 'change:uploadTotal', () => {
  169. let aggregatedTotal = 0;
  170. for ( const loader of this.loaders ) {
  171. if ( loader.uploadTotal ) {
  172. aggregatedTotal += loader.uploadTotal;
  173. }
  174. }
  175. this.uploadTotal = aggregatedTotal;
  176. } );
  177. return loader;
  178. }
  179. /**
  180. * Destroys the given loader.
  181. *
  182. * @param {File|module:upload/filerepository~FileLoader} fileOrLoader File associated with that loader or loader
  183. * itself.
  184. */
  185. destroyLoader( fileOrLoader ) {
  186. const loader = fileOrLoader instanceof FileLoader ? fileOrLoader : this.getLoader( fileOrLoader );
  187. loader._destroy();
  188. this.loaders.remove( loader );
  189. }
  190. /**
  191. * Registers or deregisters pending action bound with upload progress.
  192. *
  193. * @private
  194. */
  195. _updatePendingAction() {
  196. const pendingActions = this.editor.plugins.get( PendingActions );
  197. if ( this.loaders.length ) {
  198. if ( !this._pendingAction ) {
  199. const t = this.editor.t;
  200. const getMessage = value => `${ t( 'Upload in progress' ) } ${ parseInt( value ) }%.`;
  201. this._pendingAction = pendingActions.add( getMessage( this.uploadedPercent ) );
  202. this._pendingAction.bind( 'message' ).to( this, 'uploadedPercent', getMessage );
  203. }
  204. } else {
  205. pendingActions.remove( this._pendingAction );
  206. this._pendingAction = null;
  207. }
  208. }
  209. }
  210. mix( FileRepository, ObservableMixin );
  211. /**
  212. * File loader class.
  213. *
  214. * It is used to control the process of reading the file and uploading it using the specified upload adapter.
  215. */
  216. class FileLoader {
  217. /**
  218. * Creates a new instance of `FileLoader`.
  219. *
  220. * @param {File} file A native file instance.
  221. * @param {module:upload/filerepository~UploadAdapter} adapter
  222. */
  223. constructor( file, adapter ) {
  224. /**
  225. * Unique id of FileLoader instance.
  226. *
  227. * @readonly
  228. * @member {Number}
  229. */
  230. this.id = uid();
  231. /**
  232. * A `File` instance associated with this file loader.
  233. *
  234. * @readonly
  235. * @member {File}
  236. */
  237. this.file = file;
  238. /**
  239. * Adapter instance associated with this file loader.
  240. *
  241. * @private
  242. * @member {module:upload/filerepository~UploadAdapter}
  243. */
  244. this._adapter = adapter;
  245. /**
  246. * FileReader used by FileLoader.
  247. *
  248. * @protected
  249. * @member {module:upload/filereader~FileReader}
  250. */
  251. this._reader = new FileReader();
  252. /**
  253. * Current status of FileLoader. It can be one of the following:
  254. *
  255. * * 'idle',
  256. * * 'reading',
  257. * * 'uploading',
  258. * * 'aborted',
  259. * * 'error'.
  260. *
  261. * When reading status can change in a following way:
  262. *
  263. * `idle` -> `reading` -> `idle`
  264. * `idle` -> `reading -> `aborted`
  265. * `idle` -> `reading -> `error`
  266. *
  267. * When uploading status can change in a following way:
  268. *
  269. * `idle` -> `uploading` -> `idle`
  270. * `idle` -> `uploading` -> `aborted`
  271. * `idle` -> `uploading` -> `error`
  272. *
  273. * @readonly
  274. * @observable
  275. * @member {String} #status
  276. */
  277. this.set( 'status', 'idle' );
  278. /**
  279. * Number of bytes uploaded.
  280. *
  281. * @readonly
  282. * @observable
  283. * @member {Number} #uploaded
  284. */
  285. this.set( 'uploaded', 0 );
  286. /**
  287. * Number of total bytes to upload.
  288. *
  289. * @readonly
  290. * @observable
  291. * @member {Number|null} #uploadTotal
  292. */
  293. this.set( 'uploadTotal', null );
  294. /**
  295. * Upload progress in percents.
  296. *
  297. * @readonly
  298. * @observable
  299. * @member {Number} #uploadedPercent
  300. */
  301. this.bind( 'uploadedPercent' ).to( this, 'uploaded', this, 'uploadTotal', ( uploaded, total ) => {
  302. return total ? ( uploaded / total * 100 ) : 0;
  303. } );
  304. /**
  305. * Response of the upload.
  306. *
  307. * @readonly
  308. * @observable
  309. * @member {Object|null} #uploadResponse
  310. */
  311. this.set( 'uploadResponse', null );
  312. }
  313. /**
  314. * Reads file using {@link module:upload/filereader~FileReader}.
  315. *
  316. * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `filerepository-read-wrong-status` when status
  317. * is different than `idle`.
  318. *
  319. * Example usage:
  320. *
  321. * fileLoader.read()
  322. * .then( data => { ... } )
  323. * .catch( err => {
  324. * if ( err === 'aborted' ) {
  325. * console.log( 'Reading aborted.' );
  326. * } else {
  327. * console.log( 'Reading error.', err );
  328. * }
  329. * } );
  330. *
  331. * @returns {Promise} Returns promise that will be resolved with read data. Promise will be rejected if error
  332. * occurs or if read process is aborted.
  333. */
  334. read() {
  335. if ( this.status != 'idle' ) {
  336. throw new CKEditorError( 'filerepository-read-wrong-status: You cannot call read if the status is different than idle.' );
  337. }
  338. this.status = 'reading';
  339. return this._reader.read( this.file )
  340. .then( data => {
  341. this.status = 'idle';
  342. return data;
  343. } )
  344. .catch( err => {
  345. if ( err === 'aborted' ) {
  346. this.status = 'aborted';
  347. throw 'aborted';
  348. }
  349. this.status = 'error';
  350. throw this._reader.error;
  351. } );
  352. }
  353. /**
  354. * Reads file using the provided {@link module:upload/filerepository~UploadAdapter}.
  355. *
  356. * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `filerepository-upload-wrong-status` when status
  357. * is different than `idle`.
  358. * Example usage:
  359. *
  360. * fileLoader.upload()
  361. * .then( data => { ... } )
  362. * .catch( e => {
  363. * if ( e === 'aborted' ) {
  364. * console.log( 'Uploading aborted.' );
  365. * } else {
  366. * console.log( 'Uploading error.', e );
  367. * }
  368. * } );
  369. *
  370. * @returns {Promise} Returns promise that will be resolved with response data. Promise will be rejected if error
  371. * occurs or if read process is aborted.
  372. */
  373. upload() {
  374. if ( this.status != 'idle' ) {
  375. throw new CKEditorError( 'filerepository-upload-wrong-status: You cannot call upload if the status is different than idle.' );
  376. }
  377. this.status = 'uploading';
  378. return this._adapter.upload()
  379. .then( data => {
  380. this.uploadResponse = data;
  381. this.status = 'idle';
  382. return data;
  383. } )
  384. .catch( err => {
  385. if ( this.status === 'aborted' ) {
  386. throw 'aborted';
  387. }
  388. this.status = 'error';
  389. throw err;
  390. } );
  391. }
  392. /**
  393. * Aborts loading process.
  394. */
  395. abort() {
  396. const status = this.status;
  397. this.status = 'aborted';
  398. if ( status == 'reading' ) {
  399. this._reader.abort();
  400. }
  401. if ( status == 'uploading' && this._adapter.abort ) {
  402. this._adapter.abort();
  403. }
  404. this._destroy();
  405. }
  406. /**
  407. * Performs cleanup.
  408. *
  409. * @private
  410. */
  411. _destroy() {
  412. this._reader = undefined;
  413. this._adapter = undefined;
  414. this.data = undefined;
  415. this.uploadResponse = undefined;
  416. this.file = undefined;
  417. }
  418. }
  419. mix( FileLoader, ObservableMixin );
  420. /**
  421. * Upload adapter interface used by the {@link module:upload/filerepository~FileRepository file repository}
  422. * to handle file upload. An upload adapter is a bridge between the editor and server that handles file uploads.
  423. * It should contain a logic necessary to initiate an upload process and monitor its progress.
  424. *
  425. * Learn how to develop your own upload adapter for CKEditor 5 in the
  426. * {@glink framework/guides/deep-dive/upload-adapter "Custom upload adapter" guide}.
  427. *
  428. * @interface UploadAdapter
  429. */
  430. /**
  431. * Executes the upload process.
  432. * This method should return a promise that will resolve when data will be uploaded to server. Promise should be
  433. * resolved with an object containing information about uploaded file:
  434. *
  435. * {
  436. * default: 'http://server/default-size.image.png'
  437. * }
  438. *
  439. * Additionally, other image sizes can be provided:
  440. *
  441. * {
  442. * default: 'http://server/default-size.image.png',
  443. * '160': 'http://server/size-160.image.png',
  444. * '500': 'http://server/size-500.image.png',
  445. * '1000': 'http://server/size-1000.image.png',
  446. * '1052': 'http://server/default-size.image.png'
  447. * }
  448. *
  449. * NOTE: When returning multiple images, the widest returned one should equal the default one. It is essential to
  450. * correctly set `width` attribute of the image. See this discussion:
  451. * https://github.com/ckeditor/ckeditor5-easy-image/issues/4 for more information.
  452. *
  453. * Take a look at {@link module:upload/filerepository~UploadAdapter example Adapter implementation} and
  454. * {@link module:upload/filerepository~FileRepository#createUploadAdapter createUploadAdapter method}.
  455. *
  456. * @method module:upload/filerepository~UploadAdapter#upload
  457. * @returns {Promise} Promise that should be resolved when data is uploaded.
  458. */
  459. /**
  460. * Aborts the upload process.
  461. * After aborting it should reject promise returned from {@link #upload upload()}.
  462. *
  463. * Take a look at {@link module:upload/filerepository~UploadAdapter example Adapter implementation} and
  464. * {@link module:upload/filerepository~FileRepository#createUploadAdapter createUploadAdapter method}.
  465. *
  466. * @method module:upload/filerepository~UploadAdapter#abort
  467. */