8
0

filerepository.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  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
  24. * (which boils down to setting the {@link ~FileRepository#createUploadAdapter} factory function – see
  25. * {@link module:upload/filerepository~UploadAdapter `UploadAdapter` interface} documentation).
  26. *
  27. * Then, you can use {@link module:upload/filerepository~FileRepository#createLoader `createLoader()`} and the returned
  28. * {@link module:upload/filerepository~FileLoader} instance to load and upload files.
  29. *
  30. * @extends module:core/plugin~Plugin
  31. */
  32. export default class FileRepository extends Plugin {
  33. /**
  34. * @inheritDoc
  35. */
  36. static get pluginName() {
  37. return 'FileRepository';
  38. }
  39. /**
  40. * @inheritDoc
  41. */
  42. static get requires() {
  43. return [ PendingActions ];
  44. }
  45. /**
  46. * @inheritDoc
  47. */
  48. init() {
  49. /**
  50. * Collection of loaders associated with this repository.
  51. *
  52. * @member {module:utils/collection~Collection} #loaders
  53. */
  54. this.loaders = new Collection();
  55. // Keeps upload in a sync with pending actions.
  56. this.loaders.on( 'add', () => this._updatePendingAction() );
  57. this.loaders.on( 'remove', () => this._updatePendingAction() );
  58. /**
  59. * Reference to a pending action registered in a {@link module:core/pendingactions~PendingActions} plugin
  60. * while upload is in progress. When there is no upload then value is `null`.
  61. *
  62. * @private
  63. * @type {Object} #_pendingAction
  64. */
  65. this._pendingAction = null;
  66. /**
  67. * A factory function which should be defined before using `FileRepository`.
  68. *
  69. * It should return a new instance of {@link module:upload/filerepository~UploadAdapter} that will be used to upload files.
  70. * {@link module:upload/filerepository~FileLoader} instance associated with the adapter
  71. * will be passed to that function.
  72. *
  73. * For more information and example see {@link module:upload/filerepository~UploadAdapter}.
  74. *
  75. * @member {Function} #createUploadAdapter
  76. */
  77. /**
  78. * Number of bytes uploaded.
  79. *
  80. * @readonly
  81. * @observable
  82. * @member {Number} #uploaded
  83. */
  84. this.set( 'uploaded', 0 );
  85. /**
  86. * Number of total bytes to upload.
  87. *
  88. * It might be different than the file size because of headers and additional data.
  89. * It contains `null` if value is not available yet, so it's better to use {@link #uploadedPercent} to monitor
  90. * the progress.
  91. *
  92. * @readonly
  93. * @observable
  94. * @member {Number|null} #uploadTotal
  95. */
  96. this.set( 'uploadTotal', null );
  97. /**
  98. * Upload progress in percents.
  99. *
  100. * @readonly
  101. * @observable
  102. * @member {Number} #uploadedPercent
  103. */
  104. this.bind( 'uploadedPercent' ).to( this, 'uploaded', this, 'uploadTotal', ( uploaded, total ) => {
  105. return total ? ( uploaded / total * 100 ) : 0;
  106. } );
  107. }
  108. /**
  109. * Returns the loader associated with specified file.
  110. *
  111. * To get loader by id use `fileRepository.loaders.get( id )`.
  112. *
  113. * @param {File} file Native file handle.
  114. * @returns {module:upload/filerepository~FileLoader|null}
  115. */
  116. getLoader( file ) {
  117. for ( const loader of this.loaders ) {
  118. if ( loader.file == file ) {
  119. return loader;
  120. }
  121. }
  122. return 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} file Native File object.
  130. * @returns {module:upload/filerepository~FileLoader|null}
  131. */
  132. createLoader( file ) {
  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. * See:
  143. *
  144. * * {@link module:core/editor/editorconfig~EditorConfig#cloudServices `config.cloudServices`} for
  145. * Easy Image with Cloud Services integration,
  146. * * {@link module:core/editor/editorconfig~EditorConfig#ckfinder `config.ckfinder`} for CKFinder
  147. * file upload integration.
  148. *
  149. * **If you see this warning when using a custom build** there is a chance that you enabled
  150. * a feature like {@link module:image/imageupload~ImageUpload},
  151. * or {@link module:image/imageupload/imageuploadui~ImageUploadUI} but you did not enable any upload adapter.
  152. * You can choose one of the existing upload adapters:
  153. *
  154. * * {@link module:easy-image/cloudservicesuploadadapter~CloudServicesUploadAdapter}
  155. * (remember to {@link module:core/editor/editorconfig~EditorConfig#cloudServices configure it})
  156. * * {@link module:adapter-ckfinder/uploadadapter~CKFinderUploadAdapter}
  157. * (remember to {@link module:core/editor/editorconfig~EditorConfig#ckfinder configure it})
  158. *
  159. * You can also implement your own upload adapter (in which case, please refer
  160. * to the {@link module:upload/filerepository~UploadAdapter `UploadAdapter` interface} documentation).
  161. *
  162. * @error filerepository-no-upload-adapter
  163. */
  164. log.error( 'filerepository-no-upload-adapter: Upload adapter is not defined.' );
  165. return null;
  166. }
  167. const loader = new FileLoader( file );
  168. loader._adapter = this.createUploadAdapter( loader );
  169. this.loaders.add( loader );
  170. loader.on( 'change:uploaded', () => {
  171. let aggregatedUploaded = 0;
  172. for ( const loader of this.loaders ) {
  173. aggregatedUploaded += loader.uploaded;
  174. }
  175. this.uploaded = aggregatedUploaded;
  176. } );
  177. loader.on( 'change:uploadTotal', () => {
  178. let aggregatedTotal = 0;
  179. for ( const loader of this.loaders ) {
  180. if ( loader.uploadTotal ) {
  181. aggregatedTotal += loader.uploadTotal;
  182. }
  183. }
  184. this.uploadTotal = aggregatedTotal;
  185. } );
  186. return loader;
  187. }
  188. /**
  189. * Destroys the given loader.
  190. *
  191. * @param {File|module:upload/filerepository~FileLoader} fileOrLoader File associated with that loader or loader
  192. * itself.
  193. */
  194. destroyLoader( fileOrLoader ) {
  195. const loader = fileOrLoader instanceof FileLoader ? fileOrLoader : this.getLoader( fileOrLoader );
  196. loader._destroy();
  197. this.loaders.remove( loader );
  198. }
  199. /**
  200. * Registers or deregisters pending action bound with upload progress.
  201. *
  202. * @private
  203. */
  204. _updatePendingAction() {
  205. const pendingActions = this.editor.plugins.get( PendingActions );
  206. if ( this.loaders.length ) {
  207. if ( !this._pendingAction ) {
  208. const t = this.editor.t;
  209. const getMessage = value => `${ t( 'Upload in progress' ) } ${ parseInt( value ) }%.`;
  210. this._pendingAction = pendingActions.add( getMessage( this.uploadedPercent ) );
  211. this._pendingAction.bind( 'message' ).to( this, 'uploadedPercent', getMessage );
  212. }
  213. } else {
  214. pendingActions.remove( this._pendingAction );
  215. }
  216. }
  217. }
  218. mix( FileRepository, ObservableMixin );
  219. /**
  220. * File loader class.
  221. * It is used to control the process of file reading and uploading using specified adapter.
  222. */
  223. class FileLoader {
  224. /**
  225. * Creates a new instance of `FileLoader`.
  226. *
  227. * @param {File} file A native file instance.
  228. * @param {module:upload/filerepository~UploadAdapter} adapter
  229. */
  230. constructor( file, adapter ) {
  231. /**
  232. * Unique id of FileLoader instance.
  233. *
  234. * @readonly
  235. * @member {Number}
  236. */
  237. this.id = uid();
  238. /**
  239. * A `File` instance associated with this file loader.
  240. *
  241. * @readonly
  242. * @member {File}
  243. */
  244. this.file = file;
  245. /**
  246. * Adapter instance associated with this file loader.
  247. *
  248. * @private
  249. * @member {module:upload/filerepository~UploadAdapter}
  250. */
  251. this._adapter = adapter;
  252. /**
  253. * FileReader used by FileLoader.
  254. *
  255. * @protected
  256. * @member {module:upload/filereader~FileReader}
  257. */
  258. this._reader = new FileReader();
  259. /**
  260. * Current status of FileLoader. It can be one of the following:
  261. *
  262. * * 'idle',
  263. * * 'reading',
  264. * * 'uploading',
  265. * * 'aborted',
  266. * * 'error'.
  267. *
  268. * When reading status can change in a following way:
  269. *
  270. * `idle` -> `reading` -> `idle`
  271. * `idle` -> `reading -> `aborted`
  272. * `idle` -> `reading -> `error`
  273. *
  274. * When uploading status can change in a following way:
  275. *
  276. * `idle` -> `uploading` -> `idle`
  277. * `idle` -> `uploading` -> `aborted`
  278. * `idle` -> `uploading` -> `error`
  279. *
  280. * @readonly
  281. * @observable
  282. * @member {String} #status
  283. */
  284. this.set( 'status', 'idle' );
  285. /**
  286. * Number of bytes uploaded.
  287. *
  288. * @readonly
  289. * @observable
  290. * @member {Number} #uploaded
  291. */
  292. this.set( 'uploaded', 0 );
  293. /**
  294. * Number of total bytes to upload.
  295. *
  296. * @readonly
  297. * @observable
  298. * @member {Number|null} #uploadTotal
  299. */
  300. this.set( 'uploadTotal', null );
  301. /**
  302. * Upload progress in percents.
  303. *
  304. * @readonly
  305. * @observable
  306. * @member {Number} #uploadedPercent
  307. */
  308. this.bind( 'uploadedPercent' ).to( this, 'uploaded', this, 'uploadTotal', ( uploaded, total ) => {
  309. return total ? ( uploaded / total * 100 ) : 0;
  310. } );
  311. /**
  312. * Response of the upload.
  313. *
  314. * @readonly
  315. * @observable
  316. * @member {Object|null} #uploadResponse
  317. */
  318. this.set( 'uploadResponse', null );
  319. }
  320. /**
  321. * Reads file using {@link module:upload/filereader~FileReader}.
  322. *
  323. * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `filerepository-read-wrong-status` when status
  324. * is different than `idle`.
  325. *
  326. * Example usage:
  327. *
  328. * fileLoader.read()
  329. * .then( data => { ... } )
  330. * .catch( err => {
  331. * if ( err === 'aborted' ) {
  332. * console.log( 'Reading aborted.' );
  333. * } else {
  334. * console.log( 'Reading error.', err );
  335. * }
  336. * } );
  337. *
  338. * @returns {Promise} Returns promise that will be resolved with read data. Promise will be rejected if error
  339. * occurs or if read process is aborted.
  340. */
  341. read() {
  342. if ( this.status != 'idle' ) {
  343. throw new CKEditorError( 'filerepository-read-wrong-status: You cannot call read if the status is different than idle.' );
  344. }
  345. this.status = 'reading';
  346. return this._reader.read( this.file )
  347. .then( data => {
  348. this.status = 'idle';
  349. return data;
  350. } )
  351. .catch( err => {
  352. if ( err === 'aborted' ) {
  353. this.status = 'aborted';
  354. throw 'aborted';
  355. }
  356. this.status = 'error';
  357. throw this._reader.error;
  358. } );
  359. }
  360. /**
  361. * Reads file using the provided {@link module:upload/filerepository~UploadAdapter}.
  362. *
  363. * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `filerepository-upload-wrong-status` when status
  364. * is different than `idle`.
  365. * Example usage:
  366. *
  367. * fileLoader.upload()
  368. * .then( data => { ... } )
  369. * .catch( e => {
  370. * if ( e === 'aborted' ) {
  371. * console.log( 'Uploading aborted.' );
  372. * } else {
  373. * console.log( 'Uploading error.', e );
  374. * }
  375. * } );
  376. *
  377. * @returns {Promise} Returns promise that will be resolved with response data. Promise will be rejected if error
  378. * occurs or if read process is aborted.
  379. */
  380. upload() {
  381. if ( this.status != 'idle' ) {
  382. throw new CKEditorError( 'filerepository-upload-wrong-status: You cannot call upload if the status is different than idle.' );
  383. }
  384. this.status = 'uploading';
  385. return this._adapter.upload()
  386. .then( data => {
  387. this.uploadResponse = data;
  388. this.status = 'idle';
  389. return data;
  390. } )
  391. .catch( err => {
  392. if ( this.status === 'aborted' ) {
  393. throw 'aborted';
  394. }
  395. this.status = 'error';
  396. throw err;
  397. } );
  398. }
  399. /**
  400. * Aborts loading process.
  401. */
  402. abort() {
  403. const status = this.status;
  404. this.status = 'aborted';
  405. if ( status == 'reading' ) {
  406. this._reader.abort();
  407. }
  408. if ( status == 'uploading' && this._adapter.abort ) {
  409. this._adapter.abort();
  410. }
  411. this._destroy();
  412. }
  413. /**
  414. * Performs cleanup.
  415. *
  416. * @private
  417. */
  418. _destroy() {
  419. this._reader = undefined;
  420. this._adapter = undefined;
  421. this.data = undefined;
  422. this.uploadResponse = undefined;
  423. this.file = undefined;
  424. }
  425. }
  426. mix( FileLoader, ObservableMixin );
  427. /**
  428. * Upload adapter interface used by FileRepository to handle file upload. Upload adapter is a bridge between the editor and server that
  429. * handles file uploads. It should contain logic necessary to initiate upload process and monitor its progress.
  430. *
  431. * It should implement two methods:
  432. *
  433. * * {@link module:upload/filerepository~UploadAdapter#upload `upload()`},
  434. * * {@link module:upload/filerepository~UploadAdapter#abort `abort()`}.
  435. *
  436. * Example upload adapter implementation:
  437. *
  438. * class UploadAdapter {
  439. * constructor( loader ) {
  440. * // Save Loader instance to update upload progress.
  441. * this.loader = loader;
  442. * }
  443. *
  444. * upload() {
  445. * // Update loader's progress.
  446. * server.onUploadProgress( data => {
  447. * loader.uploadTotal = data.total;
  448. * loader.uploaded = data.uploaded;
  449. * } ):
  450. *
  451. * // Return promise that will be resolved when file is uploaded.
  452. * return server.upload( loader.file );
  453. * }
  454. *
  455. * abort() {
  456. * // Reject promise returned from upload() method.
  457. * server.abortUpload();
  458. * }
  459. * }
  460. *
  461. * Then upload adapter can be set to be used by {@link module:upload/filerepository~FileRepository FileRepository}:
  462. *
  463. * editor.plugins.get( 'FileRepository' ).createUploadAdapter = function( loader ) {
  464. * return new UploadAdapter( loader );
  465. * };
  466. *
  467. * @interface UploadAdapter
  468. */
  469. /**
  470. * Executes the upload process.
  471. * This method should return a promise that will resolve when data will be uploaded to server. Promise should be
  472. * resolved with an object containing information about uploaded file:
  473. *
  474. * {
  475. * default: 'http://server/default-size.image.png'
  476. * }
  477. *
  478. * Additionally, other image sizes can be provided:
  479. *
  480. * {
  481. * default: 'http://server/default-size.image.png',
  482. * '160': 'http://server/size-160.image.png',
  483. * '500': 'http://server/size-500.image.png',
  484. * '1000': 'http://server/size-1000.image.png',
  485. * '1052': 'http://server/default-size.image.png'
  486. * }
  487. *
  488. * NOTE: When returning multiple images, the widest returned one should equal the default one. It is essential to
  489. * correctly set `width` attribute of the image. See this discussion:
  490. * https://github.com/ckeditor/ckeditor5-easy-image/issues/4 for more information.
  491. *
  492. * Take a look at {@link module:upload/filerepository~UploadAdapter example Adapter implementation} and
  493. * {@link module:upload/filerepository~FileRepository#createUploadAdapter createUploadAdapter method}.
  494. *
  495. * @method module:upload/filerepository~UploadAdapter#upload
  496. * @returns {Promise} Promise that should be resolved when data is uploaded.
  497. */
  498. /**
  499. * Aborts the upload process.
  500. * After aborting it should reject promise returned from {@link #upload upload()}.
  501. *
  502. * Take a look at {@link module:upload/filerepository~UploadAdapter example Adapter implementation} and
  503. * {@link module:upload/filerepository~FileRepository#createUploadAdapter createUploadAdapter method}.
  504. *
  505. * @method module:upload/filerepository~UploadAdapter#abort
  506. */