@ckeditor/ckeditor5-ckbox 34.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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>