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