@ckeditor/ckeditor5-ckbox 34.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,395 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
3
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4
+ */
5
+
6
+ /* globals AbortController, FormData, URL, Image, XMLHttpRequest, window */
7
+
8
+ /**
9
+ * @module ckbox/ckboxuploadadapter
10
+ */
11
+
12
+ import { Plugin } from 'ckeditor5/src/core';
13
+ import { FileRepository } from 'ckeditor5/src/upload';
14
+ import { logError } from 'ckeditor5/src/utils';
15
+ import CKBoxEditing from './ckboxediting';
16
+ import { getImageUrls } from './utils';
17
+
18
+ /**
19
+ * A plugin that enables file uploads in CKEditor 5 using the CKBox server–side connector.
20
+ * See the {@glink features/images/image-upload/ckbox CKBox file manager integration} guide to learn how to configure
21
+ * and use this feature as well as find out more about the full integration with the file manager
22
+ * provided by the {@link module:ckbox/ckbox~CKBox} plugin.
23
+ *
24
+ * Check out the {@glink features/images/image-upload/image-upload Image upload overview} guide to learn about
25
+ * other ways to upload images into CKEditor 5.
26
+ *
27
+ * @extends module:core/plugin~Plugin
28
+ */
29
+ export default class CKBoxUploadAdapter extends Plugin {
30
+ /**
31
+ * @inheritDoc
32
+ */
33
+ static get requires() {
34
+ return [ 'ImageUploadEditing', 'ImageUploadProgress', FileRepository, CKBoxEditing ];
35
+ }
36
+
37
+ /**
38
+ * @inheritDoc
39
+ */
40
+ static get pluginName() {
41
+ return 'CKBoxUploadAdapter';
42
+ }
43
+
44
+ /**
45
+ * @inheritDoc
46
+ */
47
+ async afterInit() {
48
+ const editor = this.editor;
49
+
50
+ const hasConfiguration = !!editor.config.get( 'ckbox' );
51
+ const isLibraryLoaded = !!window.CKBox;
52
+
53
+ // Editor supports only one upload adapter. Register the CKBox upload adapter (and potentially overwrite other one) only when the
54
+ // integrator intentionally wants to use the CKBox plugin, i.e. when the `config.ckbox` exists or the CKBox JavaScript library is
55
+ // loaded.
56
+ if ( !hasConfiguration && !isLibraryLoaded ) {
57
+ return;
58
+ }
59
+
60
+ const fileRepository = editor.plugins.get( FileRepository );
61
+ const ckboxEditing = editor.plugins.get( CKBoxEditing );
62
+
63
+ fileRepository.createUploadAdapter = loader => {
64
+ return new Adapter( loader, ckboxEditing.getToken(), editor );
65
+ };
66
+
67
+ const shouldInsertDataId = !editor.config.get( 'ckbox.ignoreDataId' );
68
+ const imageUploadEditing = editor.plugins.get( 'ImageUploadEditing' );
69
+
70
+ // Mark uploaded assets with the `ckboxImageId` attribute. Its value represents an ID in CKBox.
71
+ if ( shouldInsertDataId ) {
72
+ imageUploadEditing.on( 'uploadComplete', ( evt, { imageElement, data } ) => {
73
+ editor.model.change( writer => {
74
+ writer.setAttribute( 'ckboxImageId', data.ckboxImageId, imageElement );
75
+ } );
76
+ } );
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Upload adapter for CKBox.
83
+ *
84
+ * @private
85
+ * @implements module:upload/filerepository~UploadAdapter
86
+ */
87
+ class Adapter {
88
+ /**
89
+ * Creates a new adapter instance.
90
+ *
91
+ * @param {module:upload/filerepository~FileLoader} loader
92
+ * @param {module:cloud-services/token~Token} token
93
+ * @param {module:core/editor/editor~Editor} editor
94
+ */
95
+ constructor( loader, token, editor ) {
96
+ /**
97
+ * FileLoader instance to use during the upload.
98
+ *
99
+ * @member {module:upload/filerepository~FileLoader} #loader
100
+ */
101
+ this.loader = loader;
102
+
103
+ /**
104
+ * CKEditor Cloud Services access token.
105
+ *
106
+ * @member {module:cloud-services/token~Token} #token
107
+ */
108
+ this.token = token;
109
+
110
+ /**
111
+ * The editor instance.
112
+ *
113
+ * @member {module:core/editor/editor~Editor} #editor
114
+ */
115
+ this.editor = editor;
116
+
117
+ /**
118
+ * The abort controller for aborting asynchronous processes.
119
+ *
120
+ * @member {AbortController} #controller
121
+ */
122
+ this.controller = new AbortController();
123
+
124
+ /**
125
+ * The base URL where all requests should be sent.
126
+ *
127
+ * @member {String} #serviceOrigin
128
+ */
129
+ this.serviceOrigin = editor.config.get( 'ckbox.serviceOrigin' );
130
+
131
+ /**
132
+ * The base URL from where all assets are served.
133
+ *
134
+ * @member {String} #assetsOrigin
135
+ */
136
+ this.assetsOrigin = editor.config.get( 'ckbox.assetsOrigin' );
137
+ }
138
+
139
+ /**
140
+ * Resolves a promise with an array containing available categories with which the uploaded file can be associated.
141
+ *
142
+ * If the API returns limited results, the method will collect all items.
143
+ *
144
+ * @param {Number} [offset=0]
145
+ * @returns {Promise.<Array>}
146
+ */
147
+ async getAvailableCategories( offset = 0 ) {
148
+ const ITEMS_PER_REQUEST = 50;
149
+ const categoryUrl = new URL( 'categories', this.serviceOrigin );
150
+
151
+ categoryUrl.searchParams.set( 'limit', ITEMS_PER_REQUEST.toString() );
152
+ categoryUrl.searchParams.set( 'offset', offset.toString() );
153
+
154
+ return this._sendHttpRequest( { url: categoryUrl } )
155
+ .then( async data => {
156
+ const remainingItems = data.totalCount - ( offset + ITEMS_PER_REQUEST );
157
+
158
+ if ( remainingItems > 0 ) {
159
+ const offsetItems = await this.getAvailableCategories( offset + ITEMS_PER_REQUEST );
160
+
161
+ return [
162
+ ...data.items,
163
+ ...offsetItems
164
+ ];
165
+ }
166
+
167
+ return data.items;
168
+ } )
169
+ .catch( () => {
170
+ this.controller.signal.throwIfAborted();
171
+
172
+ /**
173
+ * Fetching a list of available categories with which an uploaded file can be associated failed.
174
+ *
175
+ * @error ckbox-fetch-category-http-error
176
+ */
177
+ logError( 'ckbox-fetch-category-http-error' );
178
+ } );
179
+ }
180
+
181
+ /**
182
+ * Resolves a promise with an object containing a category with which the uploaded file is associated or an error code.
183
+ *
184
+ * @param {File} file
185
+ * @return {Promise.<String|null>}
186
+ */
187
+ async getCategoryIdForFile( file ) {
188
+ const extension = getFileExtension( file.name );
189
+ const allCategories = await this.getAvailableCategories();
190
+
191
+ // Couldn't fetch all categories. Perhaps the authorization token is invalid.
192
+ if ( !allCategories ) {
193
+ return null;
194
+ }
195
+
196
+ // The plugin allows defining to which category the uploaded file should be assigned.
197
+ const defaultCategories = this.editor.config.get( 'ckbox.defaultUploadCategories' );
198
+
199
+ // If a user specifies the plugin configuration, find the first category that accepts the uploaded file.
200
+ if ( defaultCategories ) {
201
+ const userCategory = Object.keys( defaultCategories ).find( category => {
202
+ return defaultCategories[ category ].includes( extension );
203
+ } );
204
+
205
+ // If found, return its ID if the category exists on the server side.
206
+ if ( userCategory ) {
207
+ const serverCategory = allCategories.find( category => category.id === userCategory || category.name === userCategory );
208
+
209
+ if ( !serverCategory ) {
210
+ return null;
211
+ }
212
+
213
+ return serverCategory.id;
214
+ }
215
+ }
216
+
217
+ // Otherwise, find the first category that accepts the uploaded file and returns its ID.
218
+ const category = allCategories.find( category => category.extensions.includes( extension ) );
219
+
220
+ if ( !category ) {
221
+ return null;
222
+ }
223
+
224
+ return category.id;
225
+ }
226
+
227
+ /**
228
+ * Starts the upload process.
229
+ *
230
+ * @see module:upload/filerepository~UploadAdapter#upload
231
+ * @returns {Promise.<Object>}
232
+ */
233
+ async upload() {
234
+ const t = this.editor.t;
235
+ const cannotFindCategoryError = t( 'Cannot determine a category for the uploaded file.' );
236
+ const file = await this.loader.file;
237
+ const category = await this.getCategoryIdForFile( file );
238
+
239
+ if ( !category ) {
240
+ return Promise.reject( cannotFindCategoryError );
241
+ }
242
+
243
+ const uploadUrl = new URL( 'assets', this.serviceOrigin );
244
+ const formData = new FormData();
245
+
246
+ formData.append( 'categoryId', category );
247
+ formData.append( 'file', file );
248
+
249
+ const requestConfig = {
250
+ method: 'POST',
251
+ url: uploadUrl,
252
+ data: formData,
253
+ onUploadProgress: evt => {
254
+ /* istanbul ignore else */
255
+ if ( evt.lengthComputable ) {
256
+ this.loader.uploadTotal = evt.total;
257
+ this.loader.uploaded = evt.loaded;
258
+ }
259
+ }
260
+ };
261
+
262
+ return this._sendHttpRequest( requestConfig )
263
+ .then( async data => {
264
+ const width = await this._getImageWidth();
265
+ const extension = getFileExtension( file.name );
266
+ const imageUrls = getImageUrls( {
267
+ token: this.token,
268
+ id: data.id,
269
+ origin: this.assetsOrigin,
270
+ width,
271
+ extension
272
+ } );
273
+
274
+ return {
275
+ ckboxImageId: data.id,
276
+ default: imageUrls.imageFallbackUrl,
277
+ sources: imageUrls.imageSources
278
+ };
279
+ } )
280
+ .catch( () => {
281
+ const genericError = t( 'Cannot upload file:' ) + ` ${ file.name }.`;
282
+
283
+ return Promise.reject( genericError );
284
+ } );
285
+ }
286
+
287
+ /**
288
+ * Aborts the upload process.
289
+ *
290
+ * @see module:upload/filerepository~UploadAdapter#abort
291
+ */
292
+ abort() {
293
+ this.controller.abort();
294
+ }
295
+
296
+ /**
297
+ * Sends the HTTP request.
298
+ *
299
+ * @protected
300
+ * @param {URL} config.url the URL where the request will be sent.
301
+ * @param {'GET'|'POST'} [config.method='GET'] The HTTP method.
302
+ * @param {FormData|null} [config.data] Additional data to send.
303
+ * @param {Function} [config.onUploadProgress] A callback informing about the upload progress.
304
+ * @returns {Promise}
305
+ */
306
+ _sendHttpRequest( config ) {
307
+ const { url, data, onUploadProgress } = config;
308
+ const method = config.method || 'GET';
309
+ const signal = this.controller.signal;
310
+
311
+ const xhr = new XMLHttpRequest();
312
+ xhr.open( method, url.toString(), true );
313
+ xhr.setRequestHeader( 'Authorization', this.token.value );
314
+ xhr.setRequestHeader( 'CKBox-Version', 'CKEditor 5' );
315
+ xhr.responseType = 'json';
316
+
317
+ // The callback is attached to the `signal#abort` event.
318
+ const abortCallback = () => {
319
+ xhr.abort();
320
+ };
321
+
322
+ return new Promise( ( resolve, reject ) => {
323
+ signal.addEventListener( 'abort', abortCallback );
324
+
325
+ xhr.addEventListener( 'loadstart', () => {
326
+ signal.addEventListener( 'abort', abortCallback );
327
+ } );
328
+
329
+ xhr.addEventListener( 'loadend', () => {
330
+ signal.removeEventListener( 'abort', abortCallback );
331
+ } );
332
+
333
+ xhr.addEventListener( 'error', () => {
334
+ reject();
335
+ } );
336
+
337
+ xhr.addEventListener( 'abort', () => {
338
+ reject();
339
+ } );
340
+
341
+ xhr.addEventListener( 'load', async () => {
342
+ const response = xhr.response;
343
+
344
+ if ( !response || response.statusCode >= 400 ) {
345
+ return reject( response && response.message );
346
+ }
347
+
348
+ return resolve( response );
349
+ } );
350
+
351
+ /* istanbul ignore else */
352
+ if ( onUploadProgress ) {
353
+ xhr.upload.addEventListener( 'progress', evt => {
354
+ onUploadProgress( evt );
355
+ } );
356
+ }
357
+
358
+ // Send the request.
359
+ xhr.send( data );
360
+ } );
361
+ }
362
+
363
+ /**
364
+ * Resolves a promise with a number representing the width of a given image file.
365
+ *
366
+ * @protected
367
+ * @returns {Promise.<Number>}
368
+ */
369
+ _getImageWidth() {
370
+ return new Promise( resolve => {
371
+ const image = new Image();
372
+
373
+ image.onload = () => {
374
+ // Let the browser know that it should not keep the reference any longer to avoid memory leeks.
375
+ URL.revokeObjectURL( image.src );
376
+
377
+ resolve( image.width );
378
+ };
379
+
380
+ image.src = this.loader.data;
381
+ } );
382
+ }
383
+ }
384
+
385
+ // Returns an extension from the given value.
386
+ //
387
+ // @private
388
+ // @param {String} value
389
+ // @returns {String}
390
+ function getFileExtension( value ) {
391
+ const extensionRegExp = /\.(?<ext>[^.]+)$/;
392
+ const match = value.match( extensionRegExp );
393
+
394
+ return match.groups.ext;
395
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
3
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4
+ */
5
+
6
+ /**
7
+ * @module ckbox
8
+ */
9
+
10
+ export { default as CKBox } from './ckbox';
11
+ export { default as CKBoxEditing } from './ckboxediting';
12
+ export { default as CKBoxUI } from './ckboxui';
package/src/utils.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @license Copyright (c) 2003-2022, CKSource Holding sp. z o.o. All rights reserved.
3
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4
+ */
5
+
6
+ /* global atob, URL */
7
+
8
+ /**
9
+ * @module ckbox/utils
10
+ */
11
+
12
+ const IMAGE_BREAKPOINT_MAX_WIDTH = 4000;
13
+ const IMAGE_BREAKPOINT_PIXELS_THRESHOLD = 80;
14
+ const IMAGE_BREAKPOINT_PERCENTAGE_THRESHOLD = 10;
15
+
16
+ /**
17
+ * Creates URLs for the image:
18
+ * - responsive URLs for the "webp" image format,
19
+ * - one fallback URL for browsers that do not support the "webp" format.
20
+ *
21
+ * @param {Object} data
22
+ * @param {module:cloud-services/token~Token} data.token
23
+ * @param {String} data.id
24
+ * @param {String} data.origin
25
+ * @param {Number} data.width
26
+ * @param {String} data.extension
27
+ * @returns {Object}
28
+ */
29
+ export function getImageUrls( { token, id, origin, width, extension } ) {
30
+ const environmentId = getEnvironmentId( token );
31
+ const imageBreakpoints = getImageBreakpoints( width );
32
+ const imageFallbackExtension = getImageFallbackExtension( extension );
33
+ const imageFallbackUrl = getResponsiveImageUrl( { environmentId, id, origin, width, extension: imageFallbackExtension } );
34
+ const imageResponsiveUrls = imageBreakpoints.map( imageBreakpoint => {
35
+ const responsiveImageUrl = getResponsiveImageUrl( { environmentId, id, origin, width: imageBreakpoint, extension: 'webp' } );
36
+
37
+ return `${ responsiveImageUrl } ${ imageBreakpoint }w`;
38
+ } );
39
+
40
+ // Create just one image source definition containing all calculated URLs for each image breakpoint. Additionally, limit this source
41
+ // image width by defining two allowed slot sizes:
42
+ // - If the viewport width is not greater than the image width, make the image occupy the whole slot.
43
+ // - Otherwise, limit the slot width to be equal to the image width, to avoid enlarging the image beyond its width.
44
+ //
45
+ // This is a kind of a workaround. In a perfect world we could use `sizes="100vw" width="real image width"` on our single `<source>`
46
+ // element, but at the time of writing this code the `width` attribute is not supported in the `<source>` element in Firefox yet.
47
+ const imageSources = [ {
48
+ srcset: imageResponsiveUrls.join( ',' ),
49
+ sizes: `(max-width: ${ width }px) 100vw, ${ width }px`,
50
+ type: 'image/webp'
51
+ } ];
52
+
53
+ return {
54
+ imageFallbackUrl,
55
+ imageSources
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Returns an environment id from a token used for communication with the CKBox service.
61
+ *
62
+ * @param {module:cloud-services/token~Token} token
63
+ * @returns {String}
64
+ */
65
+ export function getEnvironmentId( token ) {
66
+ const [ , binaryTokenPayload ] = token.value.split( '.' );
67
+ const payload = JSON.parse( atob( binaryTokenPayload ) );
68
+
69
+ return payload.aud;
70
+ }
71
+
72
+ // Calculates the image breakpoints for provided image width in the following way:
73
+ //
74
+ // 1) The breakpoint threshold (the breakpoint step in the calculations) should be equal to 10% of the image width, but not less than 80
75
+ // pixels.
76
+ //
77
+ // 2) Set the max. allowed image breakpoint (4000px) or the image width (if it is smaller than 4000px) as the first calculated breakpoint.
78
+ //
79
+ // 3) From the last computed image breakpoint subtract the computed breakpoint threshold, as long as the calculated new breakpoint value is
80
+ // greater than the threshold.
81
+ //
82
+ // @private
83
+ // @param {Number} width
84
+ // @returns {Array.<Number>}
85
+ function getImageBreakpoints( width ) {
86
+ // Step 1) - calculating the breakpoint threshold.
87
+ const imageBreakpointThresholds = [
88
+ width * IMAGE_BREAKPOINT_PERCENTAGE_THRESHOLD / 100,
89
+ IMAGE_BREAKPOINT_PIXELS_THRESHOLD
90
+ ];
91
+ const imageBreakpointThreshold = Math.floor( Math.max( ...imageBreakpointThresholds ) );
92
+
93
+ // Step 2) - set the first breakpoint.
94
+ const imageBreakpoints = [ Math.min( width, IMAGE_BREAKPOINT_MAX_WIDTH ) ];
95
+
96
+ // Step 3) - calculate the next breakpoint as long as it is greater than the breakpoint threshold.
97
+ let lastBreakpoint = imageBreakpoints[ 0 ];
98
+
99
+ while ( lastBreakpoint - imageBreakpointThreshold >= imageBreakpointThreshold ) {
100
+ lastBreakpoint -= imageBreakpointThreshold;
101
+ imageBreakpoints.unshift( lastBreakpoint );
102
+ }
103
+
104
+ return imageBreakpoints;
105
+ }
106
+
107
+ // Returns the image extension for the fallback URL.
108
+ //
109
+ // @private
110
+ // @param {String} extension
111
+ // @returns {String}
112
+ function getImageFallbackExtension( extension ) {
113
+ if ( extension === 'bmp' || extension === 'tiff' || extension === 'jpg' ) {
114
+ return 'jpeg';
115
+ }
116
+
117
+ return extension;
118
+ }
119
+
120
+ // Creates the URL for the given image.
121
+ //
122
+ // @private
123
+ // @param {Object} options
124
+ // @param {String} options.environmentId
125
+ // @param {String} options.id
126
+ // @param {String} options.origin
127
+ // @param {Number} options.width
128
+ // @param {String} options.extension
129
+ // @returns {String}
130
+ function getResponsiveImageUrl( { environmentId, id, origin, width, extension } ) {
131
+ const endpoint = `${ environmentId }/assets/${ id }/images/${ width }.${ extension }`;
132
+
133
+ return new URL( endpoint, origin ).toString();
134
+ }
@@ -0,0 +1 @@
1
+ <svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M11.627 16.5zm5.873-.196zm0-7.001V8h-13v8.5h4.341c.191.54.457 1.044.785 1.5H2a1.5 1.5 0 0 1-1.5-1.5v-13A1.5 1.5 0 0 1 2 2h4.5a1.5 1.5 0 0 1 1.06.44L9.122 4H16a1.5 1.5 0 0 1 1.5 1.5v1A1.5 1.5 0 0 1 19 8v2.531a6.027 6.027 0 0 0-1.5-1.228zM16 6.5v-1H8.5l-2-2H2v13h1V8a1.5 1.5 0 0 1 1.5-1.5H16z"/><path d="M14.5 19.5a5 5 0 1 1 0-10 5 5 0 0 1 0 10zM15 14v-2h-1v2h-2v1h2v2h1v-2h2v-1h-2z"/></svg>